Облік ПДВ (VAT Accounting) — Модуль Essentials¶
Зміст¶
- Огляд та бізнес-логіка
- Архітектура
- Backend — Django моделі
- Backend — Серіалізатори
- Backend — Views та API
- Міграція бази даних
- Frontend — TypeScript утиліта
- Frontend — Конфігурація навігації
- Стратегія для існуючих даних
- Початкове наповнення даними
- Тестові сценарії
1. Огляд та бізнес-логіка¶
Українська податкова специфіка¶
| Система оподаткування | Тип суб'єкта | tax_system |
is_vat_payer |
Ставка ПДВ |
|---|---|---|---|---|
| Загальна система | ТОВ / АТ | general |
True |
20% / 7% / 0% |
| Загальна система (без реєстрації платника) | ТОВ малий бізнес | general |
False |
0 (без ПДВ) |
| Спрощена система | ФОП (1–3 група) | simplified |
False |
0 (без ПДВ) |
Правило розрахунку¶
price_net = ціна за одиницю без ПДВ
amount = quantity × price_net (чиста сума рядка)
vat_amount = amount × vat_rate / 100 (сума ПДВ)
price_gross = price_net × (1 + vat_rate / 100) (ціна з ПДВ за одиницю)
amount_gross = amount + vat_amount (сума рядка з ПДВ)
total_gross = total_amount + vat_amount (підсумок документа з ПДВ)
Обмеження¶
- Якщо
Organization.is_vat_payer = False→vat_rateпримусово0на всіх рядках - Якщо
TaxRate.is_nontaxable = True→vat_rate = 0незалежно від налаштувань організації - Прецизія фінансових полів:
max_digits=19, decimal_places=4(стандарт IAS/МСФЗ)
2. Архітектура¶
┌─────────────────────────────────────────────────────────────────┐
│ Довідник TaxRate (ставки ПДВ) │
│ 20.0000% │ 7.0000% │ 0.0000% │ Без ПДВ (is_nontaxable=True) │
└───────────────────────┬─────────────────────────────────────────┘
│ FK (nullable)
┌─────────────┼──────────────┐
▼ ▼ ▼
InvoiceLine GoodsReceiptLine GoodsShipmentLine
(рядки рахунку) (рядки прихідної) (рядки видаткової)
│
│ invoice.organization.is_vat_payer
▼
Organization.is_vat_payer ──► False → vat_rate = 0 (серіалізатор)
└► True → vat_rate з TaxRate або поля vat_rate
┌──────────────────────────────────────────────────────────────────┐
│ Документи-заголовки │
│ Invoice.total_gross = total_amount + vat_amount │
└──────────────────────────────────────────────────────────────────┘
Де відбувається логіка¶
| Рівень | Що робить |
|---|---|
| Model.save() | Обчислює amount, vat_amount, price_gross з полів price, quantity, vat_rate (та FK tax_rate якщо встановлено) |
| Serializer.validate() | Синхронізує vat_rate з TaxRate FK; примусово встановлює vat_rate=0 якщо is_vat_payer=False |
| Frontend vatUtils.ts | Розрахунок на льоту при зміні кількості / ставки в UI; блокує вибір ставки для не-платників |
3. Backend — Django моделі¶
3.1 TaxRate — довідник ставок ПДВ¶
Файл: backend/essentials/models/tax_rate.py
class TaxRate(MasterDataModel):
"""Ставки ПДВ / VAT Tax Rates Directory."""
verbose_name_ua = 'Ставки ПДВ'
rate = models.DecimalField(
max_digits=6, decimal_places=4, default=0,
verbose_name='Rate (%)',
help_text='Ставка ПДВ у відсотках, напр. 20.0000 для 20%.',
)
is_nontaxable = models.BooleanField(
default=False,
verbose_name='Non-taxable',
help_text='True для "Без ПДВ" — примусово встановлює суму ПДВ в нуль.',
)
Успадковує від MasterDataModel:
- tenant (FK) — мультитенантність
- name, code, description — стандартні поля довідника
- is_active, is_group — фільтрація
- created_at, updated_at, created_by, updated_by
3.2 Organization — податковий профіль¶
Файл: backend/essentials/models/organization.py
Додані поля:
TAX_SYSTEM_CHOICES = [
('general', 'General / Загальна система'),
('simplified', 'Simplified / Спрощена система (ФОП)'),
]
tax_system = CharField(max_length=20, choices=TAX_SYSTEM_CHOICES, default='general')
is_vat_payer = BooleanField(default=True) # True = ТОВ платник ПДВ
vat_certificate = CharField(max_length=50, blank=True) # ІПН / свідоцтво платника ПДВ
3.3 InvoiceLine — рядки рахунку¶
Файл: backend/essentials/models/invoice.py
Нові / оновлені поля:
# Ціна (net = без ПДВ)
price = DecimalField(max_digits=19, decimal_places=4) # підвищена прецизія
amount = DecimalField(max_digits=19, decimal_places=4) # quantity × price
# ПДВ
tax_rate = ForeignKey('TaxRate', null=True, blank=True) # з довідника
vat_rate = DecimalField(max_digits=6, decimal_places=4, default=20) # % (синхронізується з tax_rate)
vat_amount = DecimalField(max_digits=19, decimal_places=4) # amount × vat_rate / 100
price_gross = DecimalField(max_digits=19, decimal_places=4) # price × (1 + vat_rate/100)
Логіка save():
def save(self, *args, **kwargs):
D = Decimal
# 1. Якщо встановлено FK на TaxRate → синхронізувати vat_rate
effective_rate = D(str(self.vat_rate))
if self.tax_rate_id:
tr = self.tax_rate
effective_rate = D('0') if tr.is_nontaxable else D(str(tr.rate))
self.vat_rate = effective_rate
# 2. Розрахунок сум
self.amount = (D(str(self.quantity)) * D(str(self.price))).quantize(D('0.0001'))
self.vat_amount = (self.amount * effective_rate / D('100')).quantize(D('0.0001'))
self.price_gross = (D(str(self.price)) * (D('1') + effective_rate / D('100'))).quantize(D('0.0001'))
super().save(*args, **kwargs)
Примітка: Перевірка
is_vat_payerвиконується в серіалізаторі (не в моделі), щоб уникнути зайвих запитів до БД уsave().
3.4 Invoice — заголовок рахунку¶
Додане поле:
total_gross = DecimalField(max_digits=19, decimal_places=4, default=0)
# Обчислюється в save(): total_amount + vat_amount
Оновлена прецизія:
- total_amount: max_digits=19, decimal_places=4 (було 15,2)
- vat_amount: max_digits=19, decimal_places=4 (було 15,2)
3.5 GoodsReceiptLine — рядки прихідної накладної¶
Файл: backend/essentials/models/goods_receipt.py
Додані поля (аналогічно InvoiceLine):
tax_rate = ForeignKey('TaxRate', null=True, blank=True)
vat_rate = DecimalField(max_digits=6, decimal_places=4, default=0) # за замовчуванням 0 (купівля)
vat_amount = DecimalField(max_digits=19, decimal_places=4, default=0)
price_gross = DecimalField(max_digits=19, decimal_places=4, default=0)
# price та amount підвищена прецизія: max_digits=19, decimal_places=4
Для прихідних накладних
vat_rateза замовчуванням0, оскільки при купівлі від платника ПДВ необхідна окрема «Податкова накладна» від постачальника.
3.6 GoodsShipmentLine — рядки видаткової накладної¶
Файл: backend/essentials/models/goods_shipment.py
Додані поля:
tax_rate = ForeignKey('TaxRate', null=True, blank=True)
price_gross = DecimalField(max_digits=19, decimal_places=4, default=0)
# vat_rate та vat_amount: підвищена прецизія (були max_digits=5,2 / 15,2)
4. Backend — Серіалізатори¶
Файл: backend/essentials/serializers/transactions.py
Допоміжна функція¶
def _resolve_vat_rate(data, tax_rate_key='tax_rate'):
"""Якщо передано TaxRate FK — синхронізувати vat_rate з нього."""
tax_rate = data.get(tax_rate_key)
if tax_rate is not None:
data['vat_rate'] = Decimal('0') if tax_rate.is_nontaxable else tax_rate.rate
return data
InvoiceLineSerializer¶
class InvoiceLineSerializer(serializers.ModelSerializer):
class Meta:
model = InvoiceLine
fields = '__all__'
read_only_fields = ('amount', 'vat_amount', 'price_gross') # обчислюються в save()
def validate(self, data):
# 1. Синхронізувати vat_rate з TaxRate FK
_resolve_vat_rate(data)
# 2. Примусово 0% якщо організація не є платником ПДВ
invoice = data.get('invoice') or (self.instance.invoice if self.instance else None)
org = invoice.organization if invoice else None
if org and not org.is_vat_payer:
data['vat_rate'] = Decimal('0')
return data
Аналогічна логіка validate() реалізована в:
- GoodsReceiptLineSerializer (перевіряє receipt.organization)
- GoodsShipmentLineSerializer (перевіряє shipment.organization)
TaxRateSerializer¶
Файл: backend/essentials/serializers/master_data.py
class TaxRateSerializer(serializers.ModelSerializer):
class Meta:
model = TaxRate
fields = '__all__'
read_only_fields = ('tenant', 'created_at', 'updated_at', 'created_by', 'updated_by')
5. Backend — Views та API¶
TaxRateViewSet¶
Файл: backend/essentials/views/master_data.py
class TaxRateViewSet(TenantFilterMixin, viewsets.ModelViewSet):
queryset = TaxRate.objects.all()
serializer_class = TaxRateSerializer
search_fields = ['name', 'code']
filterset_fields = ['is_nontaxable', 'is_active']
ordering_fields = ['rate', 'name']
Ендпоінти API¶
| Метод | URL | Опис |
|---|---|---|
GET |
/api/v1/essentials/tax-rates/ |
Список ставок ПДВ |
POST |
/api/v1/essentials/tax-rates/ |
Створити ставку |
GET |
/api/v1/essentials/tax-rates/{id}/ |
Деталь ставки |
PUT/PATCH |
/api/v1/essentials/tax-rates/{id}/ |
Оновити ставку |
DELETE |
/api/v1/essentials/tax-rates/{id}/ |
Видалити ставку |
Ставки ПДВ автоматично фільтруються за tenant через TenantFilterMixin.
Оновлені ендпоінти (нові поля у відповіді)¶
| URL | Нові поля у відповіді |
|---|---|
/api/v1/essentials/invoices/{id}/ |
total_gross |
/api/v1/essentials/invoice-lines/ |
tax_rate, price_gross |
/api/v1/essentials/goods-receipt-lines/ |
tax_rate, vat_rate, vat_amount, price_gross |
/api/v1/essentials/goods-shipment-lines/ |
tax_rate, price_gross (підвищена прецизія) |
/api/v1/essentials/organizations/ |
tax_system, is_vat_payer, vat_certificate |
6. Міграція бази даних¶
Файл: backend/essentials/migrations/0005_vat_system.py
Зміни схеми¶
| Таблиця | Операція | Деталі |
|---|---|---|
essentials_taxrate |
CreateModel |
Нова таблиця |
essentials_organization |
AddField × 3 |
tax_system, is_vat_payer, vat_certificate |
essentials_invoice |
AlterField × 2 + AddField |
Прецизія total_amount/vat_amount; новий total_gross |
essentials_invoiceline |
AlterField × 4 + AddField × 2 |
Прецизія; tax_rate FK; price_gross |
essentials_goodsreceiptline |
AlterField × 2 + AddField × 4 |
Прецизія; tax_rate, vat_rate, vat_amount, price_gross |
essentials_goodsshipmentline |
AlterField × 4 + AddField × 2 |
Прецизія; tax_rate FK; price_gross |
Застосування¶
# Активувати venv
cd c:\eswf\backend
venv\Scripts\activate # Windows
# source venv/bin/activate # Linux/Mac
# Перевірити стан
python manage.py showmigrations essentials
# Застосувати
python manage.py migrate essentials 0005_vat_system
Стратегія безпеки існуючих даних¶
- Всі нові
DecimalField→default=0(існуючі рядки отримають0, без помилок) tax_rateFK →null=True(існуючі рядки маютьNULL)Organization.is_vat_payer→default=True(існуючі організації вважаються платниками ПДВ)Organization.tax_system→default='general'(загальна система за замовчуванням)- Розширення прецизії (15,2 → 19,4) — завжди безпечно, дані не втрачаються
7. Frontend — TypeScript утиліта¶
Файл: frontend/erp/src/utils/vatUtils.ts
Типи¶
interface VatLineInput {
price: number | string; // Чиста ціна за одиницю (без ПДВ)
quantity: number | string; // Кількість
vatRate: number | string; // Ставка ПДВ у відсотках (напр. 20)
}
interface VatLineResult {
priceNet: number; // = price (вхідне значення)
priceGross: number; // = priceNet × (1 + vatRate/100)
vatRate: number; // Ефективна ставка (0 для не-платників)
amount: number; // = quantity × priceNet
vatAmount: number; // = amount × vatRate / 100
amountGross: number; // = amount + vatAmount
}
interface DocumentTotals {
totalNet: number; // Сума чистих сум усіх рядків
totalVat: number; // Сума ПДВ усіх рядків
totalGross: number; // totalNet + totalVat
}
Використання в компонентах¶
import { calcVatLine, calcDocumentTotals, effectiveRate } from '@/utils/vatUtils';
// 1. Перерахунок рядка при зміні кількості або ставки
const result = calcVatLine(
{ price: 1000, quantity: 5, vatRate: 20 },
organization.is_vat_payer // false для ФОП → vat = 0
);
// → { priceNet: 1000, priceGross: 1200, vatRate: 20,
// amount: 5000, vatAmount: 1000, amountGross: 6000 }
// 2. Підрахунок підсумків документа
const totals = calcDocumentTotals(lines.map(l => calcVatLine(...)));
// → { totalNet: 5000, totalVat: 1000, totalGross: 6000 }
// 3. Ставка з об'єкта TaxRate
const rate = effectiveRate(selectedTaxRate);
// → 20 (або 0 для is_nontaxable = true)
Блокування вибору ставки для не-платників ПДВ¶
// У формі TransactionForm / TransactionLine:
<Select
data={taxRates.map(tr => ({ value: String(tr.id), label: tr.name }))}
disabled={!organization?.is_vat_payer} // ← ключова умова
placeholder={organization?.is_vat_payer ? 'Оберіть ставку' : 'Без ПДВ'}
/>
8. Frontend — Конфігурація навігації¶
Файл: frontend/erp/src/config/essentials.ts
Додано пункт меню в підгрупу goodsaccounting:
{
code: "taxRates",
name: "Tax Rates",
name_ua: "Ставки ПДВ",
type: ItemType.MASTERDATA,
icon: "PercentageOutlined",
description: "VAT tax rates directory (20%, 7%, 0%, Non-taxable).",
description_ua: "Довідник ставок ПДВ (20%, 7%, 0%, Без ПДВ).",
componentProps: { entityCode: "taxRates" } as MasterDataListProps,
}
Шлях в UI: Essentials → Master Data → Goods Accounting → Ставки ПДВ
URL: /essentials/essentialsmasterdata/taxRates
9. Стратегія для існуючих даних¶
Покрокова активація ПДВ в існуючій базі¶
Крок 1. Заповнити довідник TaxRate (через Django Admin або fixtures):
# management/commands/seed_tax_rates.py (або Django shell)
from essentials.models import TaxRate
from core.models import Tenant
tenant = Tenant.objects.first()
TaxRate.objects.bulk_create([
TaxRate(tenant=tenant, name='ПДВ 20%', code='VAT20', rate='20.0000'),
TaxRate(tenant=tenant, name='ПДВ 7%', code='VAT7', rate='7.0000'),
TaxRate(tenant=tenant, name='ПДВ 0%', code='VAT0', rate='0.0000'),
TaxRate(tenant=tenant, name='Без ПДВ', code='NOVT', rate='0.0000', is_nontaxable=True),
])
Крок 2. Налаштувати організації (через UI або shell):
Organization.objects.filter(tax_system='general').update(is_vat_payer=True)
# ФОП — вручну через адмінку
Крок 3. Заповнити TaxRate FK у існуючих рядках документів (опційно):
# Для всіх InvoiceLine де vat_rate = 20 → прив'язати до TaxRate 'VAT20'
vat20 = TaxRate.objects.get(code='VAT20', tenant=tenant)
InvoiceLine.objects.filter(vat_rate=20).update(tax_rate=vat20)
Крок 4. Перерахувати price_gross для існуючих рядків (де price_gross = 0):
python manage.py shell -c "
from essentials.models import InvoiceLine
for line in InvoiceLine.objects.filter(price_gross=0):
line.save() # save() автоматично перерахує price_gross
print('Done')
"
10. Початкове наповнення даними¶
Fixtures (JSON)¶
[
{
"model": "essentials.taxrate",
"pk": 1,
"fields": {
"tenant": 1,
"name": "ПДВ 20%",
"code": "VAT20",
"description": "Стандартна ставка ПДВ 20% (загальна система)",
"rate": "20.0000",
"is_nontaxable": false,
"is_active": true,
"is_group": false
}
},
{
"model": "essentials.taxrate",
"pk": 2,
"fields": {
"tenant": 1,
"name": "ПДВ 7%",
"code": "VAT7",
"description": "Знижена ставка ПДВ 7% (медикаменти, книги)",
"rate": "7.0000",
"is_nontaxable": false,
"is_active": true,
"is_group": false
}
},
{
"model": "essentials.taxrate",
"pk": 3,
"fields": {
"tenant": 1,
"name": "ПДВ 0%",
"code": "VAT0",
"description": "Нульова ставка ПДВ (експорт)",
"rate": "0.0000",
"is_nontaxable": false,
"is_active": true,
"is_group": false
}
},
{
"model": "essentials.taxrate",
"pk": 4,
"fields": {
"tenant": 1,
"name": "Без ПДВ",
"code": "NOVAT",
"description": "Не є об'єктом оподаткування ПДВ",
"rate": "0.0000",
"is_nontaxable": true,
"is_active": true,
"is_group": false
}
}
]
11. Тестові сценарії¶
Сценарій 1 — ТОВ, платник ПДВ 20%¶
Organization: { tax_system: 'general', is_vat_payer: True }
InvoiceLine: { price: 1000.00, quantity: 5, tax_rate: 'VAT20' (20%) }
Очікуваний результат:
vat_rate = 20.0000
amount = 5000.0000 (5 × 1000)
vat_amount = 1000.0000 (5000 × 20/100)
price_gross = 1200.0000 (1000 × 1.20)
Сценарій 2 — ФОП, спрощена система (не платник ПДВ)¶
Organization: { tax_system: 'simplified', is_vat_payer: False }
InvoiceLine: { price: 1000.00, quantity: 5, tax_rate: 'VAT20' (20%) }
Очікуваний результат (серіалізатор перевизначає vat_rate → 0):
vat_rate = 0.0000
amount = 5000.0000
vat_amount = 0.0000
price_gross = 1000.0000
Сценарій 3 — Ставка "Без ПДВ" (is_nontaxable=True)¶
Organization: { is_vat_payer: True }
InvoiceLine: { price: 500.00, quantity: 2, tax_rate: 'NOVAT' (is_nontaxable=True) }
Очікуваний результат:
vat_rate = 0.0000 (is_nontaxable примусово 0)
amount = 1000.0000
vat_amount = 0.0000
price_gross = 500.0000
Сценарій 4 — Frontend calcVatLine¶
// ТОВ платник ПДВ
calcVatLine({ price: 1000, quantity: 5, vatRate: 20 }, true)
// → { priceNet: 1000, priceGross: 1200, vatRate: 20, amount: 5000, vatAmount: 1000, amountGross: 6000 }
// ФОП не платник ПДВ
calcVatLine({ price: 1000, quantity: 5, vatRate: 20 }, false)
// → { priceNet: 1000, priceGross: 1000, vatRate: 0, amount: 5000, vatAmount: 0, amountGross: 5000 }
Файли реалізації¶
| Файл | Що змінено |
|---|---|
backend/essentials/models/tax_rate.py |
✅ Новий файл — модель TaxRate |
backend/essentials/models/organization.py |
✅ Додано tax_system, is_vat_payer, vat_certificate |
backend/essentials/models/invoice.py |
✅ InvoiceLine: tax_rate FK, price_gross, прецизія; Invoice: total_gross |
backend/essentials/models/goods_receipt.py |
✅ GoodsReceiptLine: tax_rate FK, vat_rate, vat_amount, price_gross |
backend/essentials/models/goods_shipment.py |
✅ GoodsShipmentLine: tax_rate FK, price_gross, прецизія |
backend/essentials/models/__init__.py |
✅ Додано імпорт TaxRate |
backend/essentials/migrations/0005_vat_system.py |
✅ Нова міграція |
backend/essentials/serializers/master_data.py |
✅ TaxRateSerializer |
backend/essentials/serializers/transactions.py |
✅ validate() з is_vat_payer перевіркою |
backend/essentials/serializers/__init__.py |
✅ Реекспорт TaxRateSerializer |
backend/essentials/views/master_data.py |
✅ TaxRateViewSet |
backend/essentials/views/__init__.py |
✅ Реекспорт TaxRateViewSet |
backend/essentials/urls.py |
✅ Зареєстровано /tax-rates/ |
frontend/erp/src/utils/vatUtils.ts |
✅ Новий файл — утиліта розрахунку |
frontend/erp/src/config/essentials.ts |
✅ Пункт меню "Ставки ПДВ" |