fix: improve currency matching for history endpoint

This commit is contained in:
2025-09-30 22:52:32 +03:00
parent 3b71710c5d
commit 1c05eff916
5 changed files with 90 additions and 52 deletions

View File

@@ -23,6 +23,7 @@ use OCP\IDateTimeFormatter;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* @psalm-suppress UnusedClass
@@ -40,6 +41,7 @@ class ApiController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private LoggerInterface $logger,
private IAppConfig $config,
private IL10N $l,
private IUserSession $userSession,
@@ -159,7 +161,10 @@ class ApiController extends OCSController {
$id = (string)$p->getId();
$currencyName = (string)$p->getCurrencyName();
$currencies = $this->currencyMapper->findAll($id);
$currencyNames = array_map(fn ($c) => strtolower((string)$c->getName()), $currencies);
$currencyNames = array_map(function ($c) {
$resolved = $this->service->getCurrencyName((string)$c->getName());
return $resolved ?? strtolower((string)$c->getName());
}, $currencies);
$list[] = [
'id' => $id,
@@ -230,8 +235,9 @@ class ApiController extends OCSController {
}
// Resolve project and its base currency
$this->logger->debug('Fetching history for project ' . $projectId . ' from ' . ($fromDt?->format(DATE_ATOM) ?? 'null') . ' to ' . ($toDt?->format(DATE_ATOM) ?? 'null'));
$project = $this->projectMapper->find($projectId);
$projectBase = $project->getCurrencyName();
$projectBase = $this->service->getCurrencyName($project->getCurrencyName());
$lbase = strtolower((string)$projectBase);
$rows = $this->historyMapper->findByProjectAndBase(

View File

@@ -32,7 +32,7 @@ class CospendProjectMapper extends QBMapper {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('cospend_projects')
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_STR)));
return $this->findEntity($qb);
}

View File

@@ -180,7 +180,7 @@ class FetchCurrenciesService {
}
/** Match the currency name from the known currencies. **/
private function getCurrencyName(string $name): ?string {
public function getCurrencyName(string $name): ?string {
$original = trim($name);
$lower = mb_strtolower($original, 'UTF-8');

View File

@@ -62,10 +62,11 @@
<div class="history-controls">
<!-- Project -->
<NcSelect
v-model="selectedProjectId"
:options="projectOptions"
v-model="selectedProject"
:options="projects"
:option-value="'id'"
:option-label="'label'"
:option-label="'name'"
:return-object="true"
:input-label="strings.projectLabel"
:disabled="loading || projectsLoading"
required
@@ -73,11 +74,11 @@
<!-- Currency -->
<NcSelect
v-model="selectedCurrencyCode"
v-model="selectedCurrency"
:options="currencyOptions"
:option-value="'id'"
:return-object="true"
:option-label="'label'"
:return-object="true"
:input-label="strings.currencyLabel"
:disabled="loading || projectsLoading || !currencyOptions.length"
required
@@ -102,7 +103,7 @@
</div>
</template>
<script>
<script lang="ts">
import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
@@ -111,6 +112,7 @@ import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import { isValid } from 'date-fns'
import { parseISO as parseDate } from 'date-fns/parseISO'
import { format as formatDate } from 'date-fns/format'
import { Chart } from 'chart.js/auto'
@@ -129,18 +131,17 @@ export default {
const oneMonthAgo = new Date(today)
oneMonthAgo.setMonth(today.getMonth() - 1)
const toISODate = (d) => formatDate(d, 'yyyy-MM-dd')
return {
loading: true,
supportedCurrencies: [],
currencySearch: '',
projectsLoading: true,
projects: [],
selectedProjectId: null,
selectedCurrencyCode: null,
dateFrom: toISODate(oneMonthAgo),
dateTo: toISODate(today),
todayISO: toISODate(today),
selectedProject: null,
selectedCurrency: null,
dateFrom: this.formatDate(oneMonthAgo),
dateTo: this.formatDate(today),
todayISO: this.formatDate(today),
chart: null,
historyPoints: [],
historyReqId: 0,
@@ -202,26 +203,42 @@ export default {
this.fetchProjects()
},
watch: {
selectedProjectId() {
this.resetCurrencyForProject()
selectedProject() {
const currencyChanged = this.resetCurrencyForProject()
if (this.selectedProject && this.selectedCurrency && !currencyChanged) {
this.fetchHistory() // project changed, currency remained same -> refetch
}
},
selectedCurrencyCode() {
if (this.selectedProjectId && this.selectedCurrencyCode) {
selectedCurrency() {
if (this.selectedProject && this.selectedCurrency) {
this.fetchHistory()
}
},
dateFrom() {
if (this.selectedProjectId && this.selectedCurrencyCode) {
this.fetchHistory()
}
dateFrom(val) {
this.dateFrom = this.formatDate(val)
if (this.selectedProject && this.selectedCurrency) this.fetchHistory()
},
dateTo() {
if (this.selectedProjectId && this.selectedCurrencyCode) {
this.fetchHistory()
}
dateTo(val) {
this.dateTo = this.formatDate(val)
if (this.selectedProject && this.selectedCurrency) this.fetchHistory()
},
},
methods: {
formatDate(value: Date | string | null | undefined): string {
if (!value) return ''
const d =
value instanceof Date
? value
: typeof value === 'string'
? parseDate(value)
: new Date(value as any)
if (!isValid(d)) {
console.warn('[DEBUG] Invalid date received:', value)
return ''
}
return formatDate(d, 'yyyy-MM-dd')
},
async fetchSettings() {
try {
this.loading = true
@@ -247,11 +264,15 @@ export default {
this.projectsLoading = true
const resp = await ocs.get('/projects')
const data = resp.data ?? {}
this.projects = data.projects ?? []
this.projects = (data.projects ?? []).map((p: any) => ({
...p,
label: p?.name && String(p.name).trim() !== '' ? p.name : p.id,
}))
// If nothing selected yet, pick the first project
if (!this.selectedProjectId && this.projects.length) {
this.selectedProjectId = String(this.projects[0].id)
console.debug('[DEBUG] Projects fetched', this.projects, 'selected:', this.selectedProject)
if (!this.selectedProject && this.projects.length) {
this.selectedProject = this.projects[0]
}
// Ensure a currency is selected for the (new) project
@@ -264,14 +285,20 @@ export default {
},
async fetchHistory() {
if (!this.selectedProjectId || !this.selectedCurrencyCode) return
if (!this.selectedProject || !this.selectedCurrency) return
const myReq = ++this.historyReqId
try {
const params = {
projectId: this.selectedProjectId,
currency: this.selectedCurrencyCode.id.toLowerCase(),
console.log('[DEBUG] Fetching history', {
projectId: this.selectedProject.id,
currency: this.selectedCurrency.id.toLowerCase(),
from: this.dateFrom,
to: this.dateTo,
})
const params = {
projectId: this.selectedProject.id,
currency: this.selectedCurrency.id.toLowerCase(),
from: this.formatDate(this.dateFrom),
to: this.formatDate(this.dateTo),
}
const resp = await ocs.get('/history', { params })
@@ -296,7 +323,7 @@ export default {
const labels = this.historyPoints.map((p) => formatDate(parseDate(p.x), 'yyyy-MM-dd HH:mm'))
const data = this.historyPoints.map((p) => p.y)
const label = `${this.selectedCurrencyCode?.label ?? ''} rate`
const label = `${this.selectedCurrency?.label ?? ''} rate`
if (this.chart) {
this.chart.destroy()
@@ -349,26 +376,28 @@ export default {
},
// Pick the first allowed currency for the selected project
resetCurrencyForProject() {
const p = this.projects.find((pr) => String(pr.id) === String(this.selectedProjectId))
resetCurrencyForProject(): boolean {
const p = this.projects.find((pr) => String(pr.id) === String(this.selectedProject.id))
if (!p) {
this.selectedCurrencyCode = null
return
this.selectedCurrency = null
return true
}
const options = this.currencyOptions
if (!options.length) {
this.selectedCurrencyCode = null
return
this.selectedCurrency = null
return true
}
// If current selection is missing/invalid for this project, pick first
if (
!this.selectedCurrencyCode ||
!options.some((opt) => opt.id === this.selectedCurrencyCode.id)
) {
this.selectedCurrencyCode = options[0]
const currentId = this.selectedCurrency?.id
const stillValid = currentId && options.some((opt) => opt.id === currentId)
if (stillValid) {
return false
}
this.selectedCurrency = options[0]
return true
},
},
computed: {
@@ -388,19 +417,20 @@ export default {
].some(Boolean)
})
},
projectOptions() {
return this.projects.map((p) => ({ id: p.id, label: p.name }))
},
currencyOptions() {
const p = this.projects.find((pr) => String(pr.id) === String(this.selectedProjectId))
console.log('[DEBUG] Computing currency options for project', this.selectedProject)
const p = this.projects.find((pr) => String(pr.id) === String(this.selectedProject.id))
if (!p || !Array.isArray(p.currencies)) return []
return p.currencies.map((code) => {
const sc = this.supportedCurrencies.find(
(s) => s.code.toLowerCase() === String(code).toLowerCase(),
)
console.log('[DEBUG] Mapping currency option', code, '->', sc)
if (sc) {
console.log('[DEBUG] Found supported currency', sc)
return { id: sc.code.toLowerCase(), label: `${sc.code} (${sc.symbol})` }
}
console.log('[DEBUG] Currency not supported:', code)
// Fallback if not in supported list
const c = String(code).toUpperCase()
return { id: c.toLowerCase(), label: c }

View File

@@ -75,6 +75,7 @@ final class ApiControllerTest extends TestCase {
$this->request = $opts['request'] ?? $this->createMock(IRequest::class);
$this->config = $opts['config'] ?? $this->createMock(IAppConfig::class);
$this->l10n = $opts['l10n'] ?? $this->createMock(IL10N::class);
$this->logger = $opts['logger'] ?? $this->createMock(LoggerInterface::class);
$this->userSession = $opts['userSession'] ?? $this->createMock(IUserSession::class);
$this->currencyMapper = $opts['currencyMapper'] ?? $this->createMock(CurrencyMapper::class);
$this->projectMapper = $opts['projectMapper'] ?? $this->createMock(CospendProjectMapper::class);
@@ -104,6 +105,7 @@ final class ApiControllerTest extends TestCase {
return new ApiController(
App::APP_ID,
$this->request,
$this->logger,
$this->config,
$this->l10n,
$this->userSession,