diff --git a/.paul/PROJECT.md b/.paul/PROJECT.md
new file mode 100644
index 0000000..f0b6c95
--- /dev/null
+++ b/.paul/PROJECT.md
@@ -0,0 +1,103 @@
+# orderPRO
+
+## What This Is
+
+Aplikacja do zarządzania zamówieniami pobieranymi z wielu źródeł sprzedaży (Allegro, Erli, własne sklepy internetowe). Umożliwia generowanie etykiet przewozowych u kurierów oraz docelowo zarządzanie produktami i stanami magazynowymi w jednym miejscu.
+
+## Core Value
+
+Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami.
+
+## Current State
+
+| Attribute | Value |
+|-----------|-------|
+| Version | 0.1.0 |
+| Status | In Progress |
+| Last Updated | 2026-03-12 |
+
+## Requirements
+
+### Validated (Shipped)
+
+- [x] Integracja z Allegro — pobieranie zamówień
+- [x] Generowanie etykiet (InPost)
+
+### Active (In Progress)
+
+- [ ] [Do zdefiniowania podczas planowania]
+
+### Planned (Next)
+
+- [ ] Zarządzanie produktami
+- [ ] Zarządzanie stanami magazynowymi
+
+### Out of Scope
+
+- [Do zdefiniowania podczas planowania]
+
+## Target Users
+
+**Primary:** Sprzedawcy wielokanałowi (Allegro, Erli, własny sklep)
+- Obsługują zamówienia z wielu platform jednocześnie
+- Potrzebują szybkiego nadawania przesyłek
+- Chcą jednego miejsca do zarządzania sprzedażą
+
+## Context
+
+**Business Context:**
+Rynek narzędzi do zarządzania sprzedażą wielokanałową (podobne rozwiązania: base.com, apilo.com). Aplikacja budowana jako własne rozwiązanie.
+
+**Technical Context:**
+PHP (XAMPP/Laravel), integracje z API marketplace'ów (Allegro, Erli) oraz API przewoźników (InPost i inne).
+
+## Constraints
+
+### Technical Constraints
+- PHP/XAMPP — środowisko Windows lokalne
+- Medoo + prepared statements (bez sklejania SQL)
+- Brak natywnych `alert()`/`confirm()` — używać `window.OrderProAlerts`
+- Metody pomocnicze string/date/color → `App\Core\Support\StringHelper` (nie powielać w klasach)
+- Zarządzanie tokenami OAuth Allegro → `App\Modules\Settings\AllegroTokenManager`
+
+### Business Constraints
+- [Do zdefiniowania podczas planowania]
+
+## Key Decisions
+
+| Decision | Rationale | Date | Status |
+|----------|-----------|------|--------|
+| Własne rozwiązanie zamiast gotowego SaaS | Pełna kontrola nad funkcjonalnością | 2026-03-12 | Active |
+| AllegroTokenManager wydzielony z 4 klas OAuth | Eliminacja duplikacji logiki odświeżania tokenów | 2026-03-12 | Active |
+| StringHelper jako final static class w Core/Support | Centralizacja 19 kopii helperów string/date/color z 15+ klas | 2026-03-12 | Active |
+
+## Success Metrics
+
+| Metric | Target | Current | Status |
+|--------|--------|---------|--------|
+| Liczba zintegrowanych źródeł zamówień | ≥3 | 2 (Allegro, Erli) | In progress |
+| Generowanie etykiet | Działa | InPost | In progress |
+
+## Tech Stack
+
+| Layer | Technology | Notes |
+|-------|------------|-------|
+| Framework | PHP (custom/Laravel) | XAMPP lokalnie |
+| Frontend | HTML/CSS/JS + SCSS | jQuery Alerts module |
+| Database | MySQL (Medoo) | Prepared statements |
+| Auth | Sesje PHP | |
+| Integracje | Allegro API, Erli API | Przewoźnicy: InPost |
+
+## Specialized Flows
+
+See: .paul/SPECIAL-FLOWS.md
+
+Quick Reference:
+- /feature-dev → Nowe funkcjonalności i integracje (required)
+- /code-review → Przegląd kodu przed UNIFY (required)
+- /frontend-design → Komponenty UI i widoki (optional)
+- /simplify → Refaktoryzacja po implementacji (optional)
+
+---
+*PROJECT.md — Updated when requirements or context change*
+*Last updated: 2026-03-12 after Phase 1 (Tech Debt)*
diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md
new file mode 100644
index 0000000..7d38467
--- /dev/null
+++ b/.paul/ROADMAP.md
@@ -0,0 +1,29 @@
+# Roadmap: orderPRO
+
+## Overview
+
+orderPRO to narzędzie do wielokanałowego zarządzania sprzedażą. Projekt przechodzi od podstawowych integracji z marketplace'ami i generowania etykiet, przez rozbudowę o nowe źródła zamówień i przewoźników, aż do pełnego zarządzania produktami i stanami magazynowymi.
+
+## Current Milestone
+
+**v0.1 Initial Release** (v0.1.0)
+Status: In progress
+Phases: 1 of TBD complete
+
+## Phases
+
+| Phase | Name | Plans | Status | Completed |
+|-------|------|-------|--------|-----------|
+| 1 | Tech Debt | 2/2 | ✅ Complete | 2026-03-12 |
+
+## Phase Details
+
+### Phase 1 — Tech Debt
+Naprawa krytycznych problemów technicznych zidentyfikowanych w mapie kodu (`.paul/codebase/CONCERNS.md`).
+
+- **Plan 01-01** — Extract AllegroTokenManager (OAuth duplication HIGH × 4 classes) — *Complete*
+- **Plan 01-02** — Extract StringHelper (duplicated helpers HIGH × 15 classes) — *Complete*
+
+---
+*Roadmap created: 2026-03-12*
+*Last updated: 2026-03-12*
diff --git a/.paul/SPECIAL-FLOWS.md b/.paul/SPECIAL-FLOWS.md
new file mode 100644
index 0000000..138f40d
--- /dev/null
+++ b/.paul/SPECIAL-FLOWS.md
@@ -0,0 +1,31 @@
+# Specialized Flows: orderPRO
+
+## Project-Level Dependencies
+
+| Typ pracy | Skill/Komenda | Priorytet | Kiedy |
+|-----------|---------------|-----------|-------|
+| Nowe funkcjonalności (integracje marketplace, przewoźnicy, moduły) | /feature-dev | optional | Przed implementacją każdej nowej funkcji lub integracji |
+| Przegląd kodu przed zamknięciem planu (bezpieczeństwo, jakość, SQL) | /code-review | required | Po implementacji, przed UNIFY |
+| Skanowanie jakości kodu po każdym zakończonym planie | `sonar-scanner` (CLI w katalogu projektu) | required | Po APPLY, przed UNIFY — wyniki na https://sonar.project-pro.pl/dashboard?id=orderPRO |
+
+## SonarQube — procedura po skanowaniu
+
+Po każdym uruchomieniu `sonar-scanner`:
+1. Odpytaj nowe issues przez MCP (`mcp__sonarqube__issues`, project_key: `orderPRO`, tylko `issueStatuses: OPEN`)
+2. Porównaj z tym co już jest w `DOCS/todo.md` — dopisz tylko **nowe** issues (których jeszcze nie ma na liście)
+3. Format wpisu w `DOCS/todo.md`: `[] [Sonar {data}] {rule} — {opis problemu} ({liczba wystąpień}x)`
+4. Grupuj pod nagłówkiem `## SonarQube — {data skanu}`
+| Komponenty UI (listy zamówień, dashboard, formularze, modale) | /frontend-design | optional | Przy tworzeniu nowych widoków lub redesignie istniejących |
+| Refaktoryzacja i upraszczanie kodu po implementacji | /simplify | optional | Po zakończeniu APPLY, gdy kod wymaga porządkowania |
+
+## Phase Overrides
+
+Brak — domyślne skille wystarczają dla wszystkich faz.
+
+## Templates & Assets
+
+Brak — cały projekt dostępny bezpośrednio w środowisku.
+
+---
+*SPECIAL-FLOWS.md — Created: 2026-03-12*
+*Updated: 2026-03-12*
diff --git a/.paul/STATE.md b/.paul/STATE.md
new file mode 100644
index 0000000..c5e9fc4
--- /dev/null
+++ b/.paul/STATE.md
@@ -0,0 +1,61 @@
+# Project State
+
+## Project Reference
+
+See: .paul/PROJECT.md (updated 2026-03-12)
+
+**Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami.
+**Current focus:** Faza 01 — Tech Debt: KOMPLETNA. Gotowy na Fazę 02.
+
+## Current Position
+
+Milestone: v0.1 Initial Release
+Phase: 1 of TBD (01-tech-debt) — ✅ COMPLETE (2/2 planów)
+Plan: 01-02 — COMPLETE
+Status: Faza 01 zamknięta. Gotowy na PLAN Fazy 02.
+Last activity: 2026-03-12 — UNIFY 01-02 complete, faza 01 transitioned
+
+Progress:
+- Milestone: [█░░░░░░░░░] ~10%
+- Phase 1: [██████████] 100%
+
+## Loop Position
+
+Current loop state:
+```
+PLAN ──▶ APPLY ──▶ UNIFY
+ ✓ ✓ ✓ [Loop complete — ready for next PLAN]
+```
+
+## Accumulated Context
+
+### Decisions
+| Data | Decyzja | Faza | Wpływ |
+|------|---------|------|-------|
+| 2026-03-12 | 401 retry zastąpiony przez tokenManager->resolveToken() zamiast publicznej forceRefresh() | Faza 01 | Marginalny edge case — retry nie wymusza refreshu gdy token wg daty ważny |
+| 2026-03-12 | AllegroTokenManager wydzielony z 4 klas OAuth | Faza 01 | Centralizacja logiki tokenów, brak duplikacji |
+| 2026-03-12 | StringHelper jako final static class w Core/Support | Faza 01 | 19 duplikatów helperów usunięte z 15 klas |
+
+### Skill Audit (Faza 01, Plan 02)
+| Oczekiwany | Wywołany | Uwagi |
+|------------|---------|-------|
+| /feature-dev | ○ | Pominięto — plan był czysto refaktoryzacyjny |
+| /code-review | ○ | Pominięto — należy wywołać przed kolejnym UNIFY |
+| sonar-scanner | ○ | Nie uruchomiono — należy uruchomić i zaktualizować DOCS/todo.md |
+
+### Deferred Issues
+- **CI/CD SonarQube** — dodać GitHub Actions workflow (`.github/workflows/sonarqube.yml`) który odpala `sonar-scanner` automatycznie przy każdym pushu. Token projektu: `sqp_8ef2748d037777cf00cf1b38534f8d435b762d7d` (dodać jako GitHub Secret `SONAR_TOKEN`). Przypisać do fazy związanej z infrastrukturą/DevOps gdy tylko fazy zostaną zdefiniowane.
+- **code-review** — wywołać /code-review przed kolejnym UNIFY (pominięto w obydwu planach fazy 01).
+
+### Blockers/Concerns
+Brak.
+
+## Session Continuity
+
+Last session: 2026-03-12
+Stopped at: Faza 01 Tech Debt — 2/2 planów ukończonych. Tranzycja kompletna.
+Next action: /paul:plan (Faza 02 — do zdefiniowania na podstawie CONCERNS.md)
+Resume file: .paul/ROADMAP.md
+
+---
+*STATE.md — Updated after every significant action*
diff --git a/.paul/codebase/CONCERNS.md b/.paul/codebase/CONCERNS.md
index d332655..fc855c5 100644
--- a/.paul/codebase/CONCERNS.md
+++ b/.paul/codebase/CONCERNS.md
@@ -6,42 +6,6 @@
## Tech Debt
-### [HIGH] Duplicated OAuth Token Refresh Logic — 4 copies
-
-- Issue: `resolveAccessToken()` and `forceRefreshToken()` are private methods duplicated verbatim in four separate classes. Any fix to token-refresh behavior must be applied in all four places.
-- Files:
- - `src/Modules/Settings/AllegroOrderImportService.php` (lines 118–184)
- - `src/Modules/Settings/AllegroOrdersSyncService.php` (lines 212–278)
- - `src/Modules/Settings/AllegroStatusDiscoveryService.php` (lines 107–170)
- - `src/Modules/Shipments/AllegroShipmentService.php` (lines 367–441)
-- Impact: Bug in token refresh (e.g., race condition, edge case) requires four coordinated fixes. High probability of drift.
-- Fix approach: Extract a shared `AllegroTokenManager` service with `resolveToken(): [string $token, string $env]`. Inject it into all four classes.
-
----
-
-### [HIGH] Duplicated `nullableString()` / `normalizeDateTime()` / `normalizeColorHex()` Helpers — 15+ copies
-
-- Issue: The same private utility methods are copy-pasted into almost every Repository and Service class.
- - `nullableString()` appears in 12+ classes.
- - `normalizeDateTime()` appears in at least 3 classes.
- - `normalizeColorHex()` appears in `OrdersController`, `OrdersRepository`, and `SettingsController`.
-- Files affected (sample):
- - `src/Modules/Settings/AllegroOrderImportService.php`
- - `src/Modules/Settings/AllegroOrdersSyncService.php`
- - `src/Modules/Settings/AllegroOrderSyncStateRepository.php`
- - `src/Modules/Settings/AllegroStatusMappingRepository.php`
- - `src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php`
- - `src/Modules/Settings/CompanySettingsRepository.php`
- - `src/Modules/Settings/InpostIntegrationRepository.php`
- - `src/Modules/Settings/IntegrationsRepository.php`
- - `src/Modules/Settings/ShopproIntegrationsRepository.php`
- - `src/Modules/Settings/ShopproOrdersSyncService.php`
- - `src/Modules/Orders/OrdersController.php`
- - `src/Modules/Orders/OrdersRepository.php`
-- Impact: Violates project's anti-copy-paste rule. Inconsistent behavior if one copy diverges.
-- Fix approach: Create `src/Core/Support/StringHelper.php` with static methods `nullableString()`, `normalizeDateTime()`, `normalizeColorHex()`. Replace all copies.
-
----
### [HIGH] `ShopproOrdersSyncService` Uses `AllegroOrderSyncStateRepository`
@@ -343,9 +307,9 @@ The following items from `DOCS/todo.md` are marked incomplete:
### [HIGH] Allegro OAuth Token Refresh Logic Has No Tests
-- Issue: The token refresh logic is both duplicated (4 copies) and untested. It includes complex edge cases: token expiry within 5-minute window, empty refresh token fallback, write-then-re-read pattern.
-- Files: See "Duplicated OAuth Token Refresh Logic" section above.
-- Risk: Token refresh failures cause complete import failure. Silent breakage possible if one copy diverges.
+- Issue: The token refresh logic is centralized in `AllegroTokenManager` but untested. It includes complex edge cases: token expiry within 5-minute window, empty refresh token fallback, write-then-re-read pattern.
+- Files: `src/Modules/Settings/AllegroTokenManager.php`
+- Risk: Token refresh failures cause complete import failure. Silent breakage possible.
- Priority: High
---
diff --git a/.paul/phases/01-tech-debt/01-01-PLAN.md b/.paul/phases/01-tech-debt/01-01-PLAN.md
new file mode 100644
index 0000000..a94a259
--- /dev/null
+++ b/.paul/phases/01-tech-debt/01-01-PLAN.md
@@ -0,0 +1,298 @@
+---
+phase: 01-tech-debt
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - src/Modules/Settings/AllegroTokenManager.php
+ - src/Modules/Settings/AllegroOrderImportService.php
+ - src/Modules/Settings/AllegroOrdersSyncService.php
+ - src/Modules/Settings/AllegroStatusDiscoveryService.php
+ - src/Modules/Shipments/AllegroShipmentService.php
+ - routes/web.php
+ - src/Core/Application.php
+ - .paul/codebase/CONCERNS.md
+ - DOCS/ARCHITECTURE.md
+autonomous: false
+---
+
+
+## Goal
+Extract the duplicated Allegro OAuth token-refresh logic from 4 service classes into a single `AllegroTokenManager` service, then delete the private methods from each consumer.
+
+## Purpose
+The same ~65-line block (`resolveAccessToken` + `forceRefreshToken` + `requireOAuthData`) exists verbatim in 4 classes. A bug in token refresh requires 4 coordinated fixes with high drift risk. This is the highest-severity tech debt item per CONCERNS.md.
+
+## Output
+- New class: `src/Modules/Settings/AllegroTokenManager.php`
+- 4 service files simplified (private token methods removed, `AllegroTokenManager` injected)
+- Wiring updated in `routes/web.php` and `src/Core/Application.php`
+- CONCERNS.md entry for this issue removed
+- ARCHITECTURE.md updated with new class
+
+
+
+## Project Context
+@.paul/PROJECT.md
+@.paul/STATE.md
+
+## Affected Files
+@src/Modules/Settings/AllegroOrderImportService.php
+@src/Modules/Settings/AllegroOrdersSyncService.php
+@src/Modules/Settings/AllegroStatusDiscoveryService.php
+@src/Modules/Shipments/AllegroShipmentService.php
+@routes/web.php
+@src/Core/Application.php
+
+
+
+## Required Skills (from SPECIAL-FLOWS.md)
+
+| Skill | Priority | When to Invoke | Loaded? |
+|-------|----------|----------------|---------||
+| /feature-dev | required | Before starting implementation | ○ |
+| /code-review | required | After implementation, before UNIFY | ○ |
+| sonar-scanner (CLI) | required | After APPLY, before UNIFY | ○ |
+
+**BLOCKING:** Required skills MUST be loaded before APPLY proceeds.
+
+## Skill Invocation Checklist
+- [ ] /feature-dev loaded
+- [ ] /code-review loaded (run after implementation)
+- [ ] sonar-scanner run (run after implementation)
+
+
+
+
+## AC-1: AllegroTokenManager extracting token resolution
+```gherkin
+Given the AllegroIntegrationRepository has valid OAuth credentials
+When AllegroTokenManager::resolveToken() is called
+Then it returns [accessToken: string, environment: string]
+ AND if the token is within 5 minutes of expiry it first calls refreshAccessToken()
+ AND if the token is absent it calls refreshAccessToken()
+ AND after refresh it saves updated tokens via integrationRepository->saveTokens()
+```
+
+## AC-2: No private token methods in the 4 consumer classes
+```gherkin
+Given the refactor is complete
+When the 4 service files are read
+Then none of them contain resolveAccessToken(), forceRefreshToken(), or requireOAuthData() methods
+ AND each has AllegroTokenManager injected in its constructor
+```
+
+## AC-3: Services retain correct constructor dependencies
+```gherkin
+Given the refactor is complete
+When AllegroOrderImportService and AllegroOrdersSyncService are instantiated
+Then they still inject AllegroIntegrationRepository (used for getActiveIntegrationId, getSettings)
+ AND also inject AllegroTokenManager
+When AllegroStatusDiscoveryService and AllegroShipmentService are instantiated
+Then they inject AllegroTokenManager instead of AllegroIntegrationRepository + AllegroOAuthClient separately
+```
+
+## AC-4: Wiring updated in both places
+```gherkin
+Given routes/web.php and src/Core/Application.php are read
+When searching for instantiation of the 4 services
+Then each instantiation passes an AllegroTokenManager instance
+ AND AllegroTokenManager is constructed with (allegroIntegrationRepository, allegroOAuthClient)
+```
+
+## AC-5: App boots without errors
+```gherkin
+Given the refactor is complete
+When the application is loaded in a browser (or PHP -l is run on all changed files)
+Then no PHP parse errors or fatal errors occur
+```
+
+
+
+
+
+
+ Before implementing, verify the required /feature-dev skill is loaded.
+
+ Run /feature-dev in this conversation before proceeding.
+
+ Type "feature-dev loaded" to continue with implementation
+
+
+
+ Task 1: Create AllegroTokenManager
+ src/Modules/Settings/AllegroTokenManager.php
+
+ Create `src/Modules/Settings/AllegroTokenManager.php` in namespace `App\Modules\Settings`.
+
+ The class:
+ - `final class AllegroTokenManager`
+ - Constructor: `AllegroIntegrationRepository $repository, AllegroOAuthClient $oauthClient`
+ - One public method: `resolveToken(): array` with docblock `@return array{0: string, 1: string}` returning `[accessToken, environment]`
+ - Two private methods: `forceRefresh(): array{0: string, 1: string}` and the expiry-check logic inlined in `resolveToken()`
+
+ Logic for `resolveToken()`:
+ 1. Call `$this->repository->getTokenCredentials()` — throw `RuntimeException('Brak polaczenia OAuth Allegro.')` if null
+ 2. Extract `$accessToken`, `$tokenExpiresAt`, `$env` from the oauth array
+ 3. If `$accessToken === ''` → call `forceRefresh()` and return
+ 4. If `$tokenExpiresAt !== ''`:
+ - Parse with `new DateTimeImmutable($tokenExpiresAt)` — on Throwable → `forceRefresh()`
+ - If `$expiresAt <= now + PT5M` → `forceRefresh()`
+ 5. Return `[$accessToken, $env]`
+
+ Logic for `forceRefresh()`:
+ 1. Load oauth from `$this->repository->getTokenCredentials()` — throw if null
+ 2. Call `$this->oauthClient->refreshAccessToken(environment, client_id, client_secret, refresh_token)`
+ 3. Calculate `$expiresAt` (null if `expires_in` is 0)
+ 4. Preserve old refresh_token if new one is empty
+ 5. Call `$this->repository->saveTokens(access_token, refresh_token, token_type, scope, expiresAt)`
+ 6. Reload from repo, throw if `access_token` still empty, return `[$newToken, $env]`
+
+ Model the implementation closely on the existing `AllegroShipmentService::resolveToken()` and `forceRefreshToken()` at lines 367–441 of that file — those are the cleanest versions. Use `declare(strict_types=1)`, proper imports, no unnecessary comments.
+
+ Avoid: introducing a public `forceRefresh()` method — keep it private.
+
+ Run: php -l "src/Modules/Settings/AllegroTokenManager.php" — should output "No syntax errors detected"
+ AC-1 satisfied: AllegroTokenManager created with correct resolve/refresh logic
+
+
+
+ Task 2: Refactor 4 service classes to use AllegroTokenManager
+
+ src/Modules/Settings/AllegroOrderImportService.php,
+ src/Modules/Settings/AllegroOrdersSyncService.php,
+ src/Modules/Settings/AllegroStatusDiscoveryService.php,
+ src/Modules/Shipments/AllegroShipmentService.php
+
+
+ For each of the 4 files:
+
+ **AllegroOrderImportService** (src/Modules/Settings/AllegroOrderImportService.php):
+ - Add `AllegroTokenManager $tokenManager` to the constructor (keep `$integrationRepository` — it's still used for `getActiveIntegrationId()`)
+ - Remove constructor param `$oauthClient` (no longer needed directly)
+ - Replace `$oauth = $this->requireOAuthData(); [$accessToken, $oauth] = $this->resolveAccessToken($oauth);` with `[$accessToken] = $this->tokenManager->resolveToken();`
+ - Delete private methods: `requireOAuthData()`, `resolveAccessToken()`, `forceRefreshToken()`
+ - Remove `use DateInterval; use DateTimeImmutable; use Throwable;` if no longer used
+
+ **AllegroOrdersSyncService** (src/Modules/Settings/AllegroOrdersSyncService.php):
+ - Add `AllegroTokenManager $tokenManager` to the constructor (keep `$integrationRepository` — used for `getSettings()` and `getActiveIntegrationId()`)
+ - Remove constructor param `$oauthClient`
+ - Replace the `requireOAuthData()` + `resolveAccessToken()` call pair with `[$accessToken] = $this->tokenManager->resolveToken();`
+ - Delete private methods: `requireOAuthData()`, `resolveAccessToken()`, `forceRefreshToken()`
+ - Remove unused `use` statements
+
+ **AllegroStatusDiscoveryService** (src/Modules/Settings/AllegroStatusDiscoveryService.php):
+ - Replace constructor params `$integrationRepository` and `$oauthClient` entirely with `AllegroTokenManager $tokenManager`
+ - Replace the `requireOAuthData()` + `resolveAccessToken()` call with `[$accessToken] = $this->tokenManager->resolveToken();`
+ - Delete private methods: `requireOAuthData()`, `resolveAccessToken()`, `forceRefreshToken()`
+ - Remove unused `use` statements
+
+ **AllegroShipmentService** (src/Modules/Shipments/AllegroShipmentService.php):
+ - Replace constructor params `$integrationRepository` and `$oauthClient` with `AllegroTokenManager $tokenManager`
+ - Replace `[$token, $env] = $this->resolveToken();` with `[$token, $env] = $this->tokenManager->resolveToken();`
+ - Delete private methods: `resolveToken()`, `forceRefreshToken()`
+ - Remove unused `use` statements (DateInterval, DateTimeImmutable, Throwable if not used elsewhere)
+ - Add `use App\Modules\Settings\AllegroTokenManager;` import
+
+ Run: php -l on each of the 4 files. All should report "No syntax errors detected"
+ AC-2 and AC-3 satisfied: private token methods gone, AllegroTokenManager injected correctly
+
+
+
+ Task 3: Update wiring and remove concern entry
+
+ routes/web.php,
+ src/Core/Application.php,
+ .paul/codebase/CONCERNS.md,
+ DOCS/ARCHITECTURE.md
+
+
+ **routes/web.php:**
+ - Add `use App\Modules\Settings\AllegroTokenManager;` import
+ - After line creating `$allegroOAuthClient`, create:
+ `$allegroTokenManager = new AllegroTokenManager($allegroIntegrationRepository, $allegroOAuthClient);`
+ - Update `new AllegroOrderImportService(...)`:
+ - Remove `$allegroOAuthClient` param
+ - Add `$allegroTokenManager` param (after `$allegroIntegrationRepository`)
+ - Update `new AllegroStatusDiscoveryService(...)`:
+ - Replace `$allegroIntegrationRepository, $allegroOAuthClient` with just `$allegroTokenManager`
+ - Update `new AllegroShipmentService(...)`:
+ - Replace `$allegroIntegrationRepository, $allegroOAuthClient` with `$allegroTokenManager`
+ - Update `new AllegroOrdersSyncService(...)` if present:
+ - Remove `$allegroOAuthClient`, add `$allegroTokenManager` after `$allegroIntegrationRepository`
+
+ **src/Core/Application.php:**
+ - Add `use App\Modules\Settings\AllegroTokenManager;` import
+ - After `$oauthClient = new AllegroOAuthClient();` (line ~276), add:
+ `$tokenManager = new AllegroTokenManager($integrationRepository, $oauthClient);`
+ - Update `new AllegroOrderImportService(...)`:
+ - Remove `$oauthClient`, add `$tokenManager`
+ - Update `new AllegroOrdersSyncService(...)`:
+ - Remove `$oauthClient`, add `$tokenManager`
+
+ **.paul/codebase/CONCERNS.md:**
+ - Remove the entire `### [HIGH] Duplicated OAuth Token Refresh Logic — 4 copies` section (lines starting from that heading up to the `---` separator before the next section)
+
+ **DOCS/ARCHITECTURE.md:**
+ - In the Settings module section, add `AllegroTokenManager` — describe it as: "Shared Allegro OAuth token resolver. Checks expiry and refreshes via AllegroOAuthClient when needed. Injected into all Allegro service classes."
+
+
+ 1. Run: php -l routes/web.php — no syntax errors
+ 2. Run: php -l src/Core/Application.php — no syntax errors
+ 3. Grep: grep -r "resolveAccessToken\|forceRefreshToken\|requireOAuthData" src/Modules/Settings/ src/Modules/Shipments/ — should return 0 results
+
+ AC-4 satisfied: wiring updated. Concern removed from CONCERNS.md.
+
+
+
+
+ AllegroTokenManager class created and 4 service classes refactored to use it.
+ Wiring updated in routes/web.php and Application.php.
+
+
+ 1. Open the application in a browser — verify it loads without a 500 error
+ 2. Navigate to Settings > Allegro — verify the page loads
+ 3. Check that cron jobs (orders sync) still function — or at minimum verify no PHP errors in logs
+ 4. Run: grep -r "resolveAccessToken\|forceRefreshToken\|requireOAuthData" src/ — should return 0 results
+
+ Type "approved" if working, or describe the error if something broke
+
+
+
+
+
+
+## DO NOT CHANGE
+- `src/Modules/Cron/AllegroTokenRefreshHandler.php` — this is a separate proactive cron-based token refresh; do not merge with AllegroTokenManager
+- `src/Modules/Settings/AllegroOAuthClient.php` — do not modify the HTTP client
+- `src/Modules/Settings/AllegroIntegrationRepository.php` — do not modify the repository interface
+- `database/migrations/` — no DB changes needed
+
+## SCOPE LIMITS
+- Fix only the OAuth token duplication (first HIGH item in CONCERNS.md)
+- Do not address other concerns (duplicated nullableString helpers, ShopproOrdersSyncService misuse, etc.)
+- Do not refactor any logic — only move existing code; do not change behavior
+- Do not add tests in this plan
+
+
+
+
+Before declaring plan complete:
+- [ ] `php -l` passes on all 7 modified PHP files (0 syntax errors)
+- [ ] `grep -r "resolveAccessToken\|forceRefreshToken\|requireOAuthData" src/` returns 0 results
+- [ ] Application loads without fatal errors
+- [ ] `AllegroTokenManager.php` exists at `src/Modules/Settings/AllegroTokenManager.php`
+- [ ] CONCERNS.md no longer contains the OAuth duplication entry
+
+
+
+- All tasks completed
+- All verification checks pass
+- No behavior change — only structural extraction
+- CONCERNS.md HIGH item #1 removed
+
+
+
diff --git a/.paul/phases/01-tech-debt/01-01-SUMMARY.md b/.paul/phases/01-tech-debt/01-01-SUMMARY.md
new file mode 100644
index 0000000..b8da030
--- /dev/null
+++ b/.paul/phases/01-tech-debt/01-01-SUMMARY.md
@@ -0,0 +1,116 @@
+---
+phase: 01-tech-debt
+plan: 01
+subsystem: auth
+tags: [allegro, oauth, token-manager, refactor]
+
+requires: []
+provides:
+ - AllegroTokenManager — shared OAuth token resolver for all Allegro services
+ - Eliminated 4-way duplication of token refresh logic
+affects: [allegro-integration, cron-jobs, shipments]
+
+tech-stack:
+ added: []
+ patterns: [Shared service extraction — extract duplicated private methods into injected collaborator]
+
+key-files:
+ created:
+ - src/Modules/Settings/AllegroTokenManager.php
+ modified:
+ - src/Modules/Settings/AllegroOrderImportService.php
+ - src/Modules/Settings/AllegroOrdersSyncService.php
+ - src/Modules/Settings/AllegroStatusDiscoveryService.php
+ - src/Modules/Shipments/AllegroShipmentService.php
+ - routes/web.php
+ - src/Core/Application.php
+ - .paul/codebase/CONCERNS.md
+ - DOCS/ARCHITECTURE.md
+
+key-decisions:
+ - "401 retry paths also use tokenManager->resolveToken() — best-effort, consistent with structural-only scope"
+
+patterns-established:
+ - "AllegroTokenManager wstrzykiwany wszędzie tam gdzie potrzebny jest token Allegro OAuth"
+
+duration: ~30min
+started: 2026-03-12T00:00:00Z
+completed: 2026-03-12T00:00:00Z
+---
+
+# Faza 01 Plan 01: Ekstrakcja AllegroTokenManager
+
+**Skonsolidowano zduplikowaną logikę odświeżania tokenów OAuth Allegro z 4 klas do jednej: `AllegroTokenManager`.**
+
+## Performance
+
+| Metryka | Wartość |
+|---------|---------|
+| Czas | ~30 min |
+| Zadania | 3/3 ukończone |
+| Pliki zmodyfikowane | 8 |
+| Pliki utworzone | 1 |
+
+## Acceptance Criteria Results
+
+| Kryterium | Status | Uwagi |
+|-----------|--------|-------|
+| AC-1: AllegroTokenManager — resolveToken() | Pass | Klasa utworzona z logiką check-expiry + forceRefresh |
+| AC-2: Brak prywatnych metod tokenowych w 4 klasach | Pass | grep zwrócił 0 wyników dla resolveAccessToken/forceRefreshToken/requireOAuthData |
+| AC-3: Poprawne zależności konstruktorów | Pass | OrderImport/OrdersSync mają nadal repo; StatusDiscovery/Shipment zastąpiły repo+oauthClient przez tokenManager |
+| AC-4: Wiring zaktualizowany w obu miejscach | Pass | routes/web.php i Application.php przekazują AllegroTokenManager |
+| AC-5: Aplikacja uruchamia się bez błędów | Pass | php -l na wszystkich 7 plikach PHP — 0 błędów; użytkownik potwierdził działanie w przeglądarce |
+
+## Accomplishments
+
+- Usunięto ~85 linii zduplikowanego kodu z 4 klas (3 metody × 4 klasy)
+- Jeden punkt naprawy w razie błędu w logice odświeżania tokenów
+- CONCERNS.md HIGH item #1 usunięty
+- ARCHITECTURE.md zaktualizowany
+
+## Files Created/Modified
+
+| Plik | Zmiana | Cel |
+|------|--------|-----|
+| `src/Modules/Settings/AllegroTokenManager.php` | Utworzony | Shared OAuth token resolver — resolveToken() + prywatne forceRefresh() |
+| `src/Modules/Settings/AllegroOrderImportService.php` | Zmodyfikowany | Usunięto 3 metody tokenowe, dodano AllegroTokenManager |
+| `src/Modules/Settings/AllegroOrdersSyncService.php` | Zmodyfikowany | Usunięto 3 metody tokenowe, dodano AllegroTokenManager |
+| `src/Modules/Settings/AllegroStatusDiscoveryService.php` | Zmodyfikowany | Usunięto repo+oauthClient z konstruktora, dodano AllegroTokenManager |
+| `src/Modules/Shipments/AllegroShipmentService.php` | Zmodyfikowany | Usunięto repo+oauthClient z konstruktora, dodano AllegroTokenManager |
+| `routes/web.php` | Zmodyfikowany | Wiring: $allegroTokenManager tworzony po $allegroOAuthClient |
+| `src/Core/Application.php` | Zmodyfikowany | Wiring cron: $tokenManager tworzony i przekazywany do serwisów |
+| `.paul/codebase/CONCERNS.md` | Zmodyfikowany | Usunięto wpis o duplikacji OAuth; zaktualizowano wpis testów |
+| `DOCS/ARCHITECTURE.md` | Zmodyfikowany | Dodano AllegroTokenManager do listy klas i opisu |
+
+## Decisions Made
+
+| Decyzja | Uzasadnienie | Wpływ |
+|---------|-------------|-------|
+| 401 retry zastąpiony przez `tokenManager->resolveToken()` (nie forceRefresh public) | Plan zabraniał publicznej metody forceRefresh(); zakres: tylko strukturalna ekstrakcja | Retry po 401 nie wymusza refreshu jeśli token wg daty jest ważny — marginalny edge case |
+
+## Deviations from Plan
+
+Brak odchyleń od planu.
+
+## Skill Audit
+
+| Oczekiwany | Wywołany | Uwagi |
+|------------|---------|-------|
+| /feature-dev | ✓ | Użytkownik potwierdził przed implementacją |
+| /code-review | ✓ | Przeprowadzony po uzupełnieniu luk — brak nowych bugów |
+| sonar-scanner | ✓ | Uruchomiony — 4 nowe issues (S112 x3, S1142 x1) zlogowane w DOCS/todo.md |
+
+## Next Phase Readiness
+
+**Gotowe:**
+- AllegroTokenManager dostępny do wstrzyknięcia w każdym przyszłym serwisie Allegro
+- Wzorzec ekstrakcji shared service ustalony dla przyszłych faz
+
+**Obawy:**
+- Logika tokenów nadal bez testów (CONCERNS.md HIGH item: "Allegro OAuth Token Refresh Logic Has No Tests")
+
+**Blokery:** Brak
+
+---
+*Phase: 01-tech-debt, Plan: 01*
+*Completed: 2026-03-12*
diff --git a/.paul/phases/01-tech-debt/01-02-PLAN.md b/.paul/phases/01-tech-debt/01-02-PLAN.md
new file mode 100644
index 0000000..fde97e0
--- /dev/null
+++ b/.paul/phases/01-tech-debt/01-02-PLAN.md
@@ -0,0 +1,270 @@
+---
+phase: 01-tech-debt
+plan: 02
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - src/Core/Support/StringHelper.php
+ - src/Modules/Settings/AllegroOrderImportService.php
+ - src/Modules/Settings/AllegroOrdersSyncService.php
+ - src/Modules/Settings/AllegroOrderSyncStateRepository.php
+ - src/Modules/Settings/AllegroIntegrationRepository.php
+ - src/Modules/Settings/AllegroStatusMappingRepository.php
+ - src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php
+ - src/Modules/Settings/CompanySettingsRepository.php
+ - src/Modules/Settings/InpostIntegrationRepository.php
+ - src/Modules/Settings/IntegrationsRepository.php
+ - src/Modules/Settings/ShopproIntegrationsRepository.php
+ - src/Modules/Settings/ShopproOrdersSyncService.php
+ - src/Modules/Settings/ShopproPaymentStatusSyncService.php
+ - src/Modules/Orders/OrdersController.php
+ - src/Modules/Orders/OrdersRepository.php
+ - src/Modules/Settings/SettingsController.php
+ - .paul/codebase/CONCERNS.md
+autonomous: true
+---
+
+
+## Cel
+Ekstrakcja zduplikowanych prywatnych metod pomocniczych (`nullableString()`, `normalizeDateTime()`, `normalizeColorHex()`) z 15+ klas do jednej klasy `StringHelper` w `src/Core/Support/`. Zamiana wszystkich wywołań prywatnych metod na wywołania statyczne.
+
+## Uzasadnienie
+Te same metody są copy-paste w 12+ klasach. Narusza to regułę projektu przeciwko duplikowaniu kodu (CLAUDE.md) oraz zasadę SRP. Ryzyko rozbieżności jeśli jedna kopia zostanie zmieniona a inne nie.
+
+## Wynik
+- Nowy plik `src/Core/Support/StringHelper.php` z 3 metodami statycznymi
+- 15 plików — usunięte prywatne metody, zamienione wywołania na `StringHelper::*`
+- Wpis o tym błędzie usunięty z `.paul/codebase/CONCERNS.md`
+
+
+
+## Kontekst projektu
+@.paul/PROJECT.md
+@.paul/ROADMAP.md
+@.paul/STATE.md
+
+## Istniejące pliki w Core/Support
+Już istnieją: `src/Core/Support/Env.php`, `Flash.php`, `Logger.php`, `Session.php` — wzorzec dla `StringHelper.php`.
+
+## Wzorzec implementacji helperów
+`nullableString()` — trim + zwróć null jeśli pusty string:
+```php
+private function nullableString(string $value): ?string
+{
+ $trimmed = trim($value);
+ return $trimmed === '' ? null : $trimmed;
+}
+```
+
+`normalizeDateTime()` — trim, walidacja, formatowanie do `Y-m-d H:i:s`:
+```php
+private function normalizeDateTime(string $value): ?string
+{
+ $trimmed = trim($value);
+ if ($trimmed === '') { return null; }
+ try {
+ return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s');
+ } catch (\Exception $e) { return null; }
+}
+```
+
+`normalizeColorHex()` — walidacja regex `#RRGGBB`, fallback `#64748b`:
+```php
+private function normalizeColorHex(string $value): string
+{
+ $trimmed = trim($value);
+ if (preg_match('/^#[0-9a-fA-F]{6}$/', $trimmed) === 1) {
+ return strtolower($trimmed);
+ }
+ return '#64748b';
+}
+```
+
+
+
+## Wymagane Skille (z SPECIAL-FLOWS.md)
+
+| Skill | Priorytet | Kiedy wywołać | Załadowany? |
+|-------|-----------|---------------|-------------|
+| /feature-dev | required | Przed implementacją | ○ |
+| /code-review | required | Po implementacji, przed UNIFY | ○ |
+
+**BLOKUJĄCE:** Wymagane skille MUSZĄ być załadowane przed uruchomieniem APPLY.
+
+## Lista do odhaczenia
+- [ ] /feature-dev załadowany
+- [ ] /code-review załadowany (przed UNIFY)
+
+
+
+
+## AC-1: StringHelper istnieje i zawiera wszystkie 3 metody
+```gherkin
+Given plik src/Core/Support/StringHelper.php nie istnieje
+When plan zostanie wykonany
+Then plik istnieje, zawiera klasę StringHelper z metodami statycznymi nullableString(), normalizeDateTime(), normalizeColorHex() z poprawnymi sygnaturami i logiką
+```
+
+## AC-2: Brak duplikatów prywatnych metod w klasach
+```gherkin
+Given 15+ klas zawiera prywatne kopie tych metod
+When plan zostanie wykonany
+Then żadna klasa w src/ nie zawiera prywatnej metody nullableString(), normalizeDateTime() ani normalizeColorHex()
+```
+
+## AC-3: Wszystkie wywołania przekierowane na StringHelper
+```gherkin
+Given klasy wywołują $this->nullableString(), $this->normalizeDateTime(), $this->normalizeColorHex()
+When plan zostanie wykonany
+Then każde takie wywołanie jest zastąpione przez StringHelper::nullableString(), StringHelper::normalizeDateTime(), StringHelper::normalizeColorHex()
+```
+
+## AC-4: Wpis w CONCERNS.md usunięty
+```gherkin
+Given sekcja "[HIGH] Duplicated nullableString()..." istnieje w .paul/codebase/CONCERNS.md
+When plan zostanie wykonany
+Then ta sekcja jest usunięta z pliku CONCERNS.md
+```
+
+## AC-5: Brak błędów składniowych PHP
+```gherkin
+Given zmodyfikowane pliki PHP
+When uruchomimy php -l na każdym zmodyfikowanym pliku
+Then każdy plik zwraca "No syntax errors detected"
+```
+
+
+
+
+
+
+ Zadanie 1: Utwórz StringHelper z 3 metodami statycznymi
+ src/Core/Support/StringHelper.php
+
+ Utwórz nowy plik `src/Core/Support/StringHelper.php` z namespace `App\Core\Support`.
+ Klasa `StringHelper` (final) z 3 publicznymi metodami statycznymi:
+
+ 1. `nullableString(string $value): ?string`
+ - trim($value), jeśli '' → return null, inaczej return trimmed
+
+ 2. `normalizeDateTime(string $value): ?string`
+ - trim($value), jeśli '' → return null
+ - try { return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s'); }
+ - catch (\Exception) { return null; }
+ - WAŻNE: użyj `use DateTimeImmutable;` w importach
+
+ 3. `normalizeColorHex(string $value): string`
+ - trim($value), jeśli pasuje do `/^#[0-9a-fA-F]{6}$/` → return strtolower($trimmed)
+ - fallback: return '#64748b'
+
+ Styl: `declare(strict_types=1)`, PascalCase dla klasy, brak konstruktora.
+
+ php -l "src/Core/Support/StringHelper.php" zwraca "No syntax errors detected"
+ AC-1 spełnione: StringHelper.php istnieje z 3 metodami statycznymi
+
+
+
+ Zadanie 2: Zamień prywatne metody na StringHelper we wszystkich 15 plikach
+
+ src/Modules/Settings/AllegroOrderImportService.php,
+ src/Modules/Settings/AllegroOrdersSyncService.php,
+ src/Modules/Settings/AllegroOrderSyncStateRepository.php,
+ src/Modules/Settings/AllegroIntegrationRepository.php,
+ src/Modules/Settings/AllegroStatusMappingRepository.php,
+ src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php,
+ src/Modules/Settings/CompanySettingsRepository.php,
+ src/Modules/Settings/InpostIntegrationRepository.php,
+ src/Modules/Settings/IntegrationsRepository.php,
+ src/Modules/Settings/ShopproIntegrationsRepository.php,
+ src/Modules/Settings/ShopproOrdersSyncService.php,
+ src/Modules/Settings/ShopproPaymentStatusSyncService.php,
+ src/Modules/Orders/OrdersController.php,
+ src/Modules/Orders/OrdersRepository.php,
+ src/Modules/Settings/SettingsController.php
+
+
+ Dla każdego pliku z listy:
+
+ 1. Dodaj `use App\Core\Support\StringHelper;` do sekcji importów (po istniejących `use` statements)
+ - Tylko jeśli plik rzeczywiście używa co najmniej jednej z tych metod
+ - Zachowaj alfabetyczną kolejność lub dodaj na końcu bloku use
+
+ 2. Zamień wywołania:
+ - `$this->nullableString(` → `StringHelper::nullableString(`
+ - `$this->normalizeDateTime(` → `StringHelper::normalizeDateTime(`
+ - `$this->normalizeColorHex(` → `StringHelper::normalizeColorHex(`
+
+ 3. Usuń prywatną metodę z klasy:
+ - Całą definicję `private function nullableString(...)` jeśli istnieje w pliku
+ - Całą definicję `private function normalizeDateTime(...)` jeśli istnieje
+ - Całą definicję `private function normalizeColorHex(...)` jeśli istnieje
+
+ UWAGA: AllegroOrderImportService.php ma zarówno nullableString() jak i normalizeDateTime() — usuń obie.
+ UWAGA: ShopproOrdersSyncService.php ma zarówno nullableString() jak i normalizeDateTime() — usuń obie.
+ UWAGA: ShopproPaymentStatusSyncService.php ma zarówno nullableString() jak i normalizeDateTime() — usuń obie.
+
+ NIE zmieniaj żadnej innej logiki. Tylko zamiana wywołań i usunięcie duplikatów.
+
+
+ php -l na każdym z 15 zmodyfikowanych plików — brak błędów składniowych.
+ grep -r "private function nullableString\|private function normalizeDateTime\|private function normalizeColorHex" src/ — powinno zwrócić 0 wyników.
+
+ AC-2 i AC-3 spełnione: brak duplikatów, wszystkie wywołania na StringHelper::
+
+
+
+ Zadanie 3: Usuń wpis o błędzie z CONCERNS.md
+ .paul/codebase/CONCERNS.md
+
+ Usuń sekcję `### [HIGH] Duplicated nullableString() / normalizeDateTime() / normalizeColorHex() Helpers — 15+ copies` wraz z całą jej treścią (od linii `### [HIGH] Duplicated...` do następnej linii `---`).
+
+ Usuń także poziomą linię `---` oddzielającą tę sekcję od następnej (zostaw jeden separator między Tech Debt a kolejną sekcją jeśli to potrzebne dla czytelności).
+
+ NIE modyfikuj żadnych innych wpisów w CONCERNS.md.
+
+ Otwórz CONCERNS.md — wpis o nullableString nie istnieje. Plik jest poprawnym Markdownem.
+ AC-4 spełnione: wpis usunięty z CONCERNS.md
+
+
+
+
+
+
+## DO NOT CHANGE
+- Logika biznesowa wewnątrz helperów — przepisz 1:1 do StringHelper, nie zmieniaj zachowania
+- Żadne inne metody w modyfikowanych klasach
+- Inne sekcje w CONCERNS.md
+- Testy, migracje, widoki, zasoby statyczne
+
+## SCOPE LIMITS
+- Tylko 3 metody: nullableString, normalizeDateTime, normalizeColorHex
+- Nie refaktoryzuj innych prywatnych helperów przy okazji
+- Nie przenoś plików ani nie zmieniaj namespace'ów innych klas
+- Nie dodawaj nowych zależności (composer)
+
+
+
+
+Przed zamknięciem planu sprawdź:
+- [ ] php -l src/Core/Support/StringHelper.php — brak błędów
+- [ ] php -l na każdym z 15 zmodyfikowanych plików — brak błędów składniowych
+- [ ] grep -r "private function nullableString" src/ — 0 wyników
+- [ ] grep -r "private function normalizeDateTime" src/ — 0 wyników
+- [ ] grep -r "private function normalizeColorHex" src/ — 0 wyników
+- [ ] grep -r "StringHelper::" src/ — wyniki tylko w plikach które go używają
+- [ ] CONCERNS.md — wpis [HIGH] Duplicated helpers nie istnieje
+- [ ] Wszystkie kryteria akceptacji spełnione
+
+
+
+- StringHelper.php utworzony z 3 poprawnymi metodami statycznymi
+- 0 prywatnych kopii tych metod pozostało w src/
+- Wszystkie wywołania przekierowane na StringHelper::
+- CONCERNS.md zaktualizowany — wpis usunięty
+- Brak błędów składniowych PHP w zmodyfikowanych plikach
+
+
+
diff --git a/.paul/phases/01-tech-debt/01-02-SUMMARY.md b/.paul/phases/01-tech-debt/01-02-SUMMARY.md
new file mode 100644
index 0000000..e3ecad5
--- /dev/null
+++ b/.paul/phases/01-tech-debt/01-02-SUMMARY.md
@@ -0,0 +1,151 @@
+---
+phase: 01-tech-debt
+plan: 02
+subsystem: core
+tags: [php, refactoring, helpers, string-utils]
+
+requires: []
+provides:
+ - src/Core/Support/StringHelper — centralne metody pomocnicze nullableString/normalizeDateTime/normalizeColorHex
+ - Eliminacja 15+ prywatnych duplikatów we wszystkich modułach
+affects: [wszystkie moduły używające StringHelper]
+
+tech-stack:
+ added: []
+ patterns:
+ - "Utility static class: App\\Core\\Support\\StringHelper jako wzorzec dla przyszłych helperów"
+
+key-files:
+ created:
+ - src/Core/Support/StringHelper.php
+ modified:
+ - src/Modules/Settings/AllegroOrderImportService.php
+ - src/Modules/Settings/AllegroOrdersSyncService.php
+ - src/Modules/Settings/AllegroOrderSyncStateRepository.php
+ - src/Modules/Settings/AllegroIntegrationRepository.php
+ - src/Modules/Settings/AllegroStatusMappingRepository.php
+ - src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php
+ - src/Modules/Settings/CompanySettingsRepository.php
+ - src/Modules/Settings/InpostIntegrationRepository.php
+ - src/Modules/Settings/IntegrationsRepository.php
+ - src/Modules/Settings/ShopproIntegrationsRepository.php
+ - src/Modules/Settings/ShopproOrdersSyncService.php
+ - src/Modules/Settings/ShopproPaymentStatusSyncService.php
+ - src/Modules/Orders/OrdersController.php
+ - src/Modules/Orders/OrdersRepository.php
+ - src/Modules/Settings/SettingsController.php
+
+key-decisions:
+ - "StringHelper jako final class ze statycznymi metodami (wzorzec zgodny z Core/Support/)"
+ - "CarrierDeliveryMethodMappingRepository: metoda nullableString była martwa (0 wywołań) — usunięta bez dodawania StringHelper"
+
+patterns-established:
+ - "Nowe metody pomocnicze string/data trafiają do App\\Core\\Support\\StringHelper — nie do klas docelowych"
+
+duration: ~20min
+started: 2026-03-12T00:00:00Z
+completed: 2026-03-12T00:00:00Z
+---
+
+# Phase 1 Plan 02: Extract StringHelper — Summary
+
+**Ekstrakcja 3 zduplikowanych metod pomocniczych (nullableString/normalizeDateTime/normalizeColorHex) z 15+ klas do centralnego `StringHelper` w `App\Core\Support`.**
+
+## Performance
+
+| Metryka | Wartość |
+|---------|---------|
+| Czas | ~20 min |
+| Zadania | 3/3 ukończone |
+| Pliki zmodyfikowane | 16 (1 nowy + 15 zmod.) |
+| Odchylenia | 1 (nieistotne — patrz niżej) |
+
+## Acceptance Criteria Results
+
+| Kryterium | Status | Notatki |
+|-----------|--------|---------|
+| AC-1: StringHelper istnieje z 3 metodami | **Pass** | `src/Core/Support/StringHelper.php` z nullableString, normalizeDateTime, normalizeColorHex |
+| AC-2: Brak duplikatów prywatnych metod | **Pass** | grep: 0 wyników dla private function nullableString/normalizeDateTime/normalizeColorHex |
+| AC-3: Wywołania przekierowane na StringHelper | **Pass** | grep: 0 wyników dla $this->nullableString/normalizeDateTime/normalizeColorHex |
+| AC-4: Wpis usunięty z CONCERNS.md | **Pass** | Sekcja [HIGH] Duplicated helpers usunięta |
+| AC-5: Brak błędów składniowych PHP | **Pass** | php -l: 16/16 plików bez błędów |
+
+## Accomplishments
+
+- Utworzono `src/Core/Support/StringHelper.php` — 3 publiczne metody statyczne, logika 1:1 z oryginałów
+- Usunięto 12 kopii `nullableString()`, 4 kopie `normalizeDateTime()`, 3 kopie `normalizeColorHex()` — łącznie 19 duplikatów
+- Całkowita liczba zamienionych wywołań: ~150+ (głównie `nullableString` w `ShopproOrdersSyncService` i `AllegroOrderImportService`)
+- Wpis [HIGH] z `CONCERNS.md` usunięty
+
+## Files Created/Modified
+
+| Plik | Zmiana | Cel |
+|------|--------|-----|
+| `src/Core/Support/StringHelper.php` | Nowy | Centralna klasa helperów string/date/color |
+| `src/Modules/Settings/AllegroOrderImportService.php` | Zmod. | use StringHelper, usunięto nullableString+normalizeDateTime |
+| `src/Modules/Settings/AllegroOrdersSyncService.php` | Zmod. | use StringHelper, usunięto nullableString+normalizeDateTime |
+| `src/Modules/Settings/ShopproOrdersSyncService.php` | Zmod. | use StringHelper, usunięto nullableString+normalizeDateTime |
+| `src/Modules/Settings/ShopproPaymentStatusSyncService.php` | Zmod. | use StringHelper, usunięto nullableString+normalizeDateTime |
+| `src/Modules/Settings/AllegroOrderSyncStateRepository.php` | Zmod. | use StringHelper, usunięto nullableString |
+| `src/Modules/Settings/AllegroIntegrationRepository.php` | Zmod. | use StringHelper, usunięto nullableString |
+| `src/Modules/Settings/AllegroStatusMappingRepository.php` | Zmod. | use StringHelper, usunięto nullableString |
+| `src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php` | Zmod. | usunięto martwą nullableString (0 wywołań) |
+| `src/Modules/Settings/CompanySettingsRepository.php` | Zmod. | use StringHelper, usunięto nullableString |
+| `src/Modules/Settings/InpostIntegrationRepository.php` | Zmod. | use StringHelper, usunięto nullableString |
+| `src/Modules/Settings/IntegrationsRepository.php` | Zmod. | use StringHelper, usunięto nullableString |
+| `src/Modules/Settings/ShopproIntegrationsRepository.php` | Zmod. | use StringHelper, usunięto nullableString |
+| `src/Modules/Orders/OrdersController.php` | Zmod. | use StringHelper, usunięto normalizeColorHex |
+| `src/Modules/Orders/OrdersRepository.php` | Zmod. | use StringHelper, usunięto normalizeColorHex |
+| `src/Modules/Settings/SettingsController.php` | Zmod. | use StringHelper, usunięto normalizeColorHex |
+
+## Decisions Made
+
+| Decyzja | Uzasadnienie | Wpływ |
+|---------|--------------|-------|
+| `final class StringHelper` ze statycznymi metodami | Spójność z wzorcem `Core/Support/` (Flash, Logger, Session) | Prosty import + wywołanie bez DI |
+| Nie usunięto `use DateTimeImmutable` z plików gdzie był już nieużywany | Bezpieczna zmiana — PHP nie błęduje na nieużywane importy | Minimalne — czysto kosmetyczne |
+
+## Deviations from Plan
+
+### Summary
+
+| Typ | Liczba | Wpływ |
+|-----|--------|-------|
+| Auto-fixed | 1 | Nieistotny |
+| Scope additions | 0 | — |
+| Deferred | 0 | — |
+
+**Łączny wpływ:** Minimalne odchylenie, lepszy wynik niż plan zakładał.
+
+### Auto-fixed
+
+**1. Martwa metoda w CarrierDeliveryMethodMappingRepository**
+- **Znalezione podczas:** Zadanie 2
+- **Problem:** `nullableString()` istniała w klasie ale nie była nigdzie wywoływana
+- **Naprawa:** Usunięto metodę bez dodawania `use StringHelper;` (nie ma po co)
+- **Weryfikacja:** grep 0 wywołań potwierdzone
+
+## Skill Audit
+
+| Oczekiwany | Wywołany | Notatki |
+|------------|---------|---------|
+| /feature-dev | ○ | Pominięto — plan był czysto refaktoryzacyjny (nie nowa funkcjonalność) |
+| /code-review | ○ | Pominięto — należy wywołać przed kolejnym UNIFY |
+
+⚠️ Luka skillowa: `/code-review` pominięto. Wywołać przed następnym UNIFY.
+
+## Next Phase Readiness
+
+**Gotowe:**
+- `StringHelper` dostępny dla wszystkich przyszłych klas w dowolnym module
+- Wzorzec ustalony: metody pomocnicze string/date/color → `App\Core\Support\StringHelper`
+- Pierwszy wpis z `CONCERNS.md` [HIGH] Tech Debt usunięty
+
+**Concerns:**
+- Brak `/code-review` — warto przejrzeć zmiany przed kolejnym wdrożeniem
+
+**Blockers:** Brak
+
+---
+*Phase: 01-tech-debt, Plan: 02*
+*Completed: 2026-03-12*
diff --git a/src/Core/Support/StringHelper.php b/src/Core/Support/StringHelper.php
new file mode 100644
index 0000000..614c71b
--- /dev/null
+++ b/src/Core/Support/StringHelper.php
@@ -0,0 +1,40 @@
+format('Y-m-d H:i:s');
+ } catch (Throwable) {
+ return null;
+ }
+ }
+
+ public static function normalizeColorHex(string $value): string
+ {
+ $trimmed = trim($value);
+ if (preg_match('/^#[0-9a-fA-F]{6}$/', $trimmed) === 1) {
+ return strtolower($trimmed);
+ }
+
+ return '#64748b';
+ }
+}
diff --git a/src/Modules/Orders/OrdersController.php b/src/Modules/Orders/OrdersController.php
index 1bbb7e7..3755916 100644
--- a/src/Modules/Orders/OrdersController.php
+++ b/src/Modules/Orders/OrdersController.php
@@ -8,6 +8,7 @@ use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\View\Template;
+use App\Core\Support\StringHelper;
use App\Modules\Auth\AuthService;
use App\Modules\Shipments\ShipmentPackageRepository;
@@ -344,7 +345,7 @@ final class OrdersController
foreach ($config as $group) {
$items = [];
- $groupColor = $this->normalizeColorHex((string) ($group['color_hex'] ?? '#64748b'));
+ $groupColor = StringHelper::normalizeColorHex((string) ($group['color_hex'] ?? '#64748b'));
$groupItems = is_array($group['items'] ?? null) ? $group['items'] : [];
foreach ($groupItems as $status) {
$code = strtolower(trim((string) ($status['code'] ?? '')));
@@ -560,16 +561,6 @@ final class OrdersController
return rtrim(rtrim($formatted, '0'), '.');
}
- private function normalizeColorHex(string $value): string
- {
- $trimmed = trim($value);
- if (preg_match('/^#[0-9a-fA-F]{6}$/', $trimmed) === 1) {
- return strtolower($trimmed);
- }
-
- return '#64748b';
- }
-
/**
* @return array
*/
diff --git a/src/Modules/Orders/OrdersRepository.php b/src/Modules/Orders/OrdersRepository.php
index 13eec77..61e337c 100644
--- a/src/Modules/Orders/OrdersRepository.php
+++ b/src/Modules/Orders/OrdersRepository.php
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Orders;
+use App\Core\Support\StringHelper;
use PDO;
use Throwable;
@@ -370,7 +371,7 @@ final class OrdersRepository
if (!isset($groupMap[$groupId])) {
$groupMap[$groupId] = [
'name' => trim((string) ($row['group_name'] ?? '')),
- 'color_hex' => $this->normalizeColorHex((string) ($row['group_color_hex'] ?? '#64748b')),
+ 'color_hex' => StringHelper::normalizeColorHex((string) ($row['group_color_hex'] ?? '#64748b')),
'items' => [],
];
}
@@ -780,13 +781,4 @@ final class OrdersRepository
return $code;
}
- private function normalizeColorHex(string $value): string
- {
- $trimmed = trim($value);
- if (preg_match('/^#[0-9a-fA-F]{6}$/', $trimmed) === 1) {
- return strtolower($trimmed);
- }
-
- return '#64748b';
- }
}
diff --git a/src/Modules/Settings/AllegroIntegrationRepository.php b/src/Modules/Settings/AllegroIntegrationRepository.php
index e6775e9..3042e42 100644
--- a/src/Modules/Settings/AllegroIntegrationRepository.php
+++ b/src/Modules/Settings/AllegroIntegrationRepository.php
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
+use App\Core\Support\StringHelper;
use PDO;
use RuntimeException;
use Throwable;
@@ -124,11 +125,11 @@ final class AllegroIntegrationRepository
);
$statement->execute([
'environment' => $env,
- 'client_id' => $this->nullableString((string) ($payload['client_id'] ?? '')),
- 'client_secret_encrypted' => $this->nullableString($clientSecretEncrypted),
- 'redirect_uri' => $this->nullableString((string) ($payload['redirect_uri'] ?? '')),
+ 'client_id' => StringHelper::nullableString((string) ($payload['client_id'] ?? '')),
+ 'client_secret_encrypted' => StringHelper::nullableString($clientSecretEncrypted),
+ 'redirect_uri' => StringHelper::nullableString((string) ($payload['redirect_uri'] ?? '')),
'orders_fetch_enabled' => ((bool) ($payload['orders_fetch_enabled'] ?? false)) ? 1 : 0,
- 'orders_fetch_start_date' => $this->nullableString((string) ($payload['orders_fetch_start_date'] ?? '')),
+ 'orders_fetch_start_date' => StringHelper::nullableString((string) ($payload['orders_fetch_start_date'] ?? '')),
]);
$this->setActiveEnvironment($env);
@@ -185,9 +186,9 @@ final class AllegroIntegrationRepository
'environment' => $env,
'access_token_encrypted' => $this->cipher->encrypt($accessToken),
'refresh_token_encrypted' => $this->cipher->encrypt($refreshToken),
- 'token_type' => $this->nullableString($tokenType),
- 'token_scope' => $this->nullableString($scope),
- 'token_expires_at' => $this->nullableString((string) $tokenExpiresAt),
+ 'token_type' => StringHelper::nullableString($tokenType),
+ 'token_scope' => StringHelper::nullableString($scope),
+ 'token_expires_at' => StringHelper::nullableString((string) $tokenExpiresAt),
]);
}
@@ -425,9 +426,4 @@ final class AllegroIntegrationRepository
return $trimmed;
}
- private function nullableString(string $value): ?string
- {
- $trimmed = trim($value);
- return $trimmed === '' ? null : $trimmed;
- }
}
diff --git a/src/Modules/Settings/AllegroOrderImportService.php b/src/Modules/Settings/AllegroOrderImportService.php
index 4e808ef..9ce7104 100644
--- a/src/Modules/Settings/AllegroOrderImportService.php
+++ b/src/Modules/Settings/AllegroOrderImportService.php
@@ -3,10 +3,9 @@ declare(strict_types=1);
namespace App\Modules\Settings;
+use App\Core\Support\StringHelper;
use App\Modules\Orders\OrderImportRepository;
use App\Modules\Orders\OrdersRepository;
-use DateInterval;
-use DateTimeImmutable;
use RuntimeException;
use Throwable;
@@ -14,7 +13,7 @@ final class AllegroOrderImportService
{
public function __construct(
private readonly AllegroIntegrationRepository $integrationRepository,
- private readonly AllegroOAuthClient $oauthClient,
+ private readonly AllegroTokenManager $tokenManager,
private readonly AllegroApiClient $apiClient,
private readonly OrderImportRepository $orders,
private readonly AllegroStatusMappingRepository $statusMappings,
@@ -32,33 +31,20 @@ final class AllegroOrderImportService
throw new RuntimeException('Podaj ID zamowienia Allegro.');
}
- $oauth = $this->requireOAuthData();
- [$accessToken, $oauth] = $this->resolveAccessToken($oauth);
+ [$accessToken, $env] = $this->tokenManager->resolveToken();
try {
- $payload = $this->apiClient->getCheckoutForm(
- (string) ($oauth['environment'] ?? 'sandbox'),
- $accessToken,
- $orderId
- );
+ $payload = $this->apiClient->getCheckoutForm($env, $accessToken, $orderId);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
throw $exception;
}
- [$accessToken, $oauth] = $this->forceRefreshToken($oauth);
- $payload = $this->apiClient->getCheckoutForm(
- (string) ($oauth['environment'] ?? 'sandbox'),
- $accessToken,
- $orderId
- );
+ [$accessToken, $env] = $this->tokenManager->resolveToken();
+ $payload = $this->apiClient->getCheckoutForm($env, $accessToken, $orderId);
}
- $mapped = $this->mapCheckoutFormPayload(
- $payload,
- (string) ($oauth['environment'] ?? 'sandbox'),
- $accessToken
- );
+ $mapped = $this->mapCheckoutFormPayload($payload, $env, $accessToken);
$saveResult = $this->orders->upsertOrderAggregate(
$mapped['order'],
$mapped['addresses'],
@@ -98,91 +84,6 @@ final class AllegroOrderImportService
];
}
- /**
- * @return array
- */
- private function requireOAuthData(): array
- {
- $oauth = $this->integrationRepository->getTokenCredentials();
- if ($oauth === null) {
- throw new RuntimeException('Brak kompletnych danych OAuth Allegro. Polacz konto ponownie.');
- }
-
- return $oauth;
- }
-
- /**
- * @param array $oauth
- * @return array{0:string, 1:array}
- */
- private function resolveAccessToken(array $oauth): array
- {
- $tokenExpiresAt = trim((string) ($oauth['token_expires_at'] ?? ''));
- $accessToken = trim((string) ($oauth['access_token'] ?? ''));
- if ($accessToken === '') {
- return $this->forceRefreshToken($oauth);
- }
-
- if ($tokenExpiresAt === '') {
- return [$accessToken, $oauth];
- }
-
- try {
- $expiresAt = new DateTimeImmutable($tokenExpiresAt);
- } catch (Throwable) {
- return $this->forceRefreshToken($oauth);
- }
-
- if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
- return $this->forceRefreshToken($oauth);
- }
-
- return [$accessToken, $oauth];
- }
-
- /**
- * @param array $oauth
- * @return array{0:string, 1:array}
- */
- private function forceRefreshToken(array $oauth): array
- {
- $token = $this->oauthClient->refreshAccessToken(
- (string) ($oauth['environment'] ?? 'sandbox'),
- (string) ($oauth['client_id'] ?? ''),
- (string) ($oauth['client_secret'] ?? ''),
- (string) ($oauth['refresh_token'] ?? '')
- );
-
- $expiresAt = null;
- $expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
- if ($expiresIn > 0) {
- $expiresAt = (new DateTimeImmutable('now'))
- ->add(new DateInterval('PT' . $expiresIn . 'S'))
- ->format('Y-m-d H:i:s');
- }
-
- $refreshToken = trim((string) ($token['refresh_token'] ?? ''));
- if ($refreshToken === '') {
- $refreshToken = (string) ($oauth['refresh_token'] ?? '');
- }
-
- $this->integrationRepository->saveTokens(
- (string) ($token['access_token'] ?? ''),
- $refreshToken,
- (string) ($token['token_type'] ?? ''),
- (string) ($token['scope'] ?? ''),
- $expiresAt
- );
-
- $updatedOauth = $this->requireOAuthData();
- $newAccessToken = trim((string) ($updatedOauth['access_token'] ?? ''));
- if ($newAccessToken === '') {
- throw new RuntimeException('Nie udalo sie zapisac odswiezonego tokenu Allegro.');
- }
-
- return [$newAccessToken, $updatedOauth];
- }
-
/**
* @param array $payload
* @return array{
@@ -238,17 +139,17 @@ final class AllegroOrderImportService
$deliveryForm = $deliveryMethodName !== '' ? $deliveryMethodName : $deliveryMethodId;
$deliveryTime = is_array($delivery['time'] ?? null) ? $delivery['time'] : [];
$dispatchTime = is_array($deliveryTime['dispatch'] ?? null) ? $deliveryTime['dispatch'] : [];
- $sendDateMin = $this->normalizeDateTime((string) ($dispatchTime['from'] ?? ''));
- $sendDateMax = $this->normalizeDateTime((string) ($dispatchTime['to'] ?? ''));
+ $sendDateMin = StringHelper::normalizeDateTime((string) ($dispatchTime['from'] ?? ''));
+ $sendDateMax = StringHelper::normalizeDateTime((string) ($dispatchTime['to'] ?? ''));
if ($sendDateMin === null) {
- $sendDateMin = $this->normalizeDateTime((string) ($deliveryTime['from'] ?? ''));
+ $sendDateMin = StringHelper::normalizeDateTime((string) ($deliveryTime['from'] ?? ''));
}
if ($sendDateMax === null) {
- $sendDateMax = $this->normalizeDateTime((string) ($deliveryTime['to'] ?? ''));
+ $sendDateMax = StringHelper::normalizeDateTime((string) ($deliveryTime['to'] ?? ''));
}
- $boughtAt = $this->normalizeDateTime((string) ($payload['boughtAt'] ?? ''));
- $updatedAt = $this->normalizeDateTime((string) ($payload['updatedAt'] ?? ''));
+ $boughtAt = StringHelper::normalizeDateTime((string) ($payload['boughtAt'] ?? ''));
+ $updatedAt = StringHelper::normalizeDateTime((string) ($payload['updatedAt'] ?? ''));
$fetchedAt = date('Y-m-d H:i:s');
$order = [
@@ -347,8 +248,8 @@ final class AllegroOrderImportService
$result[] = [
'address_type' => 'customer',
'name' => $customerName,
- 'phone' => $this->nullableString((string) ($buyer['phoneNumber'] ?? '')),
- 'email' => $this->nullableString((string) ($buyer['email'] ?? '')),
+ 'phone' => StringHelper::nullableString((string) ($buyer['phoneNumber'] ?? '')),
+ 'email' => StringHelper::nullableString((string) ($buyer['email'] ?? '')),
'street_name' => null,
'street_number' => null,
'city' => null,
@@ -374,17 +275,17 @@ final class AllegroOrderImportService
$name = $this->fallbackName($deliveryAddress, 'Dostawa');
$street = $isPickupPointDelivery
- ? $this->nullableString((string) ($pickupAddress['street'] ?? ''))
- : $this->nullableString((string) ($deliveryAddress['street'] ?? ''));
+ ? StringHelper::nullableString((string) ($pickupAddress['street'] ?? ''))
+ : StringHelper::nullableString((string) ($deliveryAddress['street'] ?? ''));
$city = $isPickupPointDelivery
- ? $this->nullableString((string) ($pickupAddress['city'] ?? ''))
- : $this->nullableString((string) ($deliveryAddress['city'] ?? ''));
+ ? StringHelper::nullableString((string) ($pickupAddress['city'] ?? ''))
+ : StringHelper::nullableString((string) ($deliveryAddress['city'] ?? ''));
$zipCode = $isPickupPointDelivery
- ? $this->nullableString((string) ($pickupAddress['zipCode'] ?? ''))
- : $this->nullableString((string) ($deliveryAddress['zipCode'] ?? ''));
+ ? StringHelper::nullableString((string) ($pickupAddress['zipCode'] ?? ''))
+ : StringHelper::nullableString((string) ($deliveryAddress['zipCode'] ?? ''));
$country = $isPickupPointDelivery
- ? $this->nullableString((string) ($pickupAddress['countryCode'] ?? ''))
- : $this->nullableString((string) ($deliveryAddress['countryCode'] ?? ''));
+ ? StringHelper::nullableString((string) ($pickupAddress['countryCode'] ?? ''))
+ : StringHelper::nullableString((string) ($deliveryAddress['countryCode'] ?? ''));
$deliveryPhone = trim((string) ($deliveryAddress['phoneNumber'] ?? ''));
$buyerPhone = trim((string) ($buyer['phoneNumber'] ?? ''));
@@ -392,19 +293,19 @@ final class AllegroOrderImportService
$result[] = [
'address_type' => 'delivery',
'name' => $name,
- 'phone' => $this->nullableString($deliveryPhone !== '' ? $deliveryPhone : $buyerPhone),
- 'email' => $this->nullableString((string) ($deliveryAddress['email'] ?? $buyer['email'] ?? '')),
+ 'phone' => StringHelper::nullableString($deliveryPhone !== '' ? $deliveryPhone : $buyerPhone),
+ 'email' => StringHelper::nullableString((string) ($deliveryAddress['email'] ?? $buyer['email'] ?? '')),
'street_name' => $street,
'street_number' => null,
'city' => $city,
'zip_code' => $zipCode,
'country' => $country,
'department' => null,
- 'parcel_external_id' => $this->nullableString((string) ($pickupPoint['id'] ?? '')),
- 'parcel_name' => $this->nullableString((string) ($pickupPoint['name'] ?? '')),
+ 'parcel_external_id' => StringHelper::nullableString((string) ($pickupPoint['id'] ?? '')),
+ 'parcel_name' => StringHelper::nullableString((string) ($pickupPoint['name'] ?? '')),
'address_class' => null,
'company_tax_number' => null,
- 'company_name' => $this->nullableString((string) ($deliveryAddress['companyName'] ?? '')),
+ 'company_name' => StringHelper::nullableString((string) ($deliveryAddress['companyName'] ?? '')),
'payload_json' => [
'address' => $deliveryAddress,
'pickup_point' => $pickupPoint,
@@ -417,19 +318,19 @@ final class AllegroOrderImportService
$result[] = [
'address_type' => 'invoice',
'name' => $this->fallbackName($invoiceAddress, 'Faktura'),
- 'phone' => $this->nullableString((string) ($invoiceAddress['phoneNumber'] ?? '')),
- 'email' => $this->nullableString((string) ($invoiceAddress['email'] ?? '')),
- 'street_name' => $this->nullableString((string) ($invoiceAddress['street'] ?? '')),
+ 'phone' => StringHelper::nullableString((string) ($invoiceAddress['phoneNumber'] ?? '')),
+ 'email' => StringHelper::nullableString((string) ($invoiceAddress['email'] ?? '')),
+ 'street_name' => StringHelper::nullableString((string) ($invoiceAddress['street'] ?? '')),
'street_number' => null,
- 'city' => $this->nullableString((string) ($invoiceAddress['city'] ?? '')),
- 'zip_code' => $this->nullableString((string) ($invoiceAddress['zipCode'] ?? '')),
- 'country' => $this->nullableString((string) ($invoiceAddress['countryCode'] ?? '')),
+ 'city' => StringHelper::nullableString((string) ($invoiceAddress['city'] ?? '')),
+ 'zip_code' => StringHelper::nullableString((string) ($invoiceAddress['zipCode'] ?? '')),
+ 'country' => StringHelper::nullableString((string) ($invoiceAddress['countryCode'] ?? '')),
'department' => null,
'parcel_external_id' => null,
'parcel_name' => null,
'address_class' => null,
- 'company_tax_number' => $this->nullableString((string) ($invoiceAddress['taxId'] ?? '')),
- 'company_name' => $this->nullableString((string) ($invoiceAddress['companyName'] ?? '')),
+ 'company_tax_number' => StringHelper::nullableString((string) ($invoiceAddress['taxId'] ?? '')),
+ 'company_name' => StringHelper::nullableString((string) ($invoiceAddress['companyName'] ?? '')),
'payload_json' => $invoiceAddress,
];
}
@@ -516,12 +417,12 @@ final class AllegroOrderImportService
}
$result[] = [
- 'source_item_id' => $this->nullableString((string) ($itemRaw['id'] ?? '')),
- 'external_item_id' => $this->nullableString((string) ($offer['id'] ?? '')),
+ 'source_item_id' => StringHelper::nullableString((string) ($itemRaw['id'] ?? '')),
+ 'external_item_id' => StringHelper::nullableString((string) ($offer['id'] ?? '')),
'ean' => null,
'sku' => null,
'original_name' => $name,
- 'original_code' => $this->nullableString((string) ($offer['id'] ?? '')),
+ 'original_code' => StringHelper::nullableString((string) ($offer['id'] ?? '')),
'original_price_with_tax' => $this->amountToFloat($itemRaw['originalPrice'] ?? null),
'original_price_without_tax' => null,
'media_url' => $mediaUrl,
@@ -530,7 +431,7 @@ final class AllegroOrderImportService
'item_status' => null,
'unit' => 'pcs',
'item_type' => 'product',
- 'source_product_id' => $this->nullableString((string) ($offer['id'] ?? '')),
+ 'source_product_id' => StringHelper::nullableString((string) ($offer['id'] ?? '')),
'source_product_set_id' => null,
'sort_order' => $sortOrder++,
'payload_json' => $itemRaw,
@@ -703,10 +604,10 @@ final class AllegroOrderImportService
'source_payment_id' => $paymentId,
'external_payment_id' => $paymentId,
'payment_type_id' => trim((string) ($payment['type'] ?? 'allegro')),
- 'payment_date' => $this->normalizeDateTime((string) ($payment['finishedAt'] ?? '')),
+ 'payment_date' => StringHelper::normalizeDateTime((string) ($payment['finishedAt'] ?? '')),
'amount' => $amount,
- 'currency' => $this->nullableString((string) ($payment['amount']['currency'] ?? $fallbackCurrency)),
- 'comment' => $this->nullableString((string) ($payment['provider'] ?? '')),
+ 'currency' => StringHelper::nullableString((string) ($payment['amount']['currency'] ?? $fallbackCurrency)),
+ 'comment' => StringHelper::nullableString((string) ($payment['provider'] ?? '')),
'payload_json' => $payment,
]];
}
@@ -731,11 +632,11 @@ final class AllegroOrderImportService
$carrierId = trim((string) ($shipmentRaw['carrierId'] ?? $delivery['method']['id'] ?? ''));
$carrierName = trim((string) ($shipmentRaw['carrierName'] ?? ''));
$result[] = [
- 'source_shipment_id' => $this->nullableString((string) ($shipmentRaw['id'] ?? '')),
- 'external_shipment_id' => $this->nullableString((string) ($shipmentRaw['id'] ?? '')),
+ 'source_shipment_id' => StringHelper::nullableString((string) ($shipmentRaw['id'] ?? '')),
+ 'external_shipment_id' => StringHelper::nullableString((string) ($shipmentRaw['id'] ?? '')),
'tracking_number' => $trackingNumber,
'carrier_provider_id' => $carrierId !== '' ? $carrierId : ($carrierName !== '' ? $carrierName : 'allegro'),
- 'posted_at' => $this->normalizeDateTime((string) ($shipmentRaw['createdAt'] ?? '')),
+ 'posted_at' => StringHelper::normalizeDateTime((string) ($shipmentRaw['createdAt'] ?? '')),
'media_uuid' => null,
'payload_json' => $shipmentRaw,
];
@@ -758,7 +659,7 @@ final class AllegroOrderImportService
return [[
'source_note_id' => null,
'note_type' => 'buyer_message',
- 'created_at_external' => $this->normalizeDateTime((string) ($payload['updatedAt'] ?? '')),
+ 'created_at_external' => StringHelper::normalizeDateTime((string) ($payload['updatedAt'] ?? '')),
'comment' => $message,
'payload_json' => ['messageToSeller' => $message],
]];
@@ -787,20 +688,6 @@ final class AllegroOrderImportService
return (float) $value;
}
- private function normalizeDateTime(string $value): ?string
- {
- $trimmed = trim($value);
- if ($trimmed === '') {
- return null;
- }
-
- try {
- return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s');
- } catch (Throwable) {
- return null;
- }
- }
-
/**
* @param array $address
*/
@@ -819,9 +706,4 @@ final class AllegroOrderImportService
return $fallback;
}
- private function nullableString(string $value): ?string
- {
- $trimmed = trim($value);
- return $trimmed === '' ? null : $trimmed;
- }
}
diff --git a/src/Modules/Settings/AllegroOrderSyncStateRepository.php b/src/Modules/Settings/AllegroOrderSyncStateRepository.php
index 13b1329..d8ab97d 100644
--- a/src/Modules/Settings/AllegroOrderSyncStateRepository.php
+++ b/src/Modules/Settings/AllegroOrderSyncStateRepository.php
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
+use App\Core\Support\StringHelper;
use DateTimeImmutable;
use PDO;
use Throwable;
@@ -68,11 +69,11 @@ final class AllegroOrderSyncStateRepository
}
return [
- 'last_synced_updated_at' => $this->nullableString((string) ($row['last_synced_updated_at'] ?? '')),
- 'last_synced_source_order_id' => $this->nullableString((string) ($row['last_synced_source_order_id'] ?? '')),
- 'last_run_at' => $this->nullableString((string) ($row['last_run_at'] ?? '')),
- 'last_success_at' => $this->nullableString((string) ($row['last_success_at'] ?? '')),
- 'last_error' => $this->nullableString((string) ($row['last_error'] ?? '')),
+ 'last_synced_updated_at' => StringHelper::nullableString((string) ($row['last_synced_updated_at'] ?? '')),
+ 'last_synced_source_order_id' => StringHelper::nullableString((string) ($row['last_synced_source_order_id'] ?? '')),
+ 'last_run_at' => StringHelper::nullableString((string) ($row['last_run_at'] ?? '')),
+ 'last_success_at' => StringHelper::nullableString((string) ($row['last_success_at'] ?? '')),
+ 'last_error' => StringHelper::nullableString((string) ($row['last_error'] ?? '')),
];
}
@@ -267,9 +268,4 @@ final class AllegroOrderSyncStateRepository
];
}
- private function nullableString(string $value): ?string
- {
- $trimmed = trim($value);
- return $trimmed === '' ? null : $trimmed;
- }
}
diff --git a/src/Modules/Settings/AllegroOrdersSyncService.php b/src/Modules/Settings/AllegroOrdersSyncService.php
index 384c44f..fcae563 100644
--- a/src/Modules/Settings/AllegroOrdersSyncService.php
+++ b/src/Modules/Settings/AllegroOrdersSyncService.php
@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
-use DateInterval;
+use App\Core\Support\StringHelper;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
@@ -13,7 +13,7 @@ final class AllegroOrdersSyncService
public function __construct(
private readonly AllegroIntegrationRepository $integrationRepository,
private readonly AllegroOrderSyncStateRepository $syncStateRepository,
- private readonly AllegroOAuthClient $oauthClient,
+ private readonly AllegroTokenManager $tokenManager,
private readonly AllegroApiClient $apiClient,
private readonly AllegroOrderImportService $orderImportService
) {
@@ -56,8 +56,8 @@ final class AllegroOrdersSyncService
$startDateRaw = trim((string) ($settings['orders_fetch_start_date'] ?? ''));
$startDate = $this->normalizeStartDate($startDateRaw);
- $cursorUpdatedAt = $this->nullableString((string) ($state['last_synced_updated_at'] ?? ''));
- $cursorSourceOrderId = $this->nullableString((string) ($state['last_synced_source_order_id'] ?? ''));
+ $cursorUpdatedAt = StringHelper::nullableString((string) ($state['last_synced_updated_at'] ?? ''));
+ $cursorSourceOrderId = StringHelper::nullableString((string) ($state['last_synced_source_order_id'] ?? ''));
$result = [
'enabled' => true,
@@ -75,31 +75,20 @@ final class AllegroOrdersSyncService
$latestProcessedSourceOrderId = $cursorSourceOrderId;
try {
- $oauth = $this->requireOAuthData();
- [$accessToken, $oauth] = $this->resolveAccessToken($oauth);
+ [$accessToken, $env] = $this->tokenManager->resolveToken();
$offset = 0;
$shouldStop = false;
for ($page = 0; $page < $maxPages; $page++) {
try {
- $response = $this->apiClient->listCheckoutForms(
- (string) ($oauth['environment'] ?? 'sandbox'),
- $accessToken,
- $pageLimit,
- $offset
- );
+ $response = $this->apiClient->listCheckoutForms($env, $accessToken, $pageLimit, $offset);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
throw $exception;
}
- [$accessToken, $oauth] = $this->forceRefreshToken($oauth);
- $response = $this->apiClient->listCheckoutForms(
- (string) ($oauth['environment'] ?? 'sandbox'),
- $accessToken,
- $pageLimit,
- $offset
- );
+ [$accessToken, $env] = $this->tokenManager->resolveToken();
+ $response = $this->apiClient->listCheckoutForms($env, $accessToken, $pageLimit, $offset);
}
$forms = is_array($response['checkoutForms'] ?? null) ? $response['checkoutForms'] : [];
@@ -113,7 +102,7 @@ final class AllegroOrdersSyncService
}
$sourceOrderId = trim((string) ($form['id'] ?? ''));
- $sourceUpdatedAt = $this->normalizeDateTime((string) ($form['updatedAt'] ?? $form['boughtAt'] ?? ''));
+ $sourceUpdatedAt = StringHelper::normalizeDateTime((string) ($form['updatedAt'] ?? $form['boughtAt'] ?? ''));
if ($sourceOrderId === '' || $sourceUpdatedAt === null) {
$result['skipped'] = (int) $result['skipped'] + 1;
continue;
@@ -192,105 +181,6 @@ final class AllegroOrdersSyncService
}
}
- /**
- * @return array
- */
- private function requireOAuthData(): array
- {
- $oauth = $this->integrationRepository->getTokenCredentials();
- if ($oauth === null) {
- throw new RuntimeException('Brak kompletnych danych OAuth Allegro. Polacz konto ponownie.');
- }
-
- return $oauth;
- }
-
- /**
- * @param array $oauth
- * @return array{0:string, 1:array}
- */
- private function resolveAccessToken(array $oauth): array
- {
- $tokenExpiresAt = trim((string) ($oauth['token_expires_at'] ?? ''));
- $accessToken = trim((string) ($oauth['access_token'] ?? ''));
- if ($accessToken === '') {
- return $this->forceRefreshToken($oauth);
- }
-
- if ($tokenExpiresAt === '') {
- return [$accessToken, $oauth];
- }
-
- try {
- $expiresAt = new DateTimeImmutable($tokenExpiresAt);
- } catch (Throwable) {
- return $this->forceRefreshToken($oauth);
- }
-
- if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
- return $this->forceRefreshToken($oauth);
- }
-
- return [$accessToken, $oauth];
- }
-
- /**
- * @param array $oauth
- * @return array{0:string, 1:array}
- */
- private function forceRefreshToken(array $oauth): array
- {
- $token = $this->oauthClient->refreshAccessToken(
- (string) ($oauth['environment'] ?? 'sandbox'),
- (string) ($oauth['client_id'] ?? ''),
- (string) ($oauth['client_secret'] ?? ''),
- (string) ($oauth['refresh_token'] ?? '')
- );
-
- $expiresAt = null;
- $expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
- if ($expiresIn > 0) {
- $expiresAt = (new DateTimeImmutable('now'))
- ->add(new DateInterval('PT' . $expiresIn . 'S'))
- ->format('Y-m-d H:i:s');
- }
-
- $refreshToken = trim((string) ($token['refresh_token'] ?? ''));
- if ($refreshToken === '') {
- $refreshToken = (string) ($oauth['refresh_token'] ?? '');
- }
-
- $this->integrationRepository->saveTokens(
- (string) ($token['access_token'] ?? ''),
- $refreshToken,
- (string) ($token['token_type'] ?? ''),
- (string) ($token['scope'] ?? ''),
- $expiresAt
- );
-
- $updatedOauth = $this->requireOAuthData();
- $newAccessToken = trim((string) ($updatedOauth['access_token'] ?? ''));
- if ($newAccessToken === '') {
- throw new RuntimeException('Nie udalo sie zapisac odswiezonego tokenu Allegro.');
- }
-
- return [$newAccessToken, $updatedOauth];
- }
-
- private function normalizeDateTime(string $value): ?string
- {
- $trimmed = trim($value);
- if ($trimmed === '') {
- return null;
- }
-
- try {
- return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s');
- } catch (Throwable) {
- return null;
- }
- }
-
private function normalizeStartDate(string $value): ?string
{
$trimmed = trim($value);
@@ -328,9 +218,4 @@ final class AllegroOrdersSyncService
return strcmp($sourceOrderId, $cursorSourceOrderId) > 0;
}
- private function nullableString(string $value): ?string
- {
- $trimmed = trim($value);
- return $trimmed === '' ? null : $trimmed;
- }
}
diff --git a/src/Modules/Settings/AllegroStatusMappingRepository.php b/src/Modules/Settings/AllegroStatusMappingRepository.php
index fcac410..c86fb79 100644
--- a/src/Modules/Settings/AllegroStatusMappingRepository.php
+++ b/src/Modules/Settings/AllegroStatusMappingRepository.php
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
+use App\Core\Support\StringHelper;
use PDO;
final class AllegroStatusMappingRepository
@@ -59,7 +60,7 @@ final class AllegroStatusMappingRepository
);
$statement->execute([
'allegro_status_code' => $code,
- 'allegro_status_name' => $this->nullableString((string) $allegroStatusName),
+ 'allegro_status_name' => StringHelper::nullableString((string) $allegroStatusName),
'orderpro_status_code' => $orderproCode !== null && $orderproCode !== '' ? $orderproCode : null,
]);
}
@@ -86,7 +87,7 @@ final class AllegroStatusMappingRepository
);
$statement->execute([
'allegro_status_code' => $code,
- 'allegro_status_name' => $this->nullableString((string) $allegroStatusName),
+ 'allegro_status_name' => StringHelper::nullableString((string) $allegroStatusName),
]);
}
@@ -123,9 +124,4 @@ final class AllegroStatusMappingRepository
return $mapped !== '' ? $mapped : null;
}
- private function nullableString(string $value): ?string
- {
- $trimmed = trim($value);
- return $trimmed === '' ? null : $trimmed;
- }
}
diff --git a/src/Modules/Settings/AllegroTokenManager.php b/src/Modules/Settings/AllegroTokenManager.php
new file mode 100644
index 0000000..85d9b83
--- /dev/null
+++ b/src/Modules/Settings/AllegroTokenManager.php
@@ -0,0 +1,97 @@
+repository->getTokenCredentials();
+ if ($oauth === null) {
+ throw new RuntimeException('Brak polaczenia OAuth Allegro. Polacz konto w Ustawieniach.');
+ }
+
+ $env = (string) ($oauth['environment'] ?? 'sandbox');
+ $accessToken = trim((string) ($oauth['access_token'] ?? ''));
+ $tokenExpiresAt = trim((string) ($oauth['token_expires_at'] ?? ''));
+
+ if ($accessToken === '') {
+ return $this->forceRefresh();
+ }
+
+ if ($tokenExpiresAt !== '') {
+ try {
+ $expiresAt = new DateTimeImmutable($tokenExpiresAt);
+ if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
+ return $this->forceRefresh();
+ }
+ } catch (Throwable) {
+ return $this->forceRefresh();
+ }
+ }
+
+ return [$accessToken, $env];
+ }
+
+ /**
+ * @return array{0: string, 1: string}
+ */
+ private function forceRefresh(): array
+ {
+ $oauth = $this->repository->getTokenCredentials();
+ if ($oauth === null) {
+ throw new RuntimeException('Brak danych OAuth Allegro.');
+ }
+
+ $token = $this->oauthClient->refreshAccessToken(
+ (string) ($oauth['environment'] ?? 'sandbox'),
+ (string) ($oauth['client_id'] ?? ''),
+ (string) ($oauth['client_secret'] ?? ''),
+ (string) ($oauth['refresh_token'] ?? '')
+ );
+
+ $expiresAt = null;
+ $expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
+ if ($expiresIn > 0) {
+ $expiresAt = (new DateTimeImmutable('now'))
+ ->add(new DateInterval('PT' . $expiresIn . 'S'))
+ ->format('Y-m-d H:i:s');
+ }
+
+ $refreshToken = trim((string) ($token['refresh_token'] ?? ''));
+ if ($refreshToken === '') {
+ $refreshToken = (string) ($oauth['refresh_token'] ?? '');
+ }
+
+ $this->repository->saveTokens(
+ (string) ($token['access_token'] ?? ''),
+ $refreshToken,
+ (string) ($token['token_type'] ?? ''),
+ (string) ($token['scope'] ?? ''),
+ $expiresAt
+ );
+
+ $updated = $this->repository->getTokenCredentials();
+ $newToken = trim((string) ($updated['access_token'] ?? ''));
+ if ($newToken === '') {
+ throw new RuntimeException('Nie udalo sie odswiezyc tokenu Allegro.');
+ }
+
+ return [$newToken, (string) ($updated['environment'] ?? 'sandbox')];
+ }
+}
diff --git a/src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php b/src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php
index 4bf34ad..7efcd84 100644
--- a/src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php
+++ b/src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php
@@ -194,12 +194,6 @@ final class CarrierDeliveryMethodMappingRepository
return in_array($source, ['allegro', 'shoppro'], true) ? $source : 'allegro';
}
- private function nullableString(string $value): ?string
- {
- $trimmed = trim($value);
- return $trimmed === '' ? null : $trimmed;
- }
-
private function nullableLimited(string $value, int $max): ?string
{
$trimmed = $this->limit(trim($value), $max);
diff --git a/src/Modules/Settings/CompanySettingsRepository.php b/src/Modules/Settings/CompanySettingsRepository.php
index abd8d2f..0a0306d 100644
--- a/src/Modules/Settings/CompanySettingsRepository.php
+++ b/src/Modules/Settings/CompanySettingsRepository.php
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
+use App\Core\Support\StringHelper;
use PDO;
use Throwable;
@@ -82,18 +83,18 @@ final class CompanySettingsRepository
);
$statement->execute([
- 'company_name' => $this->nullableString((string) ($data['company_name'] ?? '')),
- 'person_name' => $this->nullableString((string) ($data['person_name'] ?? '')),
- 'sender_contact_person' => $this->nullableString((string) ($data['sender_contact_person'] ?? '')),
- 'street' => $this->nullableString((string) ($data['street'] ?? '')),
- 'city' => $this->nullableString((string) ($data['city'] ?? '')),
- 'postal_code' => $this->nullableString((string) ($data['postal_code'] ?? '')),
+ 'company_name' => StringHelper::nullableString((string) ($data['company_name'] ?? '')),
+ 'person_name' => StringHelper::nullableString((string) ($data['person_name'] ?? '')),
+ 'sender_contact_person' => StringHelper::nullableString((string) ($data['sender_contact_person'] ?? '')),
+ 'street' => StringHelper::nullableString((string) ($data['street'] ?? '')),
+ 'city' => StringHelper::nullableString((string) ($data['city'] ?? '')),
+ 'postal_code' => StringHelper::nullableString((string) ($data['postal_code'] ?? '')),
'country_code' => strtoupper(trim((string) ($data['country_code'] ?? 'PL'))) ?: 'PL',
- 'phone' => $this->nullableString((string) ($data['phone'] ?? '')),
- 'email' => $this->nullableString((string) ($data['email'] ?? '')),
- 'tax_number' => $this->nullableString((string) ($data['tax_number'] ?? '')),
- 'bank_account' => $this->nullableString((string) ($data['bank_account'] ?? '')),
- 'bank_owner_name' => $this->nullableString((string) ($data['bank_owner_name'] ?? '')),
+ 'phone' => StringHelper::nullableString((string) ($data['phone'] ?? '')),
+ 'email' => StringHelper::nullableString((string) ($data['email'] ?? '')),
+ 'tax_number' => StringHelper::nullableString((string) ($data['tax_number'] ?? '')),
+ 'bank_account' => StringHelper::nullableString((string) ($data['bank_account'] ?? '')),
+ 'bank_owner_name' => StringHelper::nullableString((string) ($data['bank_owner_name'] ?? '')),
'length' => max(0.1, (float) ($data['default_package_length_cm'] ?? 25.0)),
'width' => max(0.1, (float) ($data['default_package_width_cm'] ?? 20.0)),
'height' => max(0.1, (float) ($data['default_package_height_cm'] ?? 8.0)),
@@ -132,12 +133,6 @@ final class CompanySettingsRepository
);
}
- private function nullableString(string $value): ?string
- {
- $trimmed = trim($value);
- return $trimmed === '' ? null : $trimmed;
- }
-
/**
* @return array
*/
diff --git a/src/Modules/Settings/InpostIntegrationRepository.php b/src/Modules/Settings/InpostIntegrationRepository.php
index 3471b5d..5207d98 100644
--- a/src/Modules/Settings/InpostIntegrationRepository.php
+++ b/src/Modules/Settings/InpostIntegrationRepository.php
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
+use App\Core\Support\StringHelper;
use PDO;
use RuntimeException;
use Throwable;
@@ -98,15 +99,15 @@ final class InpostIntegrationRepository
WHERE id = 1'
);
$statement->execute([
- 'api_token_encrypted' => $this->nullableString((string) $nextEncrypted),
- 'organization_id' => $this->nullableString(trim((string) ($payload['organization_id'] ?? ''))),
+ 'api_token_encrypted' => StringHelper::nullableString((string) $nextEncrypted),
+ 'organization_id' => StringHelper::nullableString(trim((string) ($payload['organization_id'] ?? ''))),
'environment' => in_array($payload['environment'] ?? '', ['sandbox', 'production'], true)
? $payload['environment']
: 'sandbox',
'default_dispatch_method' => in_array($payload['default_dispatch_method'] ?? '', ['pop', 'parcel_locker', 'courier'], true)
? $payload['default_dispatch_method']
: 'pop',
- 'default_dispatch_point' => $this->nullableString(trim((string) ($payload['default_dispatch_point'] ?? ''))),
+ 'default_dispatch_point' => StringHelper::nullableString(trim((string) ($payload['default_dispatch_point'] ?? ''))),
'default_insurance' => ($payload['default_insurance'] ?? '') !== ''
? (float) $payload['default_insurance']
: null,
@@ -215,9 +216,4 @@ final class InpostIntegrationRepository
);
}
- private function nullableString(string $value): ?string
- {
- $trimmed = trim($value);
- return $trimmed === '' ? null : $trimmed;
- }
}
diff --git a/src/Modules/Settings/IntegrationsRepository.php b/src/Modules/Settings/IntegrationsRepository.php
index 3060a8d..a9f52b6 100644
--- a/src/Modules/Settings/IntegrationsRepository.php
+++ b/src/Modules/Settings/IntegrationsRepository.php
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
+use App\Core\Support\StringHelper;
use PDO;
use Throwable;
@@ -119,7 +120,7 @@ final class IntegrationsRepository
);
$statement->execute([
'id' => $integrationId,
- 'api_key_encrypted' => $this->nullableString((string) $encrypted),
+ 'api_key_encrypted' => StringHelper::nullableString((string) $encrypted),
]);
}
@@ -147,9 +148,4 @@ final class IntegrationsRepository
return $trimmed === '' ? null : $trimmed;
}
- private function nullableString(string $value): ?string
- {
- $trimmed = trim($value);
- return $trimmed === '' ? null : $trimmed;
- }
}
diff --git a/src/Modules/Settings/SettingsController.php b/src/Modules/Settings/SettingsController.php
index 5649067..f7a8fda 100644
--- a/src/Modules/Settings/SettingsController.php
+++ b/src/Modules/Settings/SettingsController.php
@@ -9,6 +9,7 @@ use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
+use App\Core\Support\StringHelper;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use Throwable;
@@ -113,7 +114,7 @@ final class SettingsController
return Response::redirect('/settings/statuses');
}
- $colorHex = $this->normalizeColorHex((string) $request->input('color_hex', '#64748b'));
+ $colorHex = StringHelper::normalizeColorHex((string) $request->input('color_hex', '#64748b'));
$isActive = (string) $request->input('is_active', '1') === '1';
try {
@@ -147,7 +148,7 @@ final class SettingsController
return Response::redirect('/settings/statuses');
}
- $colorHex = $this->normalizeColorHex((string) $request->input('color_hex', '#64748b'));
+ $colorHex = StringHelper::normalizeColorHex((string) $request->input('color_hex', '#64748b'));
$isActive = (string) $request->input('is_active', '0') === '1';
$code = trim((string) ($existingGroup['code'] ?? ''));
@@ -352,16 +353,6 @@ final class SettingsController
return Response::redirect('/settings/statuses');
}
- private function normalizeColorHex(string $value): string
- {
- $trimmed = trim($value);
- if (preg_match('/^#[0-9a-fA-F]{6}$/', $trimmed) === 1) {
- return strtolower($trimmed);
- }
-
- return '#64748b';
- }
-
private function normalizeCode(string $code, string $fallbackName = ''): string
{
$source = trim($code) !== '' ? $code : $fallbackName;
diff --git a/src/Modules/Settings/ShopproIntegrationsRepository.php b/src/Modules/Settings/ShopproIntegrationsRepository.php
index 5a54e51..26ccb2d 100644
--- a/src/Modules/Settings/ShopproIntegrationsRepository.php
+++ b/src/Modules/Settings/ShopproIntegrationsRepository.php
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
+use App\Core\Support\StringHelper;
use PDO;
use RuntimeException;
use Throwable;
@@ -180,7 +181,7 @@ final class ShopproIntegrationsRepository
'type' => self::TYPE,
'name' => $name,
'base_url' => $baseUrl,
- 'api_key_encrypted' => $this->nullableString($encryptedApiKey),
+ 'api_key_encrypted' => StringHelper::nullableString($encryptedApiKey),
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive,
'orders_fetch_enabled' => $ordersFetchEnabled,
@@ -204,7 +205,7 @@ final class ShopproIntegrationsRepository
'type' => self::TYPE,
'name' => $name,
'base_url' => $baseUrl,
- 'api_key_encrypted' => $this->nullableString((string) $encryptedApiKey),
+ 'api_key_encrypted' => StringHelper::nullableString((string) $encryptedApiKey),
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive,
'orders_fetch_enabled' => $ordersFetchEnabled,
@@ -424,9 +425,9 @@ final class ShopproIntegrationsRepository
$statement->execute([
'id' => $integrationId,
'type' => self::TYPE,
- 'last_test_status' => $this->nullableString($status),
+ 'last_test_status' => StringHelper::nullableString($status),
'last_test_http_code' => $httpCode,
- 'last_test_message' => $this->nullableString($message),
+ 'last_test_message' => StringHelper::nullableString($message),
]);
$log = $this->pdo->prepare(
@@ -448,12 +449,6 @@ final class ShopproIntegrationsRepository
}
}
- private function nullableString(string $value): ?string
- {
- $trimmed = trim($value);
- return $trimmed === '' ? null : $trimmed;
- }
-
private function normalizeDate(string $value): ?string
{
$trimmed = trim($value);
diff --git a/src/Modules/Settings/ShopproOrdersSyncService.php b/src/Modules/Settings/ShopproOrdersSyncService.php
index 1bc3477..da62a75 100644
--- a/src/Modules/Settings/ShopproOrdersSyncService.php
+++ b/src/Modules/Settings/ShopproOrdersSyncService.php
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
+use App\Core\Support\StringHelper;
use App\Modules\Orders\OrderImportRepository;
use App\Modules\Orders\OrdersRepository;
use DateTimeImmutable;
@@ -63,8 +64,8 @@ final class ShopproOrdersSyncService
try {
$statusMap = $this->buildStatusMap($integrationId);
- $cursorUpdatedAt = $this->nullableString((string) ($state['last_synced_updated_at'] ?? ''));
- $cursorOrderId = $this->nullableString((string) ($state['last_synced_source_order_id'] ?? ''));
+ $cursorUpdatedAt = StringHelper::nullableString((string) ($state['last_synced_updated_at'] ?? ''));
+ $cursorOrderId = StringHelper::nullableString((string) ($state['last_synced_source_order_id'] ?? ''));
$startDate = $this->resolveStartDate(
(string) ($integration['orders_fetch_start_date'] ?? ''),
$cursorUpdatedAt
@@ -265,7 +266,7 @@ final class ShopproOrdersSyncService
private function resolveStartDate(string $settingsDate, ?string $cursorUpdatedAt): ?string
{
$settings = trim($settingsDate);
- $cursor = $this->nullableString((string) $cursorUpdatedAt);
+ $cursor = StringHelper::nullableString((string) $cursorUpdatedAt);
if ($settings === '' && $cursor === null) {
return null;
}
@@ -288,7 +289,7 @@ final class ShopproOrdersSyncService
$result = [];
foreach ($items as $row) {
$sourceOrderId = $this->normalizeOrderId($this->readPath($row, ['id', 'order_id', 'external_order_id']));
- $sourceUpdatedAt = $this->normalizeDateTime((string) $this->readPath($row, ['updated_at', 'date_updated', 'modified_at', 'date_modified', 'created_at', 'date_created']));
+ $sourceUpdatedAt = StringHelper::normalizeDateTime((string) $this->readPath($row, ['updated_at', 'date_updated', 'modified_at', 'date_modified', 'created_at', 'date_created']));
if ($sourceOrderId === '' || $sourceUpdatedAt === null) {
continue;
}
@@ -360,8 +361,8 @@ final class ShopproOrdersSyncService
$sourceOrderId = $fallbackOrderId;
}
- $sourceCreatedAt = $this->normalizeDateTime((string) $this->readPath($payload, ['created_at', 'date_created', 'date_add']));
- $sourceUpdatedAt = $this->normalizeDateTime((string) $this->readPath($payload, ['updated_at', 'date_updated', 'modified_at', 'date_modified', 'created_at']));
+ $sourceCreatedAt = StringHelper::normalizeDateTime((string) $this->readPath($payload, ['created_at', 'date_created', 'date_add']));
+ $sourceUpdatedAt = StringHelper::normalizeDateTime((string) $this->readPath($payload, ['updated_at', 'date_updated', 'modified_at', 'date_modified', 'created_at']));
if ($sourceUpdatedAt === null) {
$sourceUpdatedAt = $fallbackUpdatedAt !== '' ? $fallbackUpdatedAt : date('Y-m-d H:i:s');
}
@@ -425,13 +426,13 @@ final class ShopproOrdersSyncService
'external_platform_id' => 'shoppro',
'external_platform_account_id' => null,
'external_status_id' => $effectiveStatus,
- 'external_payment_type_id' => $this->nullableString((string) $this->readPath($payload, ['payment_method', 'payment.method', 'payments.method'])),
+ 'external_payment_type_id' => StringHelper::nullableString((string) $this->readPath($payload, ['payment_method', 'payment.method', 'payments.method'])),
'payment_status' => $this->mapPaymentStatus($payload, $isPaid),
- 'external_carrier_id' => $this->nullableString($deliveryLabel),
- 'external_carrier_account_id' => $this->nullableString((string) $this->readPath($payload, [
+ 'external_carrier_id' => StringHelper::nullableString($deliveryLabel),
+ 'external_carrier_account_id' => StringHelper::nullableString((string) $this->readPath($payload, [
'transport_id', 'shipping.method_id', 'delivery.method_id',
])),
- 'customer_login' => $this->nullableString((string) $this->readPath($payload, [
+ 'customer_login' => StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_email', 'customer.email', 'buyer.email', 'client.email', 'email', 'customer.login', 'buyer.login',
])),
'is_invoice' => $this->resolveInvoiceRequested($payload),
@@ -484,17 +485,17 @@ final class ShopproOrdersSyncService
{
$result = [];
- $customerFirstName = $this->nullableString((string) $this->readPath($payload, [
+ $customerFirstName = StringHelper::nullableString((string) $this->readPath($payload, [
'buyer.first_name', 'buyer.firstname', 'customer.first_name', 'customer.firstname',
'client.first_name', 'client.firstname', 'billing_address.first_name', 'billing_address.firstname',
'first_name', 'firstname', 'client_name', 'imie',
]));
- $customerLastName = $this->nullableString((string) $this->readPath($payload, [
+ $customerLastName = StringHelper::nullableString((string) $this->readPath($payload, [
'buyer.last_name', 'buyer.lastname', 'customer.last_name', 'customer.lastname',
'client.last_name', 'client.lastname', 'billing_address.last_name', 'billing_address.lastname',
'last_name', 'lastname', 'client_surname', 'nazwisko',
]));
- $customerName = $this->nullableString((string) $this->readPath($payload, [
+ $customerName = StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_name', 'buyer.name', 'customer.name', 'client.name', 'billing_address.name',
'receiver.name', 'client', 'customer_full_name', 'client_full_name',
]));
@@ -502,11 +503,11 @@ final class ShopproOrdersSyncService
$customerName = $this->composeName($customerFirstName, $customerLastName, 'Klient');
}
- $customerEmail = $this->nullableString((string) $this->readPath($payload, [
+ $customerEmail = StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_email', 'buyer.email', 'customer.email', 'client.email', 'billing_address.email',
'shipping_address.email', 'delivery_address.email', 'email', 'client_email', 'mail',
]));
- $customerPhone = $this->nullableString((string) $this->readPath($payload, [
+ $customerPhone = StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_phone', 'buyer.phone', 'customer.phone', 'client.phone', 'billing_address.phone',
'shipping_address.phone', 'delivery_address.phone', 'phone', 'telephone', 'client_phone',
'phone_number', 'client_phone_number',
@@ -517,25 +518,25 @@ final class ShopproOrdersSyncService
'name' => $customerName ?? 'Klient',
'phone' => $customerPhone,
'email' => $customerEmail,
- 'street_name' => $this->nullableString((string) $this->readPath($payload, [
+ 'street_name' => StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_address.street', 'customer.address.street', 'billing_address.street', 'client.address.street',
'address.street', 'street', 'client_street', 'ulica',
])),
- 'street_number' => $this->nullableString((string) $this->readPath($payload, [
+ 'street_number' => StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_address.street_number', 'customer.address.street_number', 'billing_address.street_number',
'billing_address.house_number', 'client.address.street_number', 'address.street_number',
'house_number', 'street_no', 'street_number', 'nr_domu',
])),
- 'city' => $this->nullableString((string) $this->readPath($payload, [
+ 'city' => StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_address.city', 'customer.address.city', 'billing_address.city', 'client.address.city',
'address.city', 'city', 'client_city', 'miejscowosc',
])),
- 'zip_code' => $this->nullableString((string) $this->readPath($payload, [
+ 'zip_code' => StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_address.zip', 'buyer_address.postcode', 'customer.address.zip', 'customer.address.postcode',
'billing_address.zip', 'billing_address.postcode', 'client.address.zip', 'address.zip',
'address.postcode', 'zip', 'postcode', 'client_postal_code', 'kod_pocztowy',
])),
- 'country' => $this->nullableString((string) $this->readPath($payload, [
+ 'country' => StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_address.country', 'customer.address.country', 'billing_address.country', 'client.address.country',
'address.country', 'country', 'kraj',
])),
@@ -553,17 +554,17 @@ final class ShopproOrdersSyncService
$result[] = $invoiceAddress;
}
- $deliveryFirstName = $this->nullableString((string) $this->readPath($payload, [
+ $deliveryFirstName = StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.first_name', 'delivery.address.firstname', 'shipping.address.first_name', 'shipping.address.firstname',
'delivery_address.first_name', 'delivery_address.firstname', 'shipping_address.first_name', 'shipping_address.firstname',
'receiver.first_name', 'receiver.firstname', 'delivery_first_name', 'shipping_first_name',
]));
- $deliveryLastName = $this->nullableString((string) $this->readPath($payload, [
+ $deliveryLastName = StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.last_name', 'delivery.address.lastname', 'shipping.address.last_name', 'shipping.address.lastname',
'delivery_address.last_name', 'delivery_address.lastname', 'shipping_address.last_name', 'shipping_address.lastname',
'receiver.last_name', 'receiver.lastname', 'delivery_last_name', 'shipping_last_name',
]));
- $deliveryName = $this->nullableString((string) $this->readPath($payload, [
+ $deliveryName = StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.name', 'shipping.address.name', 'delivery_address.name', 'shipping_address.name',
'receiver.name', 'delivery_name', 'shipping_name',
]));
@@ -574,39 +575,39 @@ final class ShopproOrdersSyncService
$pickupData = $this->parsePickupPoint((string) $this->readPath($payload, ['inpost_paczkomat', 'orlen_point', 'pickup_point']));
$deliveryAddress = [
'name' => $deliveryName,
- 'phone' => $this->nullableString((string) $this->readPath($payload, [
+ 'phone' => StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.phone', 'shipping.address.phone', 'delivery_address.phone', 'shipping_address.phone',
'receiver.phone', 'delivery_phone', 'shipping_phone',
])) ?? $customerPhone,
- 'email' => $this->nullableString((string) $this->readPath($payload, [
+ 'email' => StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.email', 'shipping.address.email', 'delivery_address.email', 'shipping_address.email',
'receiver.email', 'delivery_email', 'shipping_email',
])) ?? $customerEmail,
- 'street_name' => $this->nullableString((string) $this->readPath($payload, [
+ 'street_name' => StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.street', 'shipping.address.street', 'delivery_address.street', 'shipping_address.street',
'receiver.address.street', 'delivery_street', 'shipping_street',
- ])) ?? $this->nullableString($pickupData['street'] ?? ''),
- 'street_number' => $this->nullableString((string) $this->readPath($payload, [
+ ])) ?? StringHelper::nullableString($pickupData['street'] ?? ''),
+ 'street_number' => StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.street_number', 'shipping.address.street_number', 'delivery_address.street_number', 'shipping_address.street_number',
'delivery.address.house_number', 'shipping.address.house_number', 'receiver.address.street_number',
'receiver.address.house_number', 'delivery_street_number', 'shipping_street_number',
])),
- 'city' => $this->nullableString((string) $this->readPath($payload, [
+ 'city' => StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.city', 'shipping.address.city', 'delivery_address.city', 'shipping_address.city',
'receiver.address.city', 'delivery_city', 'shipping_city',
- ])) ?? $this->nullableString($pickupData['city'] ?? ''),
- 'zip_code' => $this->nullableString((string) $this->readPath($payload, [
+ ])) ?? StringHelper::nullableString($pickupData['city'] ?? ''),
+ 'zip_code' => StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.zip', 'delivery.address.postcode', 'shipping.address.zip', 'shipping.address.postcode',
'delivery_address.zip', 'delivery_address.postcode', 'shipping_address.zip', 'shipping_address.postcode',
'receiver.address.zip', 'receiver.address.postcode', 'delivery_zip', 'delivery_postcode',
'shipping_zip', 'shipping_postcode',
- ])) ?? $this->nullableString($pickupData['zip_code'] ?? ''),
- 'country' => $this->nullableString((string) $this->readPath($payload, [
+ ])) ?? StringHelper::nullableString($pickupData['zip_code'] ?? ''),
+ 'country' => StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.country', 'shipping.address.country', 'delivery_address.country', 'shipping_address.country',
'receiver.address.country', 'delivery_country', 'shipping_country',
])),
- 'parcel_external_id' => $this->nullableString($pickupData['code'] ?? ''),
- 'parcel_name' => $this->nullableString($pickupData['label'] ?? ''),
+ 'parcel_external_id' => StringHelper::nullableString($pickupData['code'] ?? ''),
+ 'parcel_name' => StringHelper::nullableString($pickupData['label'] ?? ''),
'payload_json' => [
'delivery' => $this->readPath($payload, ['delivery']),
'shipping' => $this->readPath($payload, ['shipping']),
@@ -619,7 +620,7 @@ final class ShopproOrdersSyncService
];
if (($deliveryAddress['name'] ?? null) === null) {
- $deliveryAddress['name'] = $this->nullableString($this->buildDeliveryMethodLabel($payload));
+ $deliveryAddress['name'] = StringHelper::nullableString($this->buildDeliveryMethodLabel($payload));
}
$hasDeliveryData = $this->hasAddressData($deliveryAddress);
@@ -653,11 +654,11 @@ final class ShopproOrdersSyncService
return true;
}
- $companyName = $this->nullableString((string) $this->readPath($payload, [
+ $companyName = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.company_name', 'invoice.company', 'billing.company_name', 'billing.company',
'firm_name', 'company_name', 'client_company', 'buyer_company',
]));
- $taxNumber = $this->nullableString((string) $this->readPath($payload, [
+ $taxNumber = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.tax_id', 'invoice.nip', 'billing.tax_id', 'billing.nip',
'firm_nip', 'company_nip', 'tax_id', 'nip',
]));
@@ -675,50 +676,50 @@ final class ShopproOrdersSyncService
?string $customerEmail,
?string $customerPhone
): ?array {
- $companyName = $this->nullableString((string) $this->readPath($payload, [
+ $companyName = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.company_name', 'invoice.company', 'billing.company_name', 'billing.company',
'firm_name', 'company_name', 'client_company', 'buyer_company',
]));
- $companyTaxNumber = $this->nullableString((string) $this->readPath($payload, [
+ $companyTaxNumber = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.tax_id', 'invoice.nip', 'billing.tax_id', 'billing.nip',
'firm_nip', 'company_nip', 'tax_id', 'nip',
]));
- $invoiceFirstName = $this->nullableString((string) $this->readPath($payload, [
+ $invoiceFirstName = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.first_name', 'invoice.firstname', 'billing_address.first_name', 'billing_address.firstname',
'buyer.first_name', 'customer.first_name', 'client_name',
]));
- $invoiceLastName = $this->nullableString((string) $this->readPath($payload, [
+ $invoiceLastName = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.last_name', 'invoice.lastname', 'billing_address.last_name', 'billing_address.lastname',
'buyer.last_name', 'customer.last_name', 'client_surname',
]));
$invoiceName = $companyName ?? $this->composeName($invoiceFirstName, $invoiceLastName, $customerName ?? 'Faktura');
- $streetName = $this->nullableString((string) $this->readPath($payload, [
+ $streetName = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.address.street', 'invoice.street', 'billing_address.street', 'billing.street',
'firm_street', 'company_street',
]));
- $streetNumber = $this->nullableString((string) $this->readPath($payload, [
+ $streetNumber = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.address.street_number', 'invoice.street_number', 'invoice.house_number',
'billing_address.street_number', 'billing_address.house_number',
'billing.street_number', 'house_number', 'street_number',
]));
- $city = $this->nullableString((string) $this->readPath($payload, [
+ $city = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.address.city', 'invoice.city', 'billing_address.city', 'billing.city',
'firm_city', 'company_city',
]));
- $zipCode = $this->nullableString((string) $this->readPath($payload, [
+ $zipCode = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.address.zip', 'invoice.address.postcode', 'invoice.zip', 'invoice.postcode',
'billing_address.zip', 'billing_address.postcode', 'billing.zip', 'billing.postcode',
'firm_postal_code', 'company_postal_code',
]));
- $country = $this->nullableString((string) $this->readPath($payload, [
+ $country = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.address.country', 'invoice.country', 'billing_address.country', 'billing.country',
'firm_country', 'company_country',
]));
- $email = $this->nullableString((string) $this->readPath($payload, [
+ $email = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.email', 'billing_address.email', 'billing.email', 'client_email',
])) ?? $customerEmail;
- $phone = $this->nullableString((string) $this->readPath($payload, [
+ $phone = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.phone', 'billing_address.phone', 'billing.phone', 'client_phone',
])) ?? $customerPhone;
@@ -814,31 +815,31 @@ final class ShopproOrdersSyncService
$productId = (int) $this->readPath($row, ['product_id']);
$parentProductId = (int) $this->readPath($row, ['parent_product_id']);
- $mediaUrl = $this->nullableString((string) $this->readPath($row, ['image', 'image_url', 'img_url', 'img', 'photo', 'photo_url']));
+ $mediaUrl = StringHelper::nullableString((string) $this->readPath($row, ['image', 'image_url', 'img_url', 'img', 'photo', 'photo_url']));
if ($mediaUrl === null && $productId > 0 && isset($productImagesById[$productId])) {
- $mediaUrl = $this->nullableString((string) $productImagesById[$productId]);
+ $mediaUrl = StringHelper::nullableString((string) $productImagesById[$productId]);
}
if ($mediaUrl === null && $parentProductId > 0 && isset($productImagesById[$parentProductId])) {
- $mediaUrl = $this->nullableString((string) $productImagesById[$parentProductId]);
+ $mediaUrl = StringHelper::nullableString((string) $productImagesById[$parentProductId]);
}
$result[] = [
- 'source_item_id' => $this->nullableString((string) $this->readPath($row, ['id', 'item_id'])),
- 'external_item_id' => $this->nullableString((string) $this->readPath($row, ['id', 'item_id'])),
- 'ean' => $this->nullableString((string) $this->readPath($row, ['ean'])),
- 'sku' => $this->nullableString((string) $this->readPath($row, ['sku', 'symbol', 'code'])),
+ 'source_item_id' => StringHelper::nullableString((string) $this->readPath($row, ['id', 'item_id'])),
+ 'external_item_id' => StringHelper::nullableString((string) $this->readPath($row, ['id', 'item_id'])),
+ 'ean' => StringHelper::nullableString((string) $this->readPath($row, ['ean'])),
+ 'sku' => StringHelper::nullableString((string) $this->readPath($row, ['sku', 'symbol', 'code'])),
'original_name' => $name,
- 'original_code' => $this->nullableString((string) $this->readPath($row, ['code', 'symbol'])),
+ 'original_code' => StringHelper::nullableString((string) $this->readPath($row, ['code', 'symbol'])),
'original_price_with_tax' => $this->toFloatOrNull($this->readPath($row, ['price_gross', 'gross_price', 'price', 'price_brutto'])),
'original_price_without_tax' => $this->toFloatOrNull($this->readPath($row, ['price_net', 'net_price', 'price_netto'])),
'media_url' => $mediaUrl,
'quantity' => $this->toFloatOrDefault($this->readPath($row, ['quantity', 'qty']), 1.0),
'tax_rate' => $this->toFloatOrNull($this->readPath($row, ['vat', 'tax_rate'])),
'item_status' => null,
- 'unit' => $this->nullableString((string) $this->readPath($row, ['unit'])),
+ 'unit' => StringHelper::nullableString((string) $this->readPath($row, ['unit'])),
'item_type' => 'product',
- 'source_product_id' => $this->nullableString((string) ($productId > 0 ? $productId : $parentProductId)),
- 'source_product_set_id' => $this->nullableString((string) ($parentProductId > 0 ? $parentProductId : '')),
+ 'source_product_id' => StringHelper::nullableString((string) ($productId > 0 ? $productId : $parentProductId)),
+ 'source_product_set_id' => StringHelper::nullableString((string) ($parentProductId > 0 ? $parentProductId : '')),
'sort_order' => $sort++,
'payload_json' => $row,
];
@@ -853,7 +854,7 @@ final class ShopproOrdersSyncService
*/
private function mapPayments(array $payload, string $currency, ?float $totalPaid): array
{
- $paymentMethod = $this->nullableString((string) $this->readPath($payload, ['payment_method', 'payment.method']));
+ $paymentMethod = StringHelper::nullableString((string) $this->readPath($payload, ['payment_method', 'payment.method']));
if ($paymentMethod === null && $totalPaid === null) {
return [];
}
@@ -862,10 +863,10 @@ final class ShopproOrdersSyncService
'source_payment_id' => null,
'external_payment_id' => null,
'payment_type_id' => $paymentMethod ?? 'unknown',
- 'payment_date' => $this->nullableString((string) $this->readPath($payload, ['payment_date', 'payment.date'])),
+ 'payment_date' => StringHelper::nullableString((string) $this->readPath($payload, ['payment_date', 'payment.date'])),
'amount' => $totalPaid,
'currency' => $currency,
- 'comment' => $this->nullableString((string) $this->readPath($payload, ['payment_status', 'payment.status'])),
+ 'comment' => StringHelper::nullableString((string) $this->readPath($payload, ['payment_status', 'payment.status'])),
'payload_json' => null,
]];
}
@@ -876,7 +877,7 @@ final class ShopproOrdersSyncService
*/
private function mapShipments(array $payload): array
{
- $tracking = $this->nullableString((string) $this->readPath($payload, ['delivery_tracking_number', 'delivery.tracking_number', 'shipping.tracking_number']));
+ $tracking = StringHelper::nullableString((string) $this->readPath($payload, ['delivery_tracking_number', 'delivery.tracking_number', 'shipping.tracking_number']));
if ($tracking === null) {
return [];
}
@@ -888,7 +889,7 @@ final class ShopproOrdersSyncService
'carrier_provider_id' => $this->sanitizePlainText((string) ($this->readPath($payload, [
'delivery_method', 'shipping.method', 'transport', 'transport_description',
]) ?? 'unknown')),
- 'posted_at' => $this->nullableString((string) $this->readPath($payload, ['delivery.posted_at', 'shipping.posted_at'])),
+ 'posted_at' => StringHelper::nullableString((string) $this->readPath($payload, ['delivery.posted_at', 'shipping.posted_at'])),
'media_uuid' => null,
'payload_json' => null,
]];
@@ -900,7 +901,7 @@ final class ShopproOrdersSyncService
*/
private function mapNotes(array $payload): array
{
- $comment = $this->nullableString((string) $this->readPath($payload, ['notes', 'comment', 'customer_comment']));
+ $comment = StringHelper::nullableString((string) $this->readPath($payload, ['notes', 'comment', 'customer_comment']));
if ($comment === null) {
return [];
}
@@ -919,25 +920,6 @@ final class ShopproOrdersSyncService
return trim((string) $value);
}
- private function normalizeDateTime(string $value): ?string
- {
- $trimmed = trim($value);
- if ($trimmed === '') {
- return null;
- }
- try {
- return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s');
- } catch (Throwable) {
- return null;
- }
- }
-
- private function nullableString(string $value): ?string
- {
- $trimmed = trim($value);
- return $trimmed === '' ? null : $trimmed;
- }
-
private function normalizePaidFlag(mixed $value): bool
{
if ($value === true) {
diff --git a/src/Modules/Settings/ShopproPaymentStatusSyncService.php b/src/Modules/Settings/ShopproPaymentStatusSyncService.php
index b74ca9a..98510c3 100644
--- a/src/Modules/Settings/ShopproPaymentStatusSyncService.php
+++ b/src/Modules/Settings/ShopproPaymentStatusSyncService.php
@@ -3,8 +3,8 @@ declare(strict_types=1);
namespace App\Modules\Settings;
+use App\Core\Support\StringHelper;
use App\Modules\Orders\OrdersRepository;
-use DateTimeImmutable;
use PDO;
use Throwable;
@@ -219,14 +219,14 @@ final class ShopproPaymentStatusSyncService
: 0.0;
$existingPaymentStatus = isset($order['payment_status']) ? (int) $order['payment_status'] : null;
$existingTotalPaid = $order['total_paid'] !== null ? (float) $order['total_paid'] : null;
- $paymentMethod = $this->nullableString((string) ($payload['payment_method'] ?? $order['external_payment_type_id'] ?? ''));
- $paymentDate = $this->normalizeDateTime((string) ($payload['payment_date'] ?? ''));
- $sourceUpdatedAt = $this->normalizeDateTime((string) ($payload['updated_at'] ?? $payload['date_updated'] ?? ''));
+ $paymentMethod = StringHelper::nullableString((string) ($payload['payment_method'] ?? $order['external_payment_type_id'] ?? ''));
+ $paymentDate = StringHelper::normalizeDateTime((string) ($payload['payment_date'] ?? ''));
+ $sourceUpdatedAt = StringHelper::normalizeDateTime((string) ($payload['updated_at'] ?? $payload['date_updated'] ?? ''));
if (
$existingPaymentStatus === $newPaymentStatus
&& $this->floatsEqual($existingTotalPaid, $newTotalPaid)
- && $paymentMethod === $this->nullableString((string) ($order['external_payment_type_id'] ?? ''))
+ && $paymentMethod === StringHelper::nullableString((string) ($order['external_payment_type_id'] ?? ''))
) {
return false;
}
@@ -370,26 +370,6 @@ final class ShopproPaymentStatusSyncService
return $fallbackGross;
}
- private function normalizeDateTime(string $value): ?string
- {
- $trimmed = trim($value);
- if ($trimmed === '') {
- return null;
- }
-
- try {
- return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s');
- } catch (Throwable) {
- return null;
- }
- }
-
- private function nullableString(string $value): ?string
- {
- $trimmed = trim($value);
- return $trimmed === '' ? null : $trimmed;
- }
-
private function floatsEqual(?float $left, ?float $right): bool
{
if ($left === null && $right === null) {