feat(113): fakturownia integration foundation

Phase 113 complete (v3.7 Invoices):
- DB: invoices, invoice_configs, invoice_number_counters, fakturownia_integration_settings + orders.invoice_requested
- FakturowniaIntegrationRepository (multi-account via integrations.type='fakturownia')
- FakturowniaApiClient (testConnection; createInvoice/downloadPdf STUBs)
- IntegrationsRepository::updateTestResult() (reusable test-result writer)
- /settings/integrations/fakturownia (list + edit + test + delete)
- Karta Fakturownia w hubie /settings/integrations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 22:11:55 +02:00
parent 322b23b7be
commit 2382018739
20 changed files with 1766 additions and 32 deletions

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\SslCertificateResolver;
use RuntimeException;
final class FakturowniaApiClient
{
public function __construct(private readonly int $timeoutSeconds = 15)
{
}
/**
* @return array{ok: bool, http_code: int, message: string}
*/
public function testConnection(string $accountPrefix, string $apiToken): array
{
$prefix = strtolower(trim($accountPrefix));
$token = trim($apiToken);
if ($prefix === '' || $token === '') {
return [
'ok' => false,
'http_code' => 0,
'message' => 'Brak prefiksu konta lub tokenu API.',
];
}
$url = $this->buildUrl($prefix, '/account.json') . '?api_token=' . rawurlencode($token);
[$body, $httpCode, $curlError] = $this->httpGet($url);
if ($curlError !== null) {
return [
'ok' => false,
'http_code' => $httpCode,
'message' => 'Blad polaczenia: ' . $curlError,
];
}
if ($httpCode >= 200 && $httpCode < 300) {
return [
'ok' => true,
'http_code' => $httpCode,
'message' => 'OK',
];
}
$message = $this->resolveErrorMessage($body);
if ($message === '') {
$message = 'HTTP ' . $httpCode;
}
return [
'ok' => false,
'http_code' => $httpCode,
'message' => $message,
];
}
/**
* Implementation in a follow-up plan (Phase 113-02+).
*
* @param array<string, mixed> $settings
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function createInvoice(array $settings, array $payload): array
{
unset($settings, $payload);
throw new RuntimeException('FakturowniaApiClient::createInvoice not implemented in Phase 113-01.');
}
/**
* Implementation in a follow-up plan (Phase 113-02+).
*
* @param array<string, mixed> $settings
*/
public function downloadPdf(array $settings, string $invoiceId): string
{
unset($settings, $invoiceId);
throw new RuntimeException('FakturowniaApiClient::downloadPdf not implemented in Phase 113-01.');
}
private function buildUrl(string $prefix, string $path): string
{
return 'https://' . $prefix . '.fakturownia.pl' . $path;
}
/**
* @return array{0: string, 1: int, 2: ?string}
*/
private function httpGet(string $url): array
{
$ch = curl_init($url);
if ($ch === false) {
return ['', 0, 'Nie udalo sie zainicjowac cURL.'];
}
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPGET => true,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'User-Agent: orderPRO/1.0',
],
];
$caPath = SslCertificateResolver::resolve();
if ($caPath !== null) {
$opts[CURLOPT_CAINFO] = $caPath;
}
curl_setopt_array($ch, $opts);
$rawBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($rawBody === false) {
return ['', $httpCode, $curlError !== '' ? $curlError : 'Brak odpowiedzi z API.'];
}
return [(string) $rawBody, $httpCode, null];
}
private function resolveErrorMessage(string $body): string
{
$trimmed = ltrim($body, "\xEF\xBB\xBF \t\n\r\0\x0B");
if ($trimmed === '') {
return '';
}
$decoded = json_decode($trimmed, true);
if (is_array($decoded)) {
$candidates = ['message', 'error', 'code'];
foreach ($candidates as $key) {
if (isset($decoded[$key]) && is_string($decoded[$key]) && trim($decoded[$key]) !== '') {
return trim($decoded[$key]);
}
}
if (isset($decoded['errors']) && is_array($decoded['errors'])) {
$first = reset($decoded['errors']);
if (is_string($first) && trim($first) !== '') {
return trim($first);
}
}
}
$snippet = trim(strip_tags($trimmed));
return substr($snippet, 0, 200);
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\RedirectPathResolver;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use Throwable;
final class FakturowniaIntegrationController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly FakturowniaIntegrationRepository $repository,
private readonly FakturowniaApiClient $apiClient,
private readonly IntegrationsRepository $integrations
) {
}
public function index(Request $request): Response
{
$rows = $this->repository->findAll();
$html = $this->template->render('settings/fakturownia', [
'title' => 'Integracja Fakturownia',
'activeMenu' => 'settings',
'activeSettings' => 'integrations',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'rows' => $rows,
'flashSave' => (string) Flash::get('fakturownia.save', ''),
'flashTest' => (string) Flash::get('fakturownia.test', ''),
'flashError' => (string) Flash::get('fakturownia.error', ''),
], 'layouts/app');
return Response::html($html);
}
public function edit(Request $request): Response
{
$integrationId = (int) $request->input('id', 0);
$row = $integrationId > 0 ? $this->repository->findByIntegrationId($integrationId) : null;
if ($integrationId > 0 && $row === null) {
Flash::set('fakturownia.error', 'Nie znaleziono integracji Fakturowni o ID ' . $integrationId . '.');
return Response::redirect('/settings/integrations/fakturownia');
}
$html = $this->template->render('settings/fakturownia-edit', [
'title' => $row === null
? 'Nowa integracja Fakturownia'
: 'Edycja integracji Fakturownia',
'activeMenu' => 'settings',
'activeSettings' => 'integrations',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'row' => $row,
'flashSave' => (string) Flash::get('fakturownia.save', ''),
'flashTest' => (string) Flash::get('fakturownia.test', ''),
'flashError' => (string) Flash::get('fakturownia.error', ''),
], 'layouts/app');
return Response::html($html);
}
public function save(Request $request): Response
{
$integrationId = (int) $request->input('id', 0);
$redirectTo = '/settings/integrations/fakturownia';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('fakturownia.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
try {
$this->repository->save(
$integrationId > 0 ? $integrationId : null,
[
'name' => (string) $request->input('name', ''),
'account_prefix' => (string) $request->input('account_prefix', ''),
'api_token' => (string) $request->input('api_token', ''),
'department_id' => (string) $request->input('department_id', ''),
'default_kind' => (string) $request->input('default_kind', 'vat'),
'default_payment_to_days' => (int) $request->input('default_payment_to_days', 7),
'is_active' => $request->input('is_active', ''),
]
);
Flash::set('fakturownia.save', 'Zapisano integracje Fakturowni.');
} catch (Throwable $exception) {
Flash::set('fakturownia.error', 'Nie udalo sie zapisac integracji: ' . $exception->getMessage());
return Response::redirect(RedirectPathResolver::resolve(
$integrationId > 0
? '/settings/integrations/fakturownia/edit?id=' . $integrationId
: '/settings/integrations/fakturownia/new',
['/settings/integrations/fakturownia'],
'/settings/integrations/fakturownia'
));
}
return Response::redirect($redirectTo);
}
public function test(Request $request): Response
{
$integrationId = (int) $request->input('id', 0);
$redirectTo = $integrationId > 0
? '/settings/integrations/fakturownia/edit?id=' . $integrationId
: '/settings/integrations/fakturownia';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('fakturownia.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
if ($integrationId <= 0) {
Flash::set('fakturownia.error', 'Najpierw zapisz integracje, potem przetestuj polaczenie.');
return Response::redirect($redirectTo);
}
$row = $this->repository->findByIntegrationId($integrationId);
if ($row === null) {
Flash::set('fakturownia.error', 'Integracja nie istnieje.');
return Response::redirect('/settings/integrations/fakturownia');
}
$prefix = (string) ($row['account_prefix'] ?? '');
$token = $this->repository->getDecryptedToken($integrationId);
if ($prefix === '' || $token === null || $token === '') {
Flash::set('fakturownia.test', 'Brak prefiksu lub tokenu - uzupelnij dane i zapisz przed testem.');
$this->integrations->updateTestResult($integrationId, 'fail', 0, 'Brak prefiksu lub tokenu.');
return Response::redirect($redirectTo);
}
$result = $this->apiClient->testConnection($prefix, $token);
$status = $result['ok'] ? 'ok' : 'fail';
$this->integrations->updateTestResult(
$integrationId,
$status,
(int) $result['http_code'],
(string) $result['message']
);
$msg = $result['ok']
? 'OK (HTTP ' . (int) $result['http_code'] . ')'
: 'BLAD: ' . $result['message'] . ' (HTTP ' . (int) $result['http_code'] . ')';
Flash::set('fakturownia.test', $msg);
return Response::redirect($redirectTo);
}
public function delete(Request $request): Response
{
$integrationId = (int) $request->input('id', 0);
$redirectTo = '/settings/integrations/fakturownia';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('fakturownia.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
if ($integrationId <= 0) {
Flash::set('fakturownia.error', 'Brak identyfikatora integracji.');
return Response::redirect($redirectTo);
}
try {
$this->repository->delete($integrationId);
Flash::set('fakturownia.save', 'Usunieto integracje Fakturowni.');
} catch (Throwable $exception) {
Flash::set('fakturownia.error', $exception->getMessage());
}
return Response::redirect($redirectTo);
}
}

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Support\StringHelper;
use PDO;
use Throwable;
final class FakturowniaIntegrationRepository
{
private const INTEGRATION_TYPE = 'fakturownia';
private const INTEGRATION_BASE_URL = 'https://app.fakturownia.pl';
private readonly IntegrationsRepository $integrations;
private readonly IntegrationSecretCipher $cipher;
public function __construct(
private readonly PDO $pdo,
private readonly string $secret
) {
$this->integrations = new IntegrationsRepository($this->pdo);
$this->cipher = new IntegrationSecretCipher($this->secret);
}
/**
* @return array<int, array<string, mixed>>
*/
public function findAll(): array
{
try {
$statement = $this->pdo->prepare(
'SELECT i.id AS integration_id, i.name, i.is_active,
i.last_test_status, i.last_test_http_code, i.last_test_message, i.last_test_at,
s.id AS settings_id, s.account_prefix, s.api_token_encrypted,
s.department_id, s.default_kind, s.default_payment_to_days
FROM integrations i
LEFT JOIN fakturownia_integration_settings s ON s.integration_id = i.id
WHERE i.type = :type
ORDER BY i.id ASC'
);
$statement->execute(['type' => self::INTEGRATION_TYPE]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
return is_array($rows) ? array_map(fn (array $row) => $this->mapRow($row), $rows) : [];
}
/**
* @return array<string, mixed>|null
*/
public function findByIntegrationId(int $integrationId): ?array
{
if ($integrationId <= 0) {
return null;
}
try {
$statement = $this->pdo->prepare(
'SELECT i.id AS integration_id, i.name, i.is_active,
i.last_test_status, i.last_test_http_code, i.last_test_message, i.last_test_at,
s.id AS settings_id, s.account_prefix, s.api_token_encrypted,
s.department_id, s.default_kind, s.default_payment_to_days
FROM integrations i
LEFT JOIN fakturownia_integration_settings s ON s.integration_id = i.id
WHERE i.id = :id AND i.type = :type
LIMIT 1'
);
$statement->execute([
'id' => $integrationId,
'type' => self::INTEGRATION_TYPE,
]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return null;
}
return is_array($row) ? $this->mapRow($row) : null;
}
/**
* @param array<string, mixed> $payload
*/
public function save(?int $integrationId, array $payload): int
{
$name = trim((string) ($payload['name'] ?? ''));
if ($name === '') {
throw new IntegrationConfigException('Nazwa integracji Fakturowni jest wymagana.');
}
$prefix = strtolower(trim((string) ($payload['account_prefix'] ?? '')));
if (!preg_match('/^[a-z0-9][a-z0-9-]{1,62}$/', $prefix)) {
throw new IntegrationConfigException('Prefix konta (subdomena) ma niepoprawny format.');
}
$isActive = !empty($payload['is_active']);
$defaultKind = trim((string) ($payload['default_kind'] ?? 'vat'));
if ($defaultKind === '') {
$defaultKind = 'vat';
}
$defaultPaymentDays = max(0, (int) ($payload['default_payment_to_days'] ?? 7));
$departmentId = StringHelper::nullableString(trim((string) ($payload['department_id'] ?? '')));
if ($integrationId === null || $integrationId <= 0) {
$integrationId = $this->integrations->ensureIntegration(
self::INTEGRATION_TYPE,
$name,
self::INTEGRATION_BASE_URL,
15,
$isActive
);
} else {
$this->updateIntegrationRow($integrationId, $name, $isActive);
}
$current = $this->findByIntegrationId($integrationId);
$currentEncrypted = $current['api_token_encrypted'] ?? null;
$apiToken = trim((string) ($payload['api_token'] ?? ''));
$nextEncrypted = $currentEncrypted;
if ($apiToken !== '') {
$nextEncrypted = $this->cipher->encrypt($apiToken);
}
$this->integrations->updateApiKeyEncrypted($integrationId, $nextEncrypted);
if ($current === null || ($current['settings_id'] ?? null) === null) {
$insert = $this->pdo->prepare(
'INSERT INTO fakturownia_integration_settings
(integration_id, account_prefix, api_token_encrypted, department_id, default_kind, default_payment_to_days)
VALUES
(:integration_id, :account_prefix, :api_token_encrypted, :department_id, :default_kind, :default_payment_to_days)'
);
$insert->execute([
'integration_id' => $integrationId,
'account_prefix' => $prefix,
'api_token_encrypted' => StringHelper::nullableString((string) $nextEncrypted),
'department_id' => $departmentId,
'default_kind' => $defaultKind,
'default_payment_to_days' => $defaultPaymentDays,
]);
} else {
$update = $this->pdo->prepare(
'UPDATE fakturownia_integration_settings
SET account_prefix = :account_prefix,
api_token_encrypted = :api_token_encrypted,
department_id = :department_id,
default_kind = :default_kind,
default_payment_to_days = :default_payment_to_days,
updated_at = NOW()
WHERE integration_id = :integration_id'
);
$update->execute([
'integration_id' => $integrationId,
'account_prefix' => $prefix,
'api_token_encrypted' => StringHelper::nullableString((string) $nextEncrypted),
'department_id' => $departmentId,
'default_kind' => $defaultKind,
'default_payment_to_days' => $defaultPaymentDays,
]);
}
return $integrationId;
}
public function delete(int $integrationId): void
{
if ($integrationId <= 0) {
return;
}
if ($this->isUsedByInvoiceConfig($integrationId)) {
throw new IntegrationConfigException(
'Nie mozna usunac integracji Fakturowni - jest uzywana przez konfiguracje faktur (invoice_configs).'
);
}
$statement = $this->pdo->prepare(
'DELETE FROM integrations WHERE id = :id AND type = :type'
);
$statement->execute([
'id' => $integrationId,
'type' => self::INTEGRATION_TYPE,
]);
}
public function getDecryptedToken(int $integrationId): ?string
{
$row = $this->findByIntegrationId($integrationId);
if ($row === null) {
return null;
}
$encrypted = $row['api_token_encrypted'] ?? null;
if (!is_string($encrypted) || $encrypted === '') {
return null;
}
return $this->cipher->decrypt($encrypted);
}
private function isUsedByInvoiceConfig(int $integrationId): bool
{
try {
$statement = $this->pdo->prepare(
'SELECT 1 FROM invoice_configs WHERE integration_id = :id LIMIT 1'
);
$statement->execute(['id' => $integrationId]);
return $statement->fetchColumn() !== false;
} catch (Throwable) {
return false;
}
}
private function updateIntegrationRow(int $integrationId, string $name, bool $isActive): void
{
$statement = $this->pdo->prepare(
'UPDATE integrations
SET name = :name,
is_active = :is_active,
updated_at = NOW()
WHERE id = :id AND type = :type'
);
$statement->execute([
'id' => $integrationId,
'type' => self::INTEGRATION_TYPE,
'name' => $name,
'is_active' => $isActive ? 1 : 0,
]);
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapRow(array $row): array
{
$integrationId = (int) ($row['integration_id'] ?? 0);
$baseEncrypted = $this->integrations->getApiKeyEncrypted($integrationId);
$settingsEncrypted = isset($row['api_token_encrypted']) ? trim((string) $row['api_token_encrypted']) : '';
$resolvedEncrypted = null;
if ($baseEncrypted !== null && $baseEncrypted !== '') {
$resolvedEncrypted = $baseEncrypted;
} elseif ($settingsEncrypted !== '') {
$resolvedEncrypted = $settingsEncrypted;
}
return [
'integration_id' => $integrationId,
'settings_id' => isset($row['settings_id']) ? (int) $row['settings_id'] : null,
'name' => (string) ($row['name'] ?? ''),
'is_active' => (bool) ($row['is_active'] ?? false),
'account_prefix' => (string) ($row['account_prefix'] ?? ''),
'api_token_encrypted' => $resolvedEncrypted,
'has_api_token' => $resolvedEncrypted !== null && $resolvedEncrypted !== '',
'department_id' => isset($row['department_id']) ? (string) $row['department_id'] : '',
'default_kind' => (string) ($row['default_kind'] ?? 'vat'),
'default_payment_to_days' => (int) ($row['default_payment_to_days'] ?? 7),
'last_test_status' => isset($row['last_test_status']) ? (string) $row['last_test_status'] : '',
'last_test_http_code' => isset($row['last_test_http_code']) ? (int) $row['last_test_http_code'] : null,
'last_test_message' => isset($row['last_test_message']) ? (string) $row['last_test_message'] : '',
'last_test_at' => isset($row['last_test_at']) ? (string) $row['last_test_at'] : '',
];
}
}

View File

@@ -21,7 +21,8 @@ final class IntegrationsHubController
private readonly AllegroIntegrationRepository $allegro,
private readonly ApaczkaIntegrationRepository $apaczka,
private readonly InpostIntegrationRepository $inpost,
private readonly ShopproIntegrationsRepository $shoppro
private readonly ShopproIntegrationsRepository $shoppro,
private readonly FakturowniaIntegrationRepository $fakturownia
) {
}
@@ -33,6 +34,7 @@ final class IntegrationsHubController
$this->buildApaczkaRow(),
$this->buildInpostRow(),
$this->buildShopproRow(),
$this->buildFakturowniaRow(),
];
$html = $this->template->render('settings/integrations', [
@@ -167,4 +169,49 @@ final class IntegrationsHubController
];
}
/**
* @return array<string, mixed>
*/
private function buildFakturowniaRow(): array
{
$rows = $this->fakturownia->findAll();
$instancesCount = count($rows);
$activeCount = 0;
$configuredCount = 0;
$lastTestAt = '';
foreach ($rows as $row) {
if (!empty($row['is_active'])) {
$activeCount++;
}
if (!empty($row['has_api_token'])) {
$configuredCount++;
}
$testedAt = trim((string) ($row['last_test_at'] ?? ''));
if ($testedAt !== '' && ($lastTestAt === '' || strcmp($testedAt, $lastTestAt) > 0)) {
$lastTestAt = $testedAt;
}
}
$instanceLabel = $instancesCount > 0
? 'Fakturownia (' . $instancesCount . ')'
: 'Fakturownia';
return [
'provider' => 'Fakturownia',
'instance' => $instanceLabel,
'authorization_status' => $configuredCount > 0
? $this->translator->get('settings.integrations_hub.status.configured')
: $this->translator->get('settings.integrations_hub.status.not_configured'),
'secret_status' => $configuredCount > 0
? $this->translator->get('settings.integrations_hub.status.saved')
: $this->translator->get('settings.integrations_hub.status.missing'),
'is_active' => $activeCount > 0,
'last_test_at' => $lastTestAt,
'configure_url' => '/settings/integrations/fakturownia',
'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'),
];
}
}

View File

@@ -124,6 +124,29 @@ final class IntegrationsRepository
]);
}
public function updateTestResult(int $integrationId, string $status, ?int $httpCode, string $message): void
{
if ($integrationId <= 0) {
return;
}
$statement = $this->pdo->prepare(
'UPDATE integrations
SET last_test_status = :status,
last_test_http_code = :http_code,
last_test_message = :message,
last_test_at = NOW(),
updated_at = NOW()
WHERE id = :id'
);
$statement->execute([
'id' => $integrationId,
'status' => substr($status, 0, 16),
'http_code' => $httpCode,
'message' => substr($message, 0, 255),
]);
}
public function getApiKeyEncrypted(int $integrationId): ?string
{
if ($integrationId <= 0) {