Перейти до змісту

Валютний облік (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:

{ "date": "2026-02-23" }

Відповідь:

{
  "date": "2026-02-23",
  "created": 5,
  "updated": 2,
  "skipped": 0,
  "total": 7,
  "errors": []
}


Серіалізатор

Файл: 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_invoice
  • essentials_incomingpayment
  • essentials_outgoingpayment

Запуск:

python manage.py migrate essentials


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:

case 'exchangeRates':
  return <ExchangeRatesPage />;

case 'nbuFetch':
  return <NbuFetchPage />;

Структура файлів (нові/змінені)

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)