Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.59% covered (success)
91.59%
98 / 107
53.85% covered (warning)
53.85%
7 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
CurrenciesApiController
91.51% covered (success)
91.51%
97 / 106
53.85% covered (warning)
53.85%
7 / 13
34.71
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 list
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 detail
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 syncNbp
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 updateRate
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 create
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 validateCurrencyCreation
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 resolveCreationValidationError
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 persistNewCurrency
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
2
 delete
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 validateCurrencyDeletion
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
6.29
 history
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 jsonResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Currencies\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Currencies\Application\Service\CurrencySyncServiceInterface;
12use App\Modules\Currencies\Domain\Model\Currency;
13use App\Modules\Currencies\Domain\Repository\CurrencyRepositoryInterface;
14use Nyholm\Psr7\Factory\Psr17Factory;
15use Psr\Http\Message\ResponseInterface;
16use Psr\Http\Message\ServerRequestInterface;
17use Throwable;
18
19/**
20 * REST API controller for Currency management and NBP live rate synchronization.
21 */
22final readonly class CurrenciesApiController
23{
24    private const string JSON_CONTENT_TYPE = 'application/json';
25
26    public function __construct(
27        private CurrencyRepositoryInterface $currencyRepository,
28        private CurrencySyncServiceInterface $syncService,
29        private Psr17Factory $psr17Factory
30    ) {
31    }
32
33    /**
34     * Lists active or all currencies (GET /api/v1/currencies).
35     */
36    public function list(ServerRequestInterface $request): ResponseInterface
37    {
38        $params = $request->getQueryParams();
39        $onlyActive = !isset($params['all']) || $params['all'] !== '1';
40        $entities = $onlyActive
41            ? $this->currencyRepository->findActive()
42            : $this->currencyRepository->findAll();
43
44        $data = array_map(static fn(Currency $c): array => $c->toArray(), $entities);
45
46        return $this->jsonResponse(['success' => true, 'data' => $data]);
47    }
48
49    /**
50     * Gets a single currency by 3-letter ISO code (GET /api/v1/currencies/{code}).
51     */
52    public function detail(ServerRequestInterface $request, ?string $code = null): ResponseInterface
53    {
54        $code = $code ?? (string) $request->getAttribute('code', '');
55        $currency = $this->currencyRepository->findByCode($code);
56        if ($currency === null) {
57            return $this->jsonResponse(['success' => false, 'error' => 'Currency not found.'], 404);
58        }
59
60        return $this->jsonResponse(['success' => true, 'data' => $currency->toArray()]);
61    }
62
63    /**
64     * Synchronizes current exchange rates with NBP API (POST /api/v1/currencies/sync-nbp).
65     */
66    public function syncNbp(?ServerRequestInterface $request = null): ResponseInterface
67    {
68        if ($request !== null) {
69            $request->getMethod();
70        }
71        try {
72            $result = $this->syncService->syncRates();
73            return $this->jsonResponse([
74                'success' => true,
75                'message' => 'Kursy walut NBP zaktualizowane pomyślnie.',
76                'data' => $result,
77            ]);
78        } catch (Throwable $e) {
79            return $this->jsonResponse([
80                'success' => false,
81                'error' => 'Błąd synchronizacji z NBP: ' . $e->getMessage(),
82            ], 502);
83        }
84    }
85
86    /**
87     * Updates custom exchange rate for a currency (POST /api/v1/currencies/{code}/rate).
88     */
89    public function updateRate(ServerRequestInterface $request, ?string $code = null): ResponseInterface
90    {
91        $code = $code ?? (string) $request->getAttribute('code', '');
92        $currency = $this->currencyRepository->findByCode($code);
93        if ($currency === null) {
94            return $this->jsonResponse(['success' => false, 'error' => 'Currency not found.'], 404);
95        }
96
97        $body = (array) json_decode((string) $request->getBody(), true);
98        $newRate = (float) ($body['exchange_rate'] ?? 0.0);
99        if ($newRate <= 0.0) {
100            return $this->jsonResponse(['success' => false, 'error' => 'Invalid exchange rate value.'], 422);
101        }
102
103        $this->currencyRepository->updateRate($code, $newRate, 'CUSTOM', date('Y-m-d'));
104        $this->currencyRepository->recordRateHistory($code, $newRate, 'CUSTOM', date('Y-m-d'), 'MANUAL');
105
106        return $this->jsonResponse([
107            'success' => true,
108            'message' => "Kurs waluty {$code} zaktualizowany do {$newRate}.",
109        ]);
110    }
111
112    /**
113     * Creates a new currency (POST /api/v1/currencies).
114     */
115    public function create(ServerRequestInterface $request): ResponseInterface
116    {
117        $body = (array) json_decode((string) $request->getBody(), true);
118        if (empty($body)) {
119            $body = (array) $request->getParsedBody();
120        }
121
122        $code = strtoupper(trim((string) ($body['code'] ?? '')));
123        $name = trim((string) ($body['name'] ?? ''));
124
125        $error = $this->validateCurrencyCreation($code, $name);
126        if ($error !== null) {
127            return $error;
128        }
129
130        return $this->persistNewCurrency($code, $name, $body);
131    }
132
133    private function validateCurrencyCreation(string $code, string $name): ?ResponseInterface
134    {
135        $error = $this->resolveCreationValidationError($code, $name);
136        if ($error !== null) {
137            return $this->jsonResponse(['success' => false, 'error' => $error[0]], $error[1]);
138        }
139
140        return null;
141    }
142
143    /**
144     * @return array{0: string, 1: int}|null
145     */
146    private function resolveCreationValidationError(string $code, string $name): ?array
147    {
148        if (!preg_match('/^[A-Z]{3}$/', $code)) {
149            return ['Kod waluty musi mieć 3 litery (ISO).', 422];
150        }
151
152        if ($name === '') {
153            return ['Nazwa waluty jest wymagana.', 422];
154        }
155
156        return $this->currencyRepository->findByCode($code) !== null
157            ? ["Waluta {$code} już istnieje.", 409]
158            : null;
159    }
160
161    /**
162     * @param array<string, mixed> $body
163     */
164    private function persistNewCurrency(string $code, string $name, array $body): ResponseInterface
165    {
166        $symbol = trim((string) ($body['symbol'] ?? $code));
167        $exchangeRate = (float) ($body['exchange_rate'] ?? 1.0);
168        $isActive = !isset($body['is_active']) || (bool) $body['is_active'];
169
170        $currency = new Currency(
171            id: null,
172            code: $code,
173            name: $name,
174            symbol: $symbol,
175            exchangeRate: max(0.0001, $exchangeRate),
176            nbpTableNo: null,
177            effectiveDate: date('Y-m-d'),
178            isBase: false,
179            isActive: $isActive,
180            sortOrder: 50
181        );
182
183        $this->currencyRepository->save($currency);
184
185        return $this->jsonResponse([
186            'success' => true,
187            'message' => "Waluta {$code} została dodana.",
188            'data'    => $currency->toArray(),
189        ], 201);
190    }
191
192    /**
193     * Deletes a non-base currency (DELETE /api/v1/currencies/{code}).
194     */
195    public function delete(ServerRequestInterface $request, ?string $code = null): ResponseInterface
196    {
197        $code = strtoupper($code ?? (string) $request->getAttribute('code', ''));
198        $currency = $this->currencyRepository->findByCode($code);
199
200        $error = $this->validateCurrencyDeletion($code, $currency);
201        if ($error !== null) {
202            return $error;
203        }
204
205        $this->currencyRepository->deleteByCode($code);
206
207        return $this->jsonResponse([
208            'success' => true,
209            'message' => "Waluta {$code} została usunięta.",
210        ]);
211    }
212
213    private function validateCurrencyDeletion(string $code, ?Currency $currency): ?ResponseInterface
214    {
215        if ($code === '' || $code === 'PLN' || ($currency !== null && $currency->isBase())) {
216            return $this->jsonResponse(['success' => false, 'error' => 'Nie można usunąć waluty bazowej PLN.'], 400);
217        }
218
219        if ($currency === null) {
220            return $this->jsonResponse(['success' => false, 'error' => 'Waluta nie została znaleziona.'], 404);
221        }
222
223        return null;
224    }
225
226    /**
227     * Retrieves rate history (GET /api/v1/currencies/{code}/history or GET /api/v1/currencies/history).
228     */
229    public function history(ServerRequestInterface $request, ?string $code = null): ResponseInterface
230    {
231        $code = $code ?? (string) $request->getAttribute('code', '');
232        $limit = (int) ($request->getQueryParams()['limit'] ?? 50);
233
234        $data = $code !== ''
235            ? $this->currencyRepository->getRateHistory($code, $limit)
236            : $this->currencyRepository->getAllRateHistory($limit);
237
238        return $this->jsonResponse(['success' => true, 'data' => $data]);
239    }
240
241    /**
242     * @param array<string, mixed> $payload
243     */
244    private function jsonResponse(array $payload, int $status = 200): ResponseInterface
245    {
246        $response = $this->psr17Factory->createResponse($status)
247            ->withHeader('Content-Type', self::JSON_CONTENT_TYPE);
248        $response->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_UNICODE));
249
250        return $response;
251    }
252}