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

Облік ПДВ (VAT Accounting) — Модуль Essentials

Зміст

  1. Огляд та бізнес-логіка
  2. Архітектура
  3. Backend — Django моделі
  4. Backend — Серіалізатори
  5. Backend — Views та API
  6. Міграція бази даних
  7. Frontend — TypeScript утиліта
  8. Frontend — Конфігурація навігації
  9. Стратегія для існуючих даних
  10. Початкове наповнення даними
  11. Тестові сценарії

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 = Falsevat_rate примусово 0 на всіх рядках
  • Якщо TaxRate.is_nontaxable = Truevat_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

Стратегія безпеки існуючих даних

  • Всі нові DecimalFielddefault=0 (існуючі рядки отримають 0, без помилок)
  • tax_rate FK → null=True (існуючі рядки мають NULL)
  • Organization.is_vat_payerdefault=True (існуючі організації вважаються платниками ПДВ)
  • Organization.tax_systemdefault='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 ✅ Пункт меню "Ставки ПДВ"