Валютний облік (Dual-Currency Accounting)¶
Дата реалізації: 2026-02-23 Модуль: Essentials
Концепція¶
Система підтримує два рівні обліку валют:
| Тип | Назва | Валюта | Призначення |
|---|---|---|---|
| Функціональна | Національна / Облікова | UAH | Бухгалтерський облік, звіти ДФС |
| Управлінська | Презентаційна | USD / EUR / інша | Управлінська звітність, KPI |
- Функціональна валюта (UAH) завжди має курс = 1.000000 і не зберігається в таблиці курсів.
- Курс управлінської валюти задається по тенанту.
- Джерело офіційних курсів — НБУ (Національний Банк України).
Архітектура¶
Схема потоку даних¶
НБУ API (bank.gov.ua)
│
▼
nbu_service.fetch_nbu_rates()
│ rate = 41.234567 UAH/USD
▼
ExchangeRate (DB)
tenant | currency | date | rate | source
───────┼──────────┼────────────┼────────────┼───────
1 | USD | 2026-02-23 | 41.234567 | nbu
1 | EUR | 2026-02-23 | 43.100000 | nbu
│
▼
get_rate_for_date(tenant, currency, date)
│ fallback: exact → latest ≤ date → Currency.rate → 1.0
▼
Invoice / IncomingPayment / OutgoingPayment
amount = 1000.00 USD
exchange_rate_value = 41.234567
functional_amount = 41 234.57 UAH ← авто-розрахунок в save()
Backend¶
Нова модель: ExchangeRate¶
Файл: backend/essentials/models/exchange_rate.py
class ExchangeRate(TenantAwareModel):
currency = FK(Currency)
date = DateField # дата курсу
rate = Decimal(18, 6) # UAH за 1 одиницю валюти
source = CharField # 'nbu' | 'manual'
class Meta:
UniqueConstraint(tenant, currency, date)
indexes: [tenant+date], [tenant+currency+date]
Точність:
- rate: Decimal(18, 6) — 6 знаків після коми (напр. 41.234567)
- functional_amount на транзакціях: Decimal(18, 2) — 2 знаки після коми
Сервіс: nbu_service.py¶
Файл: backend/essentials/services/nbu_service.py
fetch_nbu_rates(tenant, rate_date) → NbuFetchResult¶
- Викликає НБУ API:
https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange?date=YYYYMMDD&json - Отримує масив
[{cc, rate, txt, exchangedate}, ...] - Для кожної активної валюти тенанту (
iso_codeзбігається) —update_or_createв таблиціExchangeRate - Повертає:
NbuFetchResult(created, updated, skipped, errors)
get_rate_for_date(tenant, currency, date) → Decimal¶
Ланцюг fallback:
1. Точний запис на дату → ExchangeRate.objects.get(tenant, currency, date)
2. Останній курс до дати → ExchangeRate.filter(date__lte=date).order_by('-date').first()
3. Статичний курс → Currency.rate (legacy поле)
4. За замовчуванням → Decimal('1.000000')
Зміни в транзакційних моделях¶
Файли: models/invoice.py, models/incoming_payment.py, models/outgoing_payment.py
Додані поля до кожної моделі:
exchange_rate_value = DecimalField(max_digits=18, decimal_places=6, default=1)
functional_amount = DecimalField(max_digits=18, decimal_places=2, default=0)
Автоматичний розрахунок в save():
def save(self, *args, **kwargs):
if self.currency_id and self.tenant_id and self.date:
currency = Currency.objects.get(pk=self.currency_id)
self.exchange_rate_value = get_rate_for_date(
tenant=self.tenant, currency=currency, rate_date=self.date
)
self.functional_amount = (amount * exchange_rate_value).quantize('0.01')
super().save(*args, **kwargs)
ViewSet: ExchangeRateViewSet¶
Файл: backend/essentials/views/exchange_rates.py
| Метод | URL | Опис |
|---|---|---|
GET |
/api/v1/essentials/exchange-rates/ |
Список курсів (фільтр: ?date=, ?currency=) |
POST |
/api/v1/essentials/exchange-rates/ |
Додати вручну |
PUT |
/api/v1/essentials/exchange-rates/{id}/ |
Оновити |
DELETE |
/api/v1/essentials/exchange-rates/{id}/ |
Видалити |
POST |
/api/v1/essentials/exchange-rates/fetch-nbu/ |
Завантажити з НБУ |
Тіло запиту fetch-nbu:
Відповідь:
Серіалізатор¶
Файл: backend/essentials/serializers/exchange_rate.py
Поля відповіді:
id, currency, currency_name, currency_iso, currency_symbol,
date, rate, source, created_at, updated_at
Міграція: 0004_exchange_rate.py¶
Файл: backend/essentials/migrations/0004_exchange_rate.py
- Створює таблицю
essentials_exchangerate - Додає
exchange_rate_value+functional_amountдо: essentials_invoiceessentials_incomingpaymentessentials_outgoingpayment
Запуск:
Management command¶
Файл: backend/essentials/management/commands/fetch_exchange_rates.py
# Сьогоднішні курси для всіх тенантів
python manage.py fetch_exchange_rates
# За конкретну дату
python manage.py fetch_exchange_rates --date 2026-02-23
# Для одного тенанту
python manage.py fetch_exchange_rates --tenant 1
Підходить для запуску через cron щодня о 11:00 (після публікації курсів НБУ).
Frontend¶
Навігація (Essentials → Cash Accounting)¶
Додано два нових пункти в підгрупу Cash Accounting після "Currencies":
| Код | Назва | Тип | Компонент |
|---|---|---|---|
exchangeRates |
Exchange Rates | PROCESS | ExchangeRatesPage |
nbuFetch |
Load NBU Rates | PROCESS | NbuFetchPage |
ExchangeRatesPage.tsx¶
Файл: frontend/erp/src/components/Currency/ExchangeRatesPage.tsx
- Таблиця курсів: Дата | Валюта | ISO | Курс (UAH) | Джерело
- Фільтр по ISO-коду (пошук) та по даті (
DateInput) - Значки джерела:
NBU(синій) /Manual(сірий) - Пагінація (20 записів/сторінку)
- Додавання вручну (модальне вікно: валюта + дата + курс + джерело)
- Видалення з підтвердженням
- Кнопка оновлення (інвалідує React Query кеш)
NbuFetchPage.tsx¶
Файл: frontend/erp/src/components/Currency/NbuFetchPage.tsx
- Вибір дати (за замовчуванням — сьогодні)
- Кнопка "Fetch Rates" →
POST /exchange-rates/fetch-nbu/ - Відображення результату:
- Значки:
Created(зелений),Updated(синій),Skipped(сірий) - Список помилок (якщо є)
- Посилання на офіційну сторінку курсів НБУ
- Після успішного завантаження — автоматичне оновлення таблиці
ExchangeRatesPage
ProcessPanel.tsx (оновлено)¶
Файл: frontend/erp/src/components/Process/ProcessPanel.tsx
Додано два нових case:
Структура файлів (нові/змінені)¶
backend/essentials/
├── models/
│ ├── exchange_rate.py ★ новий
│ ├── __init__.py ★ оновлено (ExchangeRate, ExchangeRateSource)
│ ├── invoice.py ★ оновлено (+ exchange_rate_value, functional_amount)
│ ├── incoming_payment.py ★ оновлено (+ exchange_rate_value, functional_amount)
│ └── outgoing_payment.py ★ оновлено (+ exchange_rate_value, functional_amount)
├── serializers/
│ └── exchange_rate.py ★ новий
├── views/
│ └── exchange_rates.py ★ новий (ViewSet + fetch-nbu action)
├── services/
│ └── nbu_service.py ★ новий (fetch_nbu_rates, get_rate_for_date)
├── migrations/
│ └── 0004_exchange_rate.py ★ новий
├── management/
│ └── commands/
│ └── fetch_exchange_rates.py ★ новий
└── urls.py ★ оновлено (+ exchange-rates router)
frontend/erp/src/
├── components/
│ ├── Currency/
│ │ ├── ExchangeRatesPage.tsx ★ новий
│ │ └── NbuFetchPage.tsx ★ новий
│ └── Process/
│ └── ProcessPanel.tsx ★ оновлено (+ 2 нових case)
└── config/
└── essentials.ts ★ оновлено (+ exchangeRates, nbuFetch items)
Що залишилось (TODO)¶
- [ ] Реєстрація
ExchangeRateвessentials/admin.py - [ ] Управлінська валюта: поле
presentation_currencyна рівніTenantабо налаштувань - [ ] Звіт "Dual-Currency P&L" — порівняння UAH vs USD/EUR
- [ ] Автоматичний cron-запуск
fetch_exchange_ratesчерез Celery або системний планувальник - [ ] Відображення
functional_amountвTransactionList(паралельна колонка) - [ ] Перерахунок
functional_amountпри зміні курсу (batch update)