Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions frontend/src/api/currency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import apiClient from './axios';
import type { SupportedCurrency } from './me';

export interface ExchangeRateDto {
code: 'USD' | 'EUR';
date: string; // ISO date
rateToUah: number;
}

export interface CurrencyPreferences {
preferredCurrency: SupportedCurrency;
}

export const getLatestRates = () =>
apiClient.get<ExchangeRateDto[]>('/api/currency/rates/latest').then((r) => r.data);

export const getPreferences = () =>
apiClient.get<CurrencyPreferences>('/api/currency/preferences').then((r) => r.data);

export const updatePreferences = (preferredCurrency: SupportedCurrency) =>
apiClient
.put<CurrencyPreferences>('/api/currency/preferences', { preferredCurrency })
.then((r) => r.data);
3 changes: 3 additions & 0 deletions frontend/src/api/me.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ export interface MeResponse {
isSuperAdmin: boolean;
mfaEnabled: boolean;
mfaRequired: boolean;
preferredCurrency: SupportedCurrency;
}

export type SupportedCurrency = 'UAH' | 'USD' | 'EUR';

export const getMe = () =>
apiClient.get<MeResponse>('/api/me').then((r) => r.data);
57 changes: 57 additions & 0 deletions frontend/src/hooks/useFormatCurrency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useCallback } from 'react';
import { useCurrencyStore } from '../stores/currencyStore';
import type { SupportedCurrency } from '../api/me';

export interface FormatCurrencyOptions {
/** Override target currency; defaults to user's preferred. */
target?: SupportedCurrency;
/** Fraction digits for the output amount; defaults 2. */
fractionDigits?: number;
}

/**
* Returns a memoised formatter that converts a UAH amount into the user's
* preferred display currency using the last-known NBU rate.
*
* Invariants per ROADMAP "Decisions locked / Currency":
* - All stored amounts in the DB are UAH.
* - Conversion happens at presentation only.
* - Fallback: if the target currency's rate is not loaded yet (or failed),
* we return the UAH value labelled as UAH (graceful degrade).
*/
export function useFormatCurrency() {
const preferredCurrency = useCurrencyStore((s) => s.preferredCurrency);
const rates = useCurrencyStore((s) => s.rates);

return useCallback(
(amountUah: number | null | undefined, opts?: FormatCurrencyOptions): string => {
const value = typeof amountUah === 'number' && Number.isFinite(amountUah) ? amountUah : 0;
const target: SupportedCurrency = opts?.target ?? preferredCurrency;
const fractionDigits = opts?.fractionDigits ?? 2;

let displayValue = value;
let displayCode: SupportedCurrency = target;

if (target === 'UAH') {
displayValue = value;
} else {
const rate = rates[target];
if (rate && rate > 0) {
displayValue = value / rate;
} else {
// Rate unknown → degrade to UAH. Keeps product usable offline / before load.
displayCode = 'UAH';
displayValue = value;
}
}

return new Intl.NumberFormat('uk-UA', {
style: 'currency',
currency: displayCode,
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}).format(displayValue);
},
[preferredCurrency, rates]
);
}
6 changes: 6 additions & 0 deletions frontend/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,12 @@ const en: Translations = {
language: 'Language',
langUk: 'Українська',
langEn: 'English',
currency: 'Currency',
currencyHint: 'Display currency. Data is stored in UAH; converted at NBU rate.',
currencyUah: 'Hryvnia (UAH)',
currencyUsd: 'US Dollar (USD)',
currencyEur: 'Euro (EUR)',
currencySaveError: 'Failed to save currency',
},
budget: {
title: 'Budget Planning',
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/i18n/uk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,12 @@ const uk = {
language: 'Мова',
langUk: 'Українська',
langEn: 'English',
currency: 'Валюта',
currencyHint: 'Валюта відображення. Дані в базі зберігаються в UAH; конвертація за курсом НБУ.',
currencyUah: 'Гривня (UAH)',
currencyUsd: 'Долар США (USD)',
currencyEur: 'Євро (EUR)',
currencySaveError: 'Не вдалося зберегти валюту',
},
budget: {
title: 'Планування бюджету',
Expand Down
35 changes: 34 additions & 1 deletion frontend/src/pages/Profile/ProfilePage.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,32 @@
import { Card, Descriptions, Button, Space, Tag } from 'antd';
import { Card, Descriptions, Button, Space, Tag, Select, message } from 'antd';
import { GlobalOutlined } from '@ant-design/icons';
import { useEffect } from 'react';
import PageHeader from '../../components/PageHeader';
import { useTranslation } from '../../i18n';
import { useRole } from '../../hooks/useRole';
import { useAuthStore } from '../../stores/authStore';
import { useCurrencyStore } from '../../stores/currencyStore';
import type { SupportedCurrency } from '../../api/me';

export default function ProfilePage() {
const { t, lang, setLang } = useTranslation();
const { isAdmin, isManager, isWarehouseOperator, isAccountant } = useRole();
const { token, email: storedEmail } = useAuthStore();
const preferredCurrency = useCurrencyStore((s) => s.preferredCurrency);
const loadCurrency = useCurrencyStore((s) => s.load);
const setPreferredCurrency = useCurrencyStore((s) => s.setPreferredCurrency);

useEffect(() => {
void loadCurrency();
}, [loadCurrency]);

const handleCurrencyChange = async (c: SupportedCurrency) => {
try {
await setPreferredCurrency(c);
} catch {
message.error(t.profile.currencySaveError);
}
};

// Derive current role string from the hook flags
const roleKey = isAdmin
Expand Down Expand Up @@ -92,6 +110,21 @@ export default function ProfilePage() {
</Button>
</Space>
</Descriptions.Item>
<Descriptions.Item label={t.profile.currency}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Select<SupportedCurrency>
value={preferredCurrency}
onChange={handleCurrencyChange}
style={{ width: 240 }}
options={[
{ value: 'UAH', label: t.profile.currencyUah },
{ value: 'USD', label: t.profile.currencyUsd },
{ value: 'EUR', label: t.profile.currencyEur },
]}
/>
<span style={{ color: '#8B949E', fontSize: 12 }}>{t.profile.currencyHint}</span>
</Space>
</Descriptions.Item>
</Descriptions>
</Card>
</div>
Expand Down
67 changes: 67 additions & 0 deletions frontend/src/stores/currencyStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { create } from 'zustand';
import { getLatestRates, getPreferences, updatePreferences, type ExchangeRateDto } from '../api/currency';
import type { SupportedCurrency } from '../api/me';
import { useAuthStore } from './authStore';

interface CurrencyState {
preferredCurrency: SupportedCurrency;
rates: Record<'USD' | 'EUR', number | null>;
loaded: boolean;
loading: boolean;
loadedForTenantId: string | null;

load: () => Promise<void>;
setPreferredCurrency: (c: SupportedCurrency) => Promise<void>;
reset: () => void;
}

const emptyRates = (): Record<'USD' | 'EUR', number | null> => ({ USD: null, EUR: null });

export const useCurrencyStore = create<CurrencyState>((set, get) => ({
preferredCurrency: 'UAH',
rates: emptyRates(),
loaded: false,
loading: false,
loadedForTenantId: null,

load: async () => {
const { token, tenantId } = useAuthStore.getState();
if (!token || !tenantId) {
set({ preferredCurrency: 'UAH', rates: emptyRates(), loaded: false, loading: false, loadedForTenantId: null });
return;
}
if (get().loading) return;
if (get().loaded && get().loadedForTenantId === tenantId) return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Key currency cache by user identity

The load guard caches currency data at tenant scope, but preferredCurrency is user-specific. When user A logs out and user B (same tenant) logs in within the same SPA session, load() returns early and keeps user A’s preference, so profile/settings and any formatting based on this store can show the wrong currency for user B. Include user identity in the cache key (or force reset/refetch on auth user change) instead of only tenantId.

Useful? React with 👍 / 👎.


set({ loading: true });
try {
const [prefs, rates] = await Promise.all([getPreferences(), getLatestRates()]);
const next = emptyRates();
for (const r of rates as ExchangeRateDto[]) {
if (r.code === 'USD' || r.code === 'EUR') next[r.code] = r.rateToUah;
}
set({
preferredCurrency: prefs.preferredCurrency,
rates: next,
loaded: true,
loading: false,
loadedForTenantId: tenantId,
});
} catch {
set({ loading: false });
}
},

setPreferredCurrency: async (c) => {
const prev = get().preferredCurrency;
set({ preferredCurrency: c });
try {
await updatePreferences(c);
} catch (e) {
set({ preferredCurrency: prev });
throw e;
}
},

reset: () => set({ preferredCurrency: 'UAH', rates: emptyRates(), loaded: false, loading: false, loadedForTenantId: null }),
}));
109 changes: 109 additions & 0 deletions src/AgroPlatform.Api/Controllers/CurrencyController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System.Security.Claims;
using AgroPlatform.Application.Common.Interfaces;
using AgroPlatform.Domain.Users;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace AgroPlatform.Api.Controllers;

/// <summary>
/// Currency preferences and exchange rates. Base currency in DB is always UAH;
/// this controller exposes stored NBU rates and the signed-in user's display preference.
/// See ROADMAP.md "Decisions locked / Currency".
/// </summary>
[ApiController]
[Authorize]
[Route("api/currency")]
[Produces("application/json")]
public sealed class CurrencyController : ControllerBase
{
private static readonly string[] AllowedCodes = { "UAH", "USD", "EUR" };
private static readonly string[] TrackedCodes = { "USD", "EUR" };

private readonly IAppDbContext _db;
private readonly INbuCurrencyService _nbu;

public CurrencyController(IAppDbContext db, INbuCurrencyService nbu)
{
_db = db;
_nbu = nbu;
}

public record RateDto(string Code, DateOnly Date, decimal RateToUah);
public record PreferencesDto(string PreferredCurrency);
public record UpdatePreferencesRequest(string PreferredCurrency);

/// <summary>Latest stored rates for tracked currencies (USD, EUR).</summary>
[HttpGet("rates/latest")]
public async Task<IActionResult> GetLatestRates(CancellationToken ct)
{
var rows = new List<RateDto>();
foreach (var code in TrackedCodes)
{
var r = await _db.ExchangeRates
.Where(x => x.Code == code)
.OrderByDescending(x => x.Date)
.Select(x => new RateDto(x.Code, x.Date, x.RateToUah))
.FirstOrDefaultAsync(ct);
if (r is not null) rows.Add(r);
}
return Ok(rows);
}

/// <summary>Rate for <paramref name="code"/> on <paramref name="date"/> (fallback to previous business day).</summary>
[HttpGet("rates")]
public async Task<IActionResult> GetRate([FromQuery] string code, [FromQuery] DateOnly date, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(code)) return BadRequest(new { error = "code required" });
code = code.ToUpperInvariant();
if (code == "UAH") return Ok(new RateDto("UAH", date, 1m));
var rate = await _nbu.GetRateAsync(code, date, ct);
if (rate is null) return NotFound(new { error = "no rate for given currency" });
// Look up the actual row date (could be earlier than requested).
var row = await _db.ExchangeRates
.Where(r => r.Code == code && r.Date <= date)
.OrderByDescending(r => r.Date)
.Select(r => new RateDto(r.Code, r.Date, r.RateToUah))
.FirstOrDefaultAsync(ct);
return Ok(row);
}

/// <summary>Current user's display preferences (creates defaults if missing).</summary>
[HttpGet("preferences")]
public async Task<IActionResult> GetPreferences(CancellationToken ct)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Unauthorized();
var row = await _db.UserPreferences.FirstOrDefaultAsync(p => p.UserId == userId, ct);
return Ok(new PreferencesDto(row?.PreferredCurrency ?? "UAH"));
}

[HttpPut("preferences")]
public async Task<IActionResult> UpdatePreferences([FromBody] UpdatePreferencesRequest req, CancellationToken ct)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Unauthorized();
var code = (req.PreferredCurrency ?? string.Empty).ToUpperInvariant();
if (!AllowedCodes.Contains(code))
return BadRequest(new { error = "PreferredCurrency must be one of UAH, USD, EUR" });

var existing = await _db.UserPreferences.FirstOrDefaultAsync(p => p.UserId == userId, ct);
if (existing is null)
{
_db.UserPreferences.Add(new UserPreferences
{
UserId = userId,
PreferredCurrency = code,
UpdatedAtUtc = DateTime.UtcNow,
});
}
else
{
existing.PreferredCurrency = code;
existing.UpdatedAtUtc = DateTime.UtcNow;
}
await _db.SaveChangesAsync(ct);
return Ok(new PreferencesDto(code));
}
}
5 changes: 5 additions & 0 deletions src/AgroPlatform.Api/Controllers/MeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public async Task<IActionResult> Get(CancellationToken cancellationToken)
var userId = _currentUser.UserId;
bool isSuperAdmin = _currentUser.IsSuperAdmin;
bool mfaEnabled = false;
string preferredCurrency = "UAH";

if (!string.IsNullOrEmpty(userId))
{
Expand All @@ -52,6 +53,9 @@ public async Task<IActionResult> Get(CancellationToken cancellationToken)
// Prefer the DB flag over the JWT claim (claim may be stale across logins).
var user = await _userManager.FindByIdAsync(userId);
if (user is not null) isSuperAdmin = user.IsSuperAdmin || _currentUser.IsSuperAdmin;

var prefs = await _db.UserPreferences.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
if (prefs is not null) preferredCurrency = prefs.PreferredCurrency;
}

return Ok(new
Expand All @@ -64,6 +68,7 @@ public async Task<IActionResult> Get(CancellationToken cancellationToken)
features,
isSuperAdmin,
mfaEnabled,
preferredCurrency,
// Super-admin without MFA enrolled → SPA redirects to /setup-mfa.
mfaRequired = isSuperAdmin && !mfaEnabled,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,10 @@ public interface IAppDbContext
DbSet<ApiKey> ApiKeys { get; }
DbSet<RefreshToken> RefreshTokens { get; }
DbSet<UserMfaSettings> UserMfaSettings { get; }
DbSet<UserPreferences> UserPreferences { get; }
DbSet<SuperAdminAuditLog> SuperAdminAuditLogs { get; }
DbSet<Season> Seasons { get; }
DbSet<ExchangeRate> ExchangeRates { get; }

// Approval workflow
DbSet<ApprovalRule> ApprovalRules { get; }
Expand Down
Loading
Loading