Skip to content

Commit 573e37f

Browse files
committed
feat: add exchange rate history chart in settings
1 parent 7e5c2d1 commit 573e37f

10 files changed

Lines changed: 813 additions & 28 deletions

lib/AppInfo/Application.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,8 @@ public function register(IRegistrationContext $context): void {
2525

2626
public function boot(IBootContext $context): void {
2727
}
28+
29+
public static function tableName(string $table): string {
30+
return 'autocurrency_' . $table;
31+
}
2832
}

lib/Controller/ApiController.php

Lines changed: 134 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@
22

33
declare(strict_types=1);
44

5+
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
6+
// SPDX-License-Identifier: AGPL-3.0-or-later
7+
58
namespace OCA\AutoCurrency\Controller;
69

10+
use DateTimeImmutable;
711
use OCA\AutoCurrency\AppInfo;
12+
use OCA\AutoCurrency\Db\AutocurrencyRateHistoryMapper;
13+
use OCA\AutoCurrency\Db\CospendProjectMapper;
14+
use OCA\AutoCurrency\Db\CurrencyMapper;
815
use OCA\AutoCurrency\Service\FetchCurrenciesService;
916
use OCP\AppFramework\Http;
1017
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -19,15 +26,6 @@
1926
* @psalm-suppress UnusedClass
2027
*/
2128
class ApiController extends OCSController {
22-
/** @var IAppConfig */
23-
private $config;
24-
25-
/** @var IL10N */
26-
private $l;
27-
28-
/** @var FetchCurrenciesService */
29-
private $service;
30-
3129
/**
3230
* Admin constructor.
3331
*
@@ -40,9 +38,12 @@ class ApiController extends OCSController {
4038
public function __construct(
4139
string $appName,
4240
IRequest $request,
43-
IAppConfig $config,
44-
IL10N $l,
45-
FetchCurrenciesService $service,
41+
private IAppConfig $config,
42+
private IL10N $l,
43+
private FetchCurrenciesService $service,
44+
private CurrencyMapper $currencyMapper,
45+
private CospendProjectMapper $projectMapper,
46+
private AutocurrencyRateHistoryMapper $historyMapper,
4647
) {
4748
parent::__construct($appName, $request);
4849
$this->config = $config;
@@ -118,4 +119,125 @@ public function updateSettings(mixed $data): DataResponse {
118119
['status' => 'OK']
119120
);
120121
}
122+
123+
/**
124+
* List Cospend projects
125+
*
126+
* @return DataResponse<Http::STATUS_OK, array{
127+
* projects: list<array{id:string,name:string,currencyName:string|null}>
128+
* }, array{}>
129+
*
130+
* 200: Data returned
131+
*/
132+
#[ApiRoute(verb: 'GET', url: '/api/projects')]
133+
public function getProjects(): DataResponse {
134+
$projects = $this->projectMapper->findAll();
135+
136+
$list = [];
137+
foreach ($projects as $p) {
138+
$name = (string)$p->getName();
139+
$id = (string)$p->getId();
140+
$currencyName = (string)$p->getCurrencyName();
141+
$currencies = $this->currencyMapper->findAll($id);
142+
$currencyNames = array_map(fn ($c) => strtolower((string)$c->getName()), $currencies);
143+
144+
$list[] = [
145+
'id' => $id,
146+
'name' => $name !== '' ? $name : $id,
147+
'baseCurrency' => $currencyName,
148+
'currencies' => $currencyNames,
149+
];
150+
}
151+
152+
return new DataResponse(['projects' => $list]);
153+
}
154+
155+
/**
156+
* Get rate history for a project (uses the project's base currency)
157+
*
158+
* @param string $projectId Project ID (required)
159+
* @param string|null $currency Quoted currency code to filter (e.g. "eur")
160+
* @param string|null $from ISO-8601 datetime (inclusive)
161+
* @param string|null $to ISO-8601 datetime (inclusive)
162+
* @param int|null $limit Max rows to return (optional)
163+
* @param int|null $offset Offset for pagination (optional)
164+
*
165+
* @return DataResponse<Http::STATUS_OK, array{
166+
* projectId: string,
167+
* baseCurrency: string,
168+
* points: list<array{
169+
* fetchedAt: string,
170+
* rate: string,
171+
* currencyName: string,
172+
* source: string|null
173+
* }>
174+
* }, array{}>
175+
*
176+
* 200: Data returned
177+
*/
178+
#[ApiRoute(verb: 'GET', url: '/api/history')]
179+
public function getHistory(
180+
string $projectId,
181+
?string $currency = null,
182+
?string $from = null,
183+
?string $to = null,
184+
?int $limit = null,
185+
?int $offset = null,
186+
): DataResponse {
187+
if ($projectId === '') {
188+
return new DataResponse(['error' => 'projectId is required'], Http::STATUS_BAD_REQUEST);
189+
}
190+
191+
// Parse dates if provided (ISO-8601). If invalid, treat as null.
192+
// If "to" is a DATE ONLY (no time), shift it to end-of-day 23:59:59.
193+
$fromDt = null;
194+
$toDt = null;
195+
try {
196+
if (is_string($from) && $from !== '') {
197+
$fromDt = new DateTimeImmutable($from);
198+
}
199+
if (is_string($to) && $to !== '') {
200+
// Date-only? e.g. "2025-09-25"
201+
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $to) === 1) {
202+
$toDt = new DateTimeImmutable($to . ' 23:59:59');
203+
} else {
204+
$toDt = new DateTimeImmutable($to);
205+
}
206+
}
207+
} catch (\Throwable $e) {
208+
// ignore parsing errors; nulls mean "no bound"
209+
}
210+
211+
// Resolve project and its base currency
212+
$project = $this->projectMapper->find($projectId);
213+
$projectBase = $project->getCurrencyName();
214+
$lbase = strtolower((string)$projectBase);
215+
216+
$rows = $this->historyMapper->findByProjectAndBase(
217+
projectId: $projectId,
218+
baseCurrency: $lbase,
219+
currencyName: is_string($currency) && $currency !== '' ? strtolower($currency) : null,
220+
from: $fromDt,
221+
to: $toDt,
222+
limit: (int)($limit ?? 0),
223+
offset: (int)($offset ?? 0),
224+
order: 'ASC'
225+
);
226+
227+
$points = array_map(static function ($row) {
228+
/** @var \OCA\AutoCurrency\Db\AutocurrencyRateHistory $row */
229+
return [
230+
'fetchedAt' => $row->getFetchedAt() ? $row->getFetchedAt()->format(DATE_ATOM) : null,
231+
'rate' => $row->getRate(),
232+
'currencyName' => $row->getCurrencyName(),
233+
'source' => $row->getSource(),
234+
];
235+
}, $rows);
236+
237+
return new DataResponse([
238+
'projectId' => $projectId,
239+
'baseCurrency' => $lbase,
240+
'points' => $points,
241+
]);
242+
}
121243
}

lib/Db/AutocurrencyRateHistory.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
6+
// SPDX-License-Identifier: AGPL-3.0-or-later
7+
8+
namespace OCA\AutoCurrency\Db;
9+
10+
use DateTimeInterface;
11+
use JsonSerializable;
12+
use OCP\AppFramework\Db\Entity;
13+
14+
/**
15+
* @method int|null getId()
16+
* @method void setId(int $id)
17+
*
18+
* @method string getProjectId()
19+
* @method void setProjectId(string $value)
20+
*
21+
* @method string getProjectName()
22+
* @method void setProjectName(string $value)
23+
*
24+
* @method string getCurrencyName()
25+
* @method void setCurrencyName(string $value)
26+
*
27+
* @method string getBaseCurrency()
28+
* @method void setBaseCurrency(string $value)
29+
*
30+
* @method string getRate()
31+
* @method void setRate(string $value)
32+
*
33+
* @method DateTimeInterface getFetchedAt()
34+
* @method void setFetchedAt(DateTimeInterface $value)
35+
*
36+
* @method string|null getSource()
37+
* @method void setSource(?string $value)
38+
*
39+
* @method int|null getCurrencyId()
40+
* @method void setCurrencyId(?int $value)
41+
*/
42+
class AutocurrencyRateHistory extends Entity implements JsonSerializable {
43+
protected $projectId = '';
44+
protected $projectName = '';
45+
protected $currencyName = '';
46+
protected $baseCurrency = '';
47+
protected $rate = '';
48+
protected $fetchedAt = null;
49+
protected $source = null;
50+
protected $currencyId = null;
51+
52+
public function __construct() {
53+
$this->addType('id', 'integer');
54+
$this->addType('projectId', 'string');
55+
$this->addType('projectName', 'string');
56+
$this->addType('currencyName', 'string');
57+
$this->addType('baseCurrency', 'string');
58+
$this->addType('rate', 'string');
59+
$this->addType('fetchedAt', 'datetime');
60+
$this->addType('source', 'string');
61+
$this->addType('currencyId', 'integer');
62+
}
63+
64+
public function jsonSerialize(): array {
65+
return [
66+
'id' => $this->getId(),
67+
'projectId' => $this->getProjectId(),
68+
'projectName' => $this->getProjectName(),
69+
'currencyName' => $this->getCurrencyName(),
70+
'baseCurrency' => $this->getBaseCurrency(),
71+
'rate' => $this->getRate(),
72+
'fetchedAt' => $this->getFetchedAt() ? $this->getFetchedAt()->format(DATE_ATOM) : null,
73+
'source' => $this->getSource(),
74+
'currencyId' => $this->getCurrencyId(),
75+
];
76+
}
77+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
6+
// SPDX-License-Identifier: AGPL-3.0-or-later
7+
8+
namespace OCA\AutoCurrency\Db;
9+
10+
use DateTimeInterface;
11+
use OCA\AutoCurrency\AppInfo\Application;
12+
use OCP\AppFramework\Db\Entity;
13+
use OCP\AppFramework\Db\QBMapper;
14+
use OCP\IDBConnection;
15+
16+
/**
17+
* Mapper for autocurrency_rate_history
18+
*
19+
* Table columns (snake_case) map to entity props (camelCase):
20+
* - id -> id
21+
* - project_id -> projectId
22+
* - project_name -> projectName
23+
* - currency_name -> currencyName
24+
* - base_currency -> baseCurrency
25+
* - rate -> rate
26+
* - fetched_at -> fetchedAt
27+
* - source -> source
28+
* - currency_id -> currencyId
29+
*
30+
* @extends QBMapper<Entity>
31+
*/
32+
class AutocurrencyRateHistoryMapper extends QBMapper {
33+
public function __construct(IDBConnection $db) {
34+
parent::__construct($db, Application::tableName('history'), AutocurrencyRateHistory::class);
35+
}
36+
37+
/**
38+
* Fetch history points for a given project and base currency.
39+
*
40+
* @param string $projectId
41+
* @param string $baseCurrency Lowercase code (e.g. "ils", "usd")
42+
* @param string|null $currencyName Optional quoted currency code filter (e.g. "eur")
43+
* @param DateTimeInterface|null $from Optional >= start time
44+
* @param DateTimeInterface|null $to Optional <= end time
45+
* @param int $limit Optional limit (0 = no limit)
46+
* @param int $offset Optional offset
47+
* @param string $order 'ASC' or 'DESC' (default ASC by fetched_at)
48+
* @return AutocurrencyRateHistory[]
49+
*/
50+
public function findByProjectAndBase(
51+
string $projectId,
52+
string $baseCurrency,
53+
?string $currencyName = null,
54+
?DateTimeInterface $from = null,
55+
?DateTimeInterface $to = null,
56+
int $limit = 0,
57+
int $offset = 0,
58+
string $order = 'ASC',
59+
): array {
60+
$qb = $this->db->getQueryBuilder();
61+
$qb->select('*')
62+
->from($this->getTableName())
63+
->where(
64+
$qb->expr()->andX(
65+
$qb->expr()->eq('project_id', $qb->createNamedParameter($projectId)),
66+
$qb->expr()->eq('base_currency', $qb->createNamedParameter($baseCurrency))
67+
)
68+
)
69+
->orderBy('fetched_at', strtoupper($order) === 'DESC' ? 'DESC' : 'ASC');
70+
71+
if ($currencyName !== null && $currencyName !== '') {
72+
$qb->andWhere(
73+
$qb->expr()->eq('currency_name', $qb->createNamedParameter($currencyName))
74+
);
75+
}
76+
77+
if ($from !== null) {
78+
$qb->andWhere(
79+
$qb->expr()->gte('fetched_at', $qb->createNamedParameter($from->format('Y-m-d H:i:s')))
80+
);
81+
}
82+
if ($to !== null) {
83+
$qb->andWhere(
84+
$qb->expr()->lte('fetched_at', $qb->createNamedParameter($to->format('Y-m-d H:i:s')))
85+
);
86+
}
87+
88+
if ($limit > 0) {
89+
$qb->setMaxResults($limit);
90+
}
91+
if ($offset > 0) {
92+
$qb->setFirstResult($offset);
93+
}
94+
95+
return $this->findEntities($qb);
96+
}
97+
}

lib/Db/CospendProjectMapper.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public function __construct(IDBConnection $db) {
2525
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
2626
* @throws DoesNotExistException
2727
*/
28-
public function find(int $id): Project {
28+
public function find(string $id): Project {
2929
/* @var $qb IQueryBuilder */
3030
$qb = $this->db->getQueryBuilder();
3131
$qb->select('*')
@@ -34,6 +34,7 @@ public function find(int $id): Project {
3434
return $this->findEntity($qb);
3535
}
3636

37+
3738
/**
3839
* @param string $projectId
3940
* @return array

0 commit comments

Comments
 (0)