{{ entry.seller_name[:40] }}{% if entry.seller_name|length > 40 %}...{% endif %}
{{ entry.seller_inn or '' }}
{{ "{:,.2f}".format(entry.total_with_vat) }}
{{ "{:,.2f}".format(entry.vat_deductible) }}
{% if entry.is_deducted %}
Принят
{% else %}
Ожидает
{% endif %}
{% if not entry.is_deducted and can_deduct %}
{% endif %}
{% else %}
{% if not can_deduct %}
Книга покупок не ведётся при ставках 5%/7% (нет права на вычет)
{% else %}
Нет записей за {{ quarter }} квартал {{ year }} г.
{% endif %}
{% endfor %}
{% endblock %}
```
### 3.6. Хук для входящих УПД
```python
# Добавить в обработчик проведения входящего УПД
# в app/views/documents_extended.py
from app.services.purchase_book_service import PurchaseBookService
async def on_incoming_document_issued(db, document):
"""Хук проведения входящего документа"""
if document.document_type == 'UPD_IN':
# Попытка создать запись в книге покупок (если 22%)
await PurchaseBookService.create_entry_from_document(db, document)
# Проводка оприходования (Дт 41/10/20 Кт 60) — без НДС
# НДС выделен в отдельную проводку (Дт 19 Кт 60) внутри сервиса
```
---
## 4. Фаза 3 — Расчёт НДС {#first3}
### 4.1. `VATCalculationService`
```python
# app/services/vat_calculation_service.py
"""
Сервис расчёта НДС к уплате.
Формула:
НДС к уплате = Исходящий НДС (Кт 68.02) − Входящий НДС (Дт 68.02)
Исходящий НДС = итого из книги продаж за квартал
Входящий НДС = итого принятых вычетов из книги покупок за квартал
При ставках 5%/7% входящий НДС = 0 (нет вычетов).
"""
from decimal import Decimal
from datetime import date
from typing import Dict
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.sales_book_service import SalesBookService
from app.services.purchase_book_service import PurchaseBookService
from app.services.vat_rate_period_service import VATRatePeriodService
class VATCalculationService:
"""Расчёт НДС к уплате за квартал"""
@staticmethod
async def calculate_quarter(
db: AsyncSession,
organization_id: int,
year: int,
quarter: int
) -> Dict:
"""
Рассчитать НДС к уплате за квартал.
Returns:
{
'year': int,
'quarter': int,
'vat_rate_code': str, # Текущая ставка
'can_deduct': bool, # Есть ли право на вычет
# Исходящий НДС (книга продаж)
'output_vat_22': Decimal,
'output_vat_10': Decimal,
'output_vat_5': Decimal,
'output_vat_7': Decimal,
'output_vat_total': Decimal, # Итого исходящий НДС
# Входящий НДС (книга покупок, только при 22%)
'input_vat_deducted': Decimal, # Принятый к вычету
# Итого
'vat_payable': Decimal, # К уплате (положительное) или к возмещению (отрицательное)
'monthly_payment': Decimal, # Ежемесячный платёж (1/3)
# Сроки уплаты
'payment_dates': list, # 3 даты уплаты (28-е число)
# Доп. информация
'sales_entries_count': int,
'purchase_entries_count': int,
}
"""
# Определяем ставку
period = await VATRatePeriodService.get_current_period(
db, organization_id, date(year, (quarter - 1) * 3 + 1, 1)
)
vat_rate_code = period.vat_rate_code if period else 'none'
can_deduct = vat_rate_code in ('20', '22')
# Книга продаж
sales_totals = await SalesBookService.get_quarter_totals(
db, organization_id, year, quarter
)
# Книга покупок (только при 22%)
purchase_totals = {'vat_deducted': Decimal('0'), 'entries_count': 0}
if can_deduct:
purchase_totals = await PurchaseBookService.get_quarter_totals(
db, organization_id, year, quarter
)
output_vat_total = (
sales_totals['vat_amount_22'] +
sales_totals['vat_amount_10'] +
sales_totals['vat_amount_5'] +
sales_totals['vat_amount_7']
)
input_vat = purchase_totals.get('vat_deducted', Decimal('0'))
vat_payable = output_vat_total - input_vat
# Ежемесячный платёж (1/3, округление вверх до копейки)
if vat_payable > 0:
monthly = (vat_payable / 3).quantize(Decimal('0.01'))
# Первые два — по 1/3, третий — остаток
monthly_1 = monthly
monthly_2 = monthly
monthly_3 = vat_payable - monthly_1 - monthly_2
else:
monthly_1 = monthly_2 = monthly_3 = Decimal('0')
# Сроки уплаты (28-е число следующих 3 месяцев после квартала)
payment_dates = VATCalculationService._payment_dates(year, quarter)
return {
'year': year,
'quarter': quarter,
'vat_rate_code': vat_rate_code,
'can_deduct': can_deduct,
'output_vat_22': sales_totals['vat_amount_22'],
'output_vat_10': sales_totals['vat_amount_10'],
'output_vat_5': sales_totals['vat_amount_5'],
'output_vat_7': sales_totals['vat_amount_7'],
'output_vat_total': output_vat_total,
'input_vat_deducted': input_vat,
'vat_payable': vat_payable,
'monthly_payment_1': monthly_1,
'monthly_payment_2': monthly_2,
'monthly_payment_3': monthly_3,
'payment_dates': payment_dates,
'sales_entries_count': sales_totals['entries_count'],
'purchase_entries_count': purchase_totals.get('entries_count', 0),
}
@staticmethod
def _payment_dates(year: int, quarter: int):
"""
Сроки уплаты НДС — 28-е число каждого из 3 месяцев
следующего квартала.
Пример: Q1 2026 → 28.04.2026, 28.05.2026, 28.06.2026
"""
from datetime import date as d
base_month = quarter * 3 + 1 # Первый месяц следующего квартала
base_year = year
dates = []
for i in range(3):
m = base_month + i
y = base_year
if m > 12:
m -= 12
y += 1
dates.append(d(y, m, 28))
return dates
@staticmethod
async def calculate_year(
db: AsyncSession,
organization_id: int,
year: int
) -> Dict:
"""
Расчёт НДС за год (все 4 квартала).
Returns:
{
'year': int,
'quarters': [Q1_result, Q2_result, Q3_result, Q4_result],
'annual_output_vat': Decimal,
'annual_input_vat': Decimal,
'annual_vat_payable': Decimal,
}
"""
quarters = []
annual_output = Decimal('0')
annual_input = Decimal('0')
annual_payable = Decimal('0')
for q in range(1, 5):
result = await VATCalculationService.calculate_quarter(
db, organization_id, year, q
)
quarters.append(result)
annual_output += result['output_vat_total']
annual_input += result['input_vat_deducted']
annual_payable += result['vat_payable']
return {
'year': year,
'quarters': quarters,
'annual_output_vat': annual_output,
'annual_input_vat': annual_input,
'annual_vat_payable': annual_payable,
}
```
### 4.2. Страница «НДС» в интерфейсе
```python
# Добавить в app/views/vat.py
from app.services.vat_calculation_service import VATCalculationService
@vat_bp.route("/")
@module_permission_required("taxes", "view")
@organization_required
async def vat_overview():
"""Главная страница НДС — обзор за квартал"""
user_id = session.get("user_id")
current_org = await get_current_organization(user_id)
if not current_org:
return redirect(url_for("organizations.create"))
# Если организация не плательщик НДС — уведомление
if not current_org.is_vat_payer:
return await render_template(
"vat/not_payer.html",
organization=current_org,
)
now = date.today()
year = int(request.args.get("year", now.year))
quarter = int(request.args.get("quarter", (now.month - 1) // 3 + 1))
async with AsyncSessionLocal() as db:
calc = await VATCalculationService.calculate_quarter(
db, current_org.id, year, quarter
)
return await render_template(
"vat/overview.html",
calc=calc,
year=year,
quarter=quarter,
organization=current_org,
)
@vat_bp.route("/annual")
@module_permission_required("taxes", "view")
@organization_required
async def vat_annual():
"""Годовой обзор НДС"""
user_id = session.get("user_id")
current_org = await get_current_organization(user_id)
if not current_org:
return redirect(url_for("organizations.create"))
year = int(request.args.get("year", date.today().year))
async with AsyncSessionLocal() as db:
annual = await VATCalculationService.calculate_year(
db, current_org.id, year
)
return await render_template(
"vat/annual.html",
annual=annual,
year=year,
organization=current_org,
)
```
### 4.3. Шаблон обзора НДС
```html
{# app/templates/vat/overview.html #}
{% extends "base.html" %}
{% block title %}НДС — {{ quarter }} кв. {{ year }}{% endblock %}
{% block content %}
{# Заголовок #}
НДС
{{ organization.short_name }} — {{ quarter }} квартал {{ year }} г.
Ставка {{ calc.vat_rate_code }}%
{% if not calc.can_deduct %} (без вычета){% endif %}
{# Главная карточка — НДС к уплате #}
НДС к уплате за квартал
{{ "{:,.2f}".format(calc.vat_payable) }} ₽
{% if calc.vat_payable < 0 %}
К возмещению из бюджета
{% endif %}
{# Разбивка #}
Исходящий НДС
{{ "{:,.2f}".format(calc.output_vat_total) }} ₽
{{ calc.sales_entries_count }} записей в книге продаж
−
Входящий НДС (вычеты)
{{ "{:,.2f}".format(calc.input_vat_deducted) }} ₽
{% if not calc.can_deduct %}
❌ Вычет недоступен при {{ calc.vat_rate_code }}%
{% else %}
{{ calc.purchase_entries_count }} записей в книге покупок
{% endblock %}
```
---
## 5. Фаза 4 — Декларация НДС {#first4}
### 5.1. Структура XML декларации
Декларация по НДС (КНД 1151001) — **обязательно электронная** подача по ТКС.
**Разделы, необходимые для УСН-плательщиков:**
| Раздел | Название | Описание |
|--------|----------|----------|
| 1 | Сумма налога к уплате | Итоговая сумма НДС к уплате / возмещению |
| 3 | Расчёт суммы налога | Налоговая база и НДС по ставкам |
| 8 | Сведения из книги покупок | Построчно из книги покупок (только при 22%) |
| 9 | Сведения из книги продаж | Построчно из книги продаж |
> **Разделы 2, 4–7, 10–12** — для налоговых агентов, экспортёров, посредников. Не нужны на начальном этапе.
### 5.2. Генератор XML
```python
# app/utils/xml/nds_declaration_xml_generator.py
"""
Генератор XML декларации по НДС (КНД 1151001).
Формат: NO_NDS версия 5.08 (актуальная на 2025-2026)
Кодировка: windows-1251
Файл: NO_NDS.A_K_O_GGGGMMDD_N.xml
Применимые разделы:
- Раздел 1: Сумма налога к уплате
- Раздел 3: Расчёт суммы налога
- Раздел 8: Сведения из книги покупок
- Раздел 9: Сведения из книги продаж
Основание: Приказ ФНС от 29.10.2014 № ММВ-7-3/558@ (с изменениями)
"""
from xml.etree.ElementTree import Element, SubElement, tostring
from xml.dom import minidom
from datetime import date
from decimal import Decimal
from typing import Dict, List, Optional
from io import BytesIO
import uuid
class NDSDeclarationXMLGenerator:
"""Генератор XML декларации НДС"""
VERSION_FORMAT = "5.08"
VERSION_PROG = "Конто 1.0"
KND = "1151001"
@staticmethod
def generate(
inn: str,
kpp: Optional[str],
organization_name: str,
oktmo: str,
tax_office_code: str,
year: int,
quarter: int,
is_ip: bool,
signatory: Dict, # {'surname': '', 'name': '', 'patronymic': ''}
vat_rate_code: str, # '5', '7', '22'
calculation: Dict, # Результат VATCalculationService.calculate_quarter
sales_entries: List, # SalesBookEntry[]
purchase_entries: List, # PurchaseBookEntry[] (может быть пустым)
correction_number: int = 0,
phone: Optional[str] = None,
) -> BytesIO:
"""
Сгенерировать XML файл декларации НДС.
Returns:
BytesIO с XML-содержимым (кодировка windows-1251)
"""
# Корневой элемент
root = Element('Файл')
root.set('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance')
# Атрибуты файла
file_id = f"NO_NDS.A_{tax_office_code}_{inn}{'_' + kpp if kpp else ''}_{date.today().strftime('%Y%m%d')}_{uuid.uuid4().hex[:36]}"
root.set('ИдФайл', file_id)
root.set('ВерсФорм', NDSDeclarationXMLGenerator.VERSION_FORMAT)
root.set('ВерсПрог', NDSDeclarationXMLGenerator.VERSION_PROG)
# Документ
doc = SubElement(root, 'Документ')
doc.set('КНД', NDSDeclarationXMLGenerator.KND)
# Период: квартал
period_code = str(20 + quarter) # 21=Q1, 22=Q2, 23=Q3, 24=Q4
doc.set('Период', period_code)
doc.set('ОтчГод', str(year))
doc.set('КорНомер', str(correction_number))
doc.set('КодНО', tax_office_code)
# Сведения о налогоплательщике
sv_np = SubElement(doc, 'СвНП')
sv_np.set('ОКТМО', oktmo)
if phone:
sv_np.set('Тлф', phone)
if is_ip:
np_fl = SubElement(sv_np, 'НПФЛ')
np_fl.set('ИННФЛ', inn)
fio = SubElement(np_fl, 'ФИО')
fio.set('Фамилия', signatory.get('surname', ''))
fio.set('Имя', signatory.get('name', ''))
if signatory.get('patronymic'):
fio.set('Отчество', signatory['patronymic'])
else:
np_ul = SubElement(sv_np, 'НПЮЛ')
np_ul.set('НаимОрг', organization_name)
np_ul.set('ИННЮЛ', inn)
np_ul.set('КПП', kpp or '')
# Подписант
sign = SubElement(doc, 'Подписант')
sign.set('ПрПодworthy', '1') # 1 = руководитель
fio_sign = SubElement(sign, 'ФИО')
fio_sign.set('Фамилия', signatory.get('surname', ''))
fio_sign.set('Имя', signatory.get('name', ''))
if signatory.get('patronymic'):
fio_sign.set('Отчество', signatory['patronymic'])
# === Раздел 1: Сумма налога к уплате ===
section1 = SubElement(doc, 'НДС')
r1 = SubElement(section1, 'СумУплНП')
r1.set('ОКТМО', oktmo)
r1.set('КБК', '18210301000011000110') # КБК НДС
vat_payable = calculation.get('vat_payable', Decimal('0'))
if vat_payable >= 0:
r1.set('НалУпл', str(int(vat_payable)))
else:
r1.set('НалВозworthy', str(int(abs(vat_payable))))
# === Раздел 3: Расчёт суммы налога ===
r3 = SubElement(section1, 'РасчНалworthy')
# Налоговая база и НДС по ставкам
# Ставка 22%
if calculation.get('output_vat_22', Decimal('0')) > 0:
row_22 = SubElement(r3, 'НалБаworthy22')
base_22 = calculation['output_vat_22'] * Decimal('100') / Decimal('22')
row_22.set('НалБаза', str(int(base_22)))
row_22.set('НалИсworthy', str(int(calculation['output_vat_22'])))
# Ставка 10%
if calculation.get('output_vat_10', Decimal('0')) > 0:
row_10 = SubElement(r3, 'НалБаworthy10')
base_10 = calculation['output_vat_10'] * Decimal('100') / Decimal('10')
row_10.set('НалБаза', str(int(base_10)))
row_10.set('НалИсworthy', str(int(calculation['output_vat_10'])))
# Ставка 5%
if calculation.get('output_vat_5', Decimal('0')) > 0:
row_5 = SubElement(r3, 'НалБаworthy5')
base_5 = calculation['output_vat_5'] * Decimal('100') / Decimal('5')
row_5.set('НалБаза', str(int(base_5)))
row_5.set('НалИсworthy', str(int(calculation['output_vat_5'])))
# Ставка 7%
if calculation.get('output_vat_7', Decimal('0')) > 0:
row_7 = SubElement(r3, 'НалБаworthy7')
base_7 = calculation['output_vat_7'] * Decimal('100') / Decimal('7')
row_7.set('НалБаза', str(int(base_7)))
row_7.set('НалИсworthy', str(int(calculation['output_vat_7'])))
# Итого начислено
total_base = SubElement(r3, 'ВсегоИсworthy')
total_base.set('СумНал', str(int(calculation.get('output_vat_total', 0))))
# Вычеты (Раздел 3, строка 120-210)
input_vat = calculation.get('input_vat_deducted', Decimal('0'))
if input_vat > 0:
deductions = SubElement(r3, 'ВычНДС')
deductions.set('СумНалВычет', str(int(input_vat)))
# Итого к уплате / возмещению
r3_total = SubElement(r3, 'ИтогНДС')
if vat_payable >= 0:
r3_total.set('НалУпл', str(int(vat_payable)))
else:
r3_total.set('НалВозworthy', str(int(abs(vat_payable))))
# === Раздел 9: Сведения из книги продаж ===
if sales_entries:
r9 = SubElement(section1, 'КнПрод')
r9.set('КолСтр', str(len(sales_entries)))
for entry in sales_entries:
row = SubElement(r9, 'КнПродСтр')
row.set('НомерПор', str(entry.sequence_number))
row.set('КодВидОпер', entry.operation_type_code)
row.set('НомСчФПрод', entry.invoice_number)
row.set('ДатаСчФПрод', entry.invoice_date.strftime('%d.%m.%Y'))
# Покупатель
row.set('НаимПок', entry.buyer_name or '')
row.set('ИННПок', entry.buyer_inn or '')
if entry.buyer_kpp:
row.set('КПППок', entry.buyer_kpp)
# Суммы
total = (
(entry.total_with_vat_22 or Decimal('0')) +
(entry.total_with_vat_10 or Decimal('0')) +
(entry.total_with_vat_5 or Decimal('0')) +
(entry.total_with_vat_7 or Decimal('0'))
)
row.set('СтоимПродСНДС', str(int(total)))
vat_total = (
(entry.vat_amount_22 or Decimal('0')) +
(entry.vat_amount_10 or Decimal('0')) +
(entry.vat_amount_5 or Decimal('0')) +
(entry.vat_amount_7 or Decimal('0'))
)
row.set('СумНДС', str(int(vat_total)))
# Итого по разделу 9
r9_total = SubElement(r9, 'КнПродИтг')
r9_total.set('ВсегоСтоимПродСНДС', str(int(
sum((e.total_with_vat_22 or 0) + (e.total_with_vat_10 or 0) +
(e.total_with_vat_5 or 0) + (e.total_with_vat_7 or 0)
for e in sales_entries)
)))
r9_total.set('ВсегоНДС', str(int(
sum((e.vat_amount_22 or 0) + (e.vat_amount_10 or 0) +
(e.vat_amount_5 or 0) + (e.vat_amount_7 or 0)
for e in sales_entries)
)))
# === Раздел 8: Сведения из книги покупок ===
if purchase_entries:
r8 = SubElement(section1, 'КнПок')
r8.set('КолСтр', str(len(purchase_entries)))
for entry in purchase_entries:
row = SubElement(r8, 'КнПокСтр')
row.set('НомерПор', str(entry.sequence_number))
row.set('КодВидОпер', entry.operation_type_code)
row.set('НомСчФПрод', entry.invoice_number)
row.set('ДатаСчФПрод', entry.invoice_date.strftime('%d.%m.%Y'))
# Продавец
row.set('НаимПрод', entry.seller_name or '')
row.set('ИННПрод', entry.seller_inn or '')
if entry.seller_kpp:
row.set('КПППрод', entry.seller_kpp)
# Суммы
row.set('СтоимПокСНДС', str(int(entry.total_with_vat or 0)))
row.set('СумНДС', str(int(entry.vat_deductible or 0)))
# Итого по разделу 8
r8_total = SubElement(r8, 'КнПокИтг')
r8_total.set('ВсегоСтоимПокСНДС', str(int(
sum(e.total_with_vat or 0 for e in purchase_entries)
)))
r8_total.set('ВсегоНДС', str(int(
sum(e.vat_deductible or 0 for e in purchase_entries)
)))
# Формируем XML
xml_str = tostring(root, encoding='unicode')
dom = minidom.parseString(xml_str)
pretty_xml = dom.toprettyxml(indent=' ', encoding='windows-1251')
# Убираем двойное объявление xml
lines = pretty_xml.decode('windows-1251').split('\n')
result_xml = '\n'.join(lines[1:]) # Убираем от minidom
final_xml = '\n' + result_xml
buffer = BytesIO()
buffer.write(final_xml.encode('windows-1251'))
buffer.seek(0)
return buffer
@staticmethod
def generate_filename(
inn: str,
kpp: Optional[str],
tax_office_code: str,
) -> str:
"""Сформировать имя файла по требованиям ФНС"""
today = date.today().strftime('%Y%m%d')
guid = uuid.uuid4().hex[:36]
if kpp:
return f"NO_NDS.A_{tax_office_code}_{inn}_{kpp}_{today}_{guid}.xml"
else:
return f"NO_NDS.A_{tax_office_code}_{inn}_{today}_{guid}.xml"
```
> **Примечание:** Это упрощённая версия генератора. Для production необходимо:
> 1. Скачать актуальную XSD-схему с https://format.nalog.ru/ (раздел NO_NDS)
> 2. Привести имена XML-элементов в точное соответствие с XSD
> 3. Добавить валидацию XML по XSD перед отправкой
> 4. Тестировать через программу «Тестер» ФНС
### 5.3. Эндпоинт генерации декларации
```python
# Добавить в app/views/vat.py
from app.utils.xml.nds_declaration_xml_generator import NDSDeclarationXMLGenerator
@vat_bp.route("/declaration")
@module_permission_required("taxes", "view")
@organization_required
async def declaration():
"""Страница декларации по НДС"""
user_id = session.get("user_id")
current_org = await get_current_organization(user_id)
if not current_org:
return redirect(url_for("organizations.create"))
now = date.today()
year = int(request.args.get("year", now.year))
quarter = int(request.args.get("quarter", (now.month - 1) // 3 + 1))
async with AsyncSessionLocal() as db:
calc = await VATCalculationService.calculate_quarter(
db, current_org.id, year, quarter
)
sales_entries = await SalesBookService.get_entries(db, current_org.id, year, quarter)
purchase_entries = []
if calc['can_deduct']:
purchase_entries = await PurchaseBookService.get_entries(db, current_org.id, year, quarter)
return await render_template(
"vat/declaration.html",
calc=calc,
year=year,
quarter=quarter,
organization=current_org,
sales_count=len(sales_entries),
purchase_count=len(purchase_entries),
)
@vat_bp.route("/declaration/xml")
@module_permission_required("taxes", "edit")
@organization_required
async def declaration_xml():
"""Скачать XML файл декларации НДС"""
user_id = session.get("user_id")
current_org = await get_current_organization(user_id)
if not current_org:
return redirect(url_for("organizations.create"))
year = int(request.args.get("year", date.today().year))
quarter = int(request.args.get("quarter", 1))
async with AsyncSessionLocal() as db:
calc = await VATCalculationService.calculate_quarter(
db, current_org.id, year, quarter
)
sales_entries = await SalesBookService.get_entries(db, current_org.id, year, quarter)
purchase_entries = []
if calc['can_deduct']:
purchase_entries = await PurchaseBookService.get_entries(db, current_org.id, year, quarter)
# Парсим ФИО руководителя
leader_parts = (current_org.leader_name or '').split()
signatory = {
'surname': leader_parts[0] if len(leader_parts) > 0 else '',
'name': leader_parts[1] if len(leader_parts) > 1 else '',
'patronymic': leader_parts[2] if len(leader_parts) > 2 else '',
}
is_ip = str(current_org.organization_type) == 'OrganizationType.IP'
tax_office_code = (current_org.inn or '0000')[:4]
xml_buffer = NDSDeclarationXMLGenerator.generate(
inn=current_org.inn,
kpp=current_org.kpp,
organization_name=current_org.full_name,
oktmo=current_org.oktmo or '00000000',
tax_office_code=tax_office_code,
year=year,
quarter=quarter,
is_ip=is_ip,
signatory=signatory,
vat_rate_code=calc['vat_rate_code'],
calculation=calc,
sales_entries=sales_entries,
purchase_entries=purchase_entries,
phone=current_org.phone,
)
filename = NDSDeclarationXMLGenerator.generate_filename(
inn=current_org.inn,
kpp=current_org.kpp,
tax_office_code=tax_office_code,
)
return Response(
xml_buffer.getvalue(),
mimetype='application/xml',
headers={
'Content-Disposition': f'attachment; filename="{filename}"'
}
)
```
### 5.4. Интеграция с Астрал.Отчётность
Для отправки декларации по НДС через Астрал API используем существующую инфраструктуру (`AstralReport`):
```python
# app/services/nds_report_service.py
"""
Сервис отправки декларации НДС через Астрал.Отчётность.
Использует существующую интеграцию с Астрал.Платформа API.
Декларация НДС обязательно сдаётся в электронном виде по ТКС.
"""
from datetime import date
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.astral_reports import AstralReport, ReportType, ReportDirection, ReportStatus
from app.utils.xml.nds_declaration_xml_generator import NDSDeclarationXMLGenerator
class NDSReportService:
"""Сервис отправки декларации НДС"""
@staticmethod
async def create_nds_report(
db: AsyncSession,
organization_id: int,
xml_content: bytes,
filename: str,
year: int,
quarter: int,
correction_number: int = 0
) -> AstralReport:
"""
Создать запись отчёта НДС для отправки через Астрал.
Args:
organization_id: ID организации
xml_content: Содержимое XML файла
filename: Имя файла
year: Отчётный год
quarter: Отчётный квартал
Returns:
AstralReport
"""
period_code = f"Q{quarter}-{year}"
report = AstralReport(
organization_id=organization_id,
report_type=ReportType.NDS_DECLARATION,
direction=ReportDirection.FNS,
status=ReportStatus.DRAFT,
period=period_code,
year=year,
correction_number=correction_number,
xml_content=xml_content,
filename=filename,
)
db.add(report)
await db.flush()
return report
@staticmethod
async def send_report(
db: AsyncSession,
report_id: int,
organization_id: int
) -> bool:
"""
Отправить декларацию НДС через Астрал API.
Требует наличие подписки ЭДО и сертификата КЭП.
"""
# TODO: Интеграция с Астрал.Платформа API
# 1. Получить сертификат КЭП из EdoSubscriber
# 2. Подписать XML
# 3. Отправить через API Астрал
# 4. Обновить статус отчёта
raise NotImplementedError(
"Отправка через Астрал API будет реализована после получения "
"тестового доступа к среде Астрал.Отчётность"
)
```
---
## 6. Фаза 5 — Особенности для 5%/7% {#first5}
### 6.1. Принципиальные отличия
При ставках 5% и 7% действует **упрощённый режим НДС** (ст. 164 п. 8-9, ст. 171.1 НК РФ):
| Аспект | 5%/7% (упрощённый) | 22% (общий) |
|--------|--------------------|-------------|
| Единая ставка на все товары | ✅ Да | ❌ Нет (22%, 10%, 0%) |
| Книга продаж | ✅ Ведётся | ✅ Ведётся |
| Книга покупок | ❌ Не ведётся | ✅ Ведётся |
| Входящий НДС (вычет) | ❌ Запрещён | ✅ Разрешён |
| Счёт 19 | ❌ Не используется | ✅ Используется |
| Декларация НДС | ✅ Упрощённая (р.1,3,9) | ✅ Полная (р.1,3,8,9) |
| Льготные ставки (10%, 0%) | ❌ Не применяются | ✅ Применяются |
### 6.2. Логика определения режима
```python
# app/services/vat_mode_service.py
"""
Сервис определения режима НДС организации.
Центральная точка для определения доступных функций НДС
в зависимости от ставки организации.
"""
from dataclasses import dataclass
from typing import Optional
from datetime import date
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.vat_rate_period_service import VATRatePeriodService
@dataclass
class VATMode:
"""Режим НДС организации"""
is_vat_payer: bool # Является плательщиком НДС
rate_code: str # Код ставки: 'none', '5', '7', '22'
rate_percent: Optional[int] # Числовая ставка (5, 7, 22) или None
can_deduct: bool # Право на вычет входящего НДС
use_purchase_book: bool # Ведётся ли книга покупок
use_account_19: bool # Используется ли счёт 19
uniform_rate: bool # Единая ставка на все товары
declaration_sections: list # Разделы декларации
@property
def label(self) -> str:
if not self.is_vat_payer:
return 'Без НДС'
return f'НДС {self.rate_code}%'
class VATModeService:
"""Определение режима НДС"""
@staticmethod
async def get_mode(
db: AsyncSession,
organization_id: int,
target_date: Optional[date] = None
) -> VATMode:
"""
Определить режим НДС организации на дату.
Returns:
VATMode с полным описанием доступных функций
"""
period = await VATRatePeriodService.get_current_period(
db, organization_id, target_date
)
if not period or period.vat_rate_code == 'none':
return VATMode(
is_vat_payer=False,
rate_code='none',
rate_percent=None,
can_deduct=False,
use_purchase_book=False,
use_account_19=False,
uniform_rate=False,
declaration_sections=[],
)
rate_code = period.vat_rate_code
if rate_code in ('5', '7'):
return VATMode(
is_vat_payer=True,
rate_code=rate_code,
rate_percent=int(rate_code),
can_deduct=False,
use_purchase_book=False,
use_account_19=False,
uniform_rate=True, # Единая ставка на всё
declaration_sections=[1, 3, 9], # Без раздела 8 (нет книги покупок)
)
# 20% или 22% — общий режим
return VATMode(
is_vat_payer=True,
rate_code=rate_code,
rate_percent=int(rate_code),
can_deduct=True,
use_purchase_book=True,
use_account_19=True,
uniform_rate=False, # Могут быть разные ставки (22%, 10%, 0%)
declaration_sections=[1, 3, 8, 9],
)
```
### 6.3. Обработка при 5%/7% в документах
При создании позиций исходящего документа (УПД), если организация на 5%/7%:
```python
# Модификация в app/utils/vat_utils.py
def resolve_item_vat_rate(
org_vat_rate_code: str,
nomenclature_vat_exemption: Optional[Decimal]
) -> Optional[Decimal]:
"""
Определить ставку НДС для позиции документа.
При ставках 5%/7%:
- ВСЕГДА применяется единая ставка организации
- Льготы номенклатуры ИГНОРИРУЮТСЯ
- Все товары/услуги облагаются по единой ставке
При ставке 22%:
- Льготы номенклатуры применяются (10%, 0%, без НДС)
- Без льготы → ставка 22%
"""
if org_vat_rate_code == 'none':
return None
if org_vat_rate_code in ('5', '7'):
# ЕДИНАЯ СТАВКА — льготы не применяются
return Decimal(org_vat_rate_code)
# Режим 22% (или 20%) — льготы применяются
if nomenclature_vat_exemption is None:
return Decimal('22') if org_vat_rate_code == '22' else Decimal('20')
if nomenclature_vat_exemption == Decimal('-1'):
return None # Без НДС
return nomenclature_vat_exemption # 10 или 0
```
### 6.4. Проводки при 5%/7%
При ставках 5%/7% НДС включается в себестоимость (счёт 19 не используется):
```python
# Проводки при ПРОДАЖЕ (5%/7%):
# Дт 62 Кт 90.01 — Выручка с НДС (total_amount)
# Дт 90.03 Кт 68.02 — Начислен НДС (vat_amount)
# Проводки при ПОКУПКЕ (5%/7%):
# Дт 41 Кт 60 — Оприходование с НДС (total_amount, включая НДС поставщика!)
# Входящий НДС НЕ выделяется на счёт 19
# НДС поставщика включается в себестоимость товара
# Пример:
# Организация на 5%. Купила товар за 120,000 (в т.ч. НДС 22% = 20,000)
# Проводка: Дт 41 Кт 60 — 120,000 руб. (НДС поставщика включён в себестоимость)
#
# Продала товар за 200,000 + НДС 5% = 210,000
# Проводки:
# Дт 62 Кт 90.01 — 210,000 (выручка с НДС)
# Дт 90.03 Кт 68.02 — 10,000 (НДС 5% от 200,000)
```
### 6.5. Предупреждения в интерфейсе
```html
{# Компонент предупреждения для страниц НДС при 5%/7% #}
{% macro vat_no_deduction_warning(vat_mode) %}
{% if vat_mode.is_vat_payer and not vat_mode.can_deduct %}
Ставка {{ vat_mode.rate_code }}% — без права на вычет
При ставке НДС {{ vat_mode.rate_code }}% входящий НДС от поставщиков не принимается к вычету
(ст. 171.1 НК РФ). НДС поставщика включается в себестоимость товаров/услуг.
Книга покупок не ведётся.
{% endif %}
{% endmacro %}
```
---
## 7. Миграции Alembic
### 7.1. Миграция: создание таблиц книг НДС
```python
# alembic/versions/xxx_create_vat_books.py
"""
Создание таблиц книги продаж и книги покупок.
Revision ID: create_vat_books
Create Date: 2025-07-15
"""
from alembic import op
import sqlalchemy as sa
revision = 'create_vat_books'
down_revision = None # Указать реальную предыдущую ревизию
branch_labels = None
depends_on = None
def upgrade() -> None:
# === Книга продаж ===
op.create_table(
'sales_book_entries',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('organization_id', sa.Integer(),
sa.ForeignKey('organizations.id', ondelete='CASCADE'),
nullable=False),
sa.Column('quarter', sa.Integer(), nullable=False,
comment='Квартал (1-4)'),
sa.Column('year', sa.Integer(), nullable=False,
comment='Год'),
sa.Column('sequence_number', sa.Integer(), nullable=False),
# Код вида операции
sa.Column('operation_type_code', sa.String(2), nullable=False,
server_default='01',
comment='01=реализация, 02=аванс, 06=зачёт аванса'),
# Счёт-фактура
sa.Column('invoice_number', sa.String(100), nullable=False),
sa.Column('invoice_date', sa.Date(), nullable=False),
# Исправление / корректировка
sa.Column('correction_number', sa.String(50), nullable=True),
sa.Column('correction_date', sa.Date(), nullable=True),
sa.Column('adjustment_number', sa.String(50), nullable=True),
sa.Column('adjustment_date', sa.Date(), nullable=True),
# Покупатель
sa.Column('buyer_name', sa.String(500), nullable=False),
sa.Column('buyer_inn', sa.String(12), nullable=True),
sa.Column('buyer_kpp', sa.String(9), nullable=True),
# Документ об оплате
sa.Column('payment_document_number', sa.String(100), nullable=True),
sa.Column('payment_document_date', sa.Date(), nullable=True),
# Стоимость по ставкам
sa.Column('total_with_vat_22', sa.Numeric(15, 2), server_default='0'),
sa.Column('total_with_vat_10', sa.Numeric(15, 2), server_default='0'),
sa.Column('total_with_vat_5', sa.Numeric(15, 2), server_default='0'),
sa.Column('total_with_vat_7', sa.Numeric(15, 2), server_default='0'),
sa.Column('total_without_vat', sa.Numeric(15, 2), server_default='0'),
sa.Column('total_zero_rate', sa.Numeric(15, 2), server_default='0'),
# НДС по ставкам
sa.Column('vat_amount_22', sa.Numeric(15, 2), server_default='0'),
sa.Column('vat_amount_10', sa.Numeric(15, 2), server_default='0'),
sa.Column('vat_amount_5', sa.Numeric(15, 2), server_default='0'),
sa.Column('vat_amount_7', sa.Numeric(15, 2), server_default='0'),
# Освобождённые
sa.Column('total_exempt', sa.Numeric(15, 2), server_default='0'),
# Связи
sa.Column('document_id', sa.Integer(),
sa.ForeignKey('documents.id', ondelete='SET NULL'),
nullable=True),
sa.Column('contractor_id', sa.Integer(),
sa.ForeignKey('contractors.id', ondelete='SET NULL'),
nullable=True),
# Служебные
sa.Column('is_auto', sa.Boolean(), server_default='true'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True),
server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
)
op.create_index('idx_sales_book_org_period',
'sales_book_entries',
['organization_id', 'year', 'quarter'])
op.create_index('idx_sales_book_document',
'sales_book_entries',
['document_id'])
op.create_unique_constraint(
'uq_sales_book_org_doc_op',
'sales_book_entries',
['organization_id', 'document_id', 'operation_type_code']
)
# === Книга покупок ===
op.create_table(
'purchase_book_entries',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('organization_id', sa.Integer(),
sa.ForeignKey('organizations.id', ondelete='CASCADE'),
nullable=False),
sa.Column('quarter', sa.Integer(), nullable=False),
sa.Column('year', sa.Integer(), nullable=False),
sa.Column('sequence_number', sa.Integer(), nullable=False),
sa.Column('operation_type_code', sa.String(2), nullable=False,
server_default='01'),
# Счёт-фактура продавца
sa.Column('invoice_number', sa.String(100), nullable=False),
sa.Column('invoice_date', sa.Date(), nullable=False),
# Исправление / корректировка
sa.Column('correction_number', sa.String(50), nullable=True),
sa.Column('correction_date', sa.Date(), nullable=True),
sa.Column('adjustment_number', sa.String(50), nullable=True),
sa.Column('adjustment_date', sa.Date(), nullable=True),
# Документ об оплате
sa.Column('payment_document_number', sa.String(100), nullable=True),
sa.Column('payment_document_date', sa.Date(), nullable=True),
# Дата принятия на учёт
sa.Column('acceptance_date', sa.Date(), nullable=True),
# Продавец
sa.Column('seller_name', sa.String(500), nullable=False),
sa.Column('seller_inn', sa.String(12), nullable=True),
sa.Column('seller_kpp', sa.String(9), nullable=True),
# ГТД
sa.Column('customs_declaration', sa.String(100), nullable=True),
# Суммы
sa.Column('total_with_vat', sa.Numeric(15, 2), nullable=False,
server_default='0'),
sa.Column('vat_deductible', sa.Numeric(15, 2), nullable=False,
server_default='0'),
# Связи
sa.Column('document_id', sa.Integer(),
sa.ForeignKey('documents.id', ondelete='SET NULL'),
nullable=True),
sa.Column('contractor_id', sa.Integer(),
sa.ForeignKey('contractors.id', ondelete='SET NULL'),
nullable=True),
# Статус вычета
sa.Column('is_deducted', sa.Boolean(), server_default='false'),
sa.Column('deduction_date', sa.Date(), nullable=True),
# Служебные
sa.Column('is_auto', sa.Boolean(), server_default='true'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True),
server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
)
op.create_index('idx_purchase_book_org_period',
'purchase_book_entries',
['organization_id', 'year', 'quarter'])
op.create_index('idx_purchase_book_document',
'purchase_book_entries',
['document_id'])
op.create_unique_constraint(
'uq_purchase_book_org_doc_op',
'purchase_book_entries',
['organization_id', 'document_id', 'operation_type_code']
)
def downgrade() -> None:
op.drop_table('purchase_book_entries')
op.drop_table('sales_book_entries')
```
### 7.2. Миграция: добавление счёта 19 в план счетов
```python
# alembic/versions/xxx_add_account_19.py
"""
Добавление счёта 19 (НДС по приобретённым ценностям) и субсчетов.
Revision ID: add_account_19
"""
from alembic import op
import sqlalchemy as sa
revision = 'add_account_19'
down_revision = 'create_vat_books' # Указать реальную предыдущую ревизию
def upgrade() -> None:
# Используем raw SQL для INSERT с ON CONFLICT
op.execute("""
INSERT INTO chart_of_accounts
(account_code, parent_code, account_name, account_type, description, is_active, is_synthetic)
VALUES
('19', NULL,
'НДС по приобретённым ценностям',
'active',
'Обобщение информации об уплаченных (причитающихся к уплате) суммах НДС по приобретённым ценностям. Дебетовое сальдо — НДС, ещё не принятый к вычету.',
true, true),
('19.01', '19',
'НДС при приобретении ОС',
'active',
'НДС по приобретённым основным средствам',
true, false),
('19.02', '19',
'НДС по приобретённым НМА',
'active',
'НДС по приобретённым нематериальным активам',
true, false),
('19.03', '19',
'НДС по приобретённым МПЗ',
'active',
'НДС по приобретённым материально-производственным запасам, работам, услугам',
true, false)
ON CONFLICT (account_code) DO NOTHING;
""")
def downgrade() -> None:
op.execute("""
DELETE FROM chart_of_accounts
WHERE account_code IN ('19', '19.01', '19.02', '19.03');
""")
```
---
## 8. Тесты
### 8.1. Тесты книги продаж
```python
# tests/test_vat/test_sales_book.py
"""
Тесты книги продаж.
"""
import pytest
from decimal import Decimal
from datetime import date
from app.models.documents import Document
from app.models.document_items import DocumentItem
from app.models.vat_books import SalesBookEntry
from app.services.sales_book_service import SalesBookService
@pytest.fixture
def sample_upd_out(db_session, organization):
"""Создаём тестовый исходящий УПД"""
doc = Document(
organization_id=organization.id,
document_type='UPD_OUT',
document_number='УПД-001',
document_date=date(2026, 2, 15),
contractor_id=1,
amount=Decimal('100000.00'),
vat_amount=Decimal('5000.00'),
total_amount=Decimal('105000.00'),
status='issued',
)
db_session.add(doc)
db_session.flush()
item = DocumentItem(
document_id=doc.id,
sequence=1,
name='Консультационные услуги',
unit='шт',
quantity=Decimal('1'),
price=Decimal('100000.00'),
amount=Decimal('100000.00'),
vat_rate=Decimal('5'),
vat_amount=Decimal('5000.00'),
total_amount=Decimal('105000.00'),
)
db_session.add(item)
db_session.flush()
return doc
@pytest.mark.asyncio
async def test_create_sales_entry_from_upd(db_session, sample_upd_out, contractor):
"""Тест создания записи книги продаж из УПД"""
entry = await SalesBookService.create_entry_from_document(
db_session, sample_upd_out, contractor
)
assert entry is not None
assert entry.invoice_number == 'УПД-001'
assert entry.quarter == 1 # Февраль → Q1
assert entry.year == 2026
assert entry.vat_amount_5 == Decimal('5000.00')
assert entry.total_with_vat_5 == Decimal('105000.00')
assert entry.total_vat == Decimal('5000.00')
@pytest.mark.asyncio
async def test_no_duplicate_sales_entry(db_session, sample_upd_out, contractor):
"""Тест защиты от дублирования"""
entry1 = await SalesBookService.create_entry_from_document(
db_session, sample_upd_out, contractor
)
entry2 = await SalesBookService.create_entry_from_document(
db_session, sample_upd_out, contractor
)
assert entry1 is not None
assert entry2 is None # Дубль не создан
@pytest.mark.asyncio
async def test_quarter_totals(db_session, organization, sample_upd_out, contractor):
"""Тест итогов за квартал"""
await SalesBookService.create_entry_from_document(
db_session, sample_upd_out, contractor
)
totals = await SalesBookService.get_quarter_totals(
db_session, organization.id, 2026, 1
)
assert totals['entries_count'] == 1
assert totals['vat_amount_5'] == Decimal('5000.00')
assert totals['total_vat'] == Decimal('5000.00')
@pytest.mark.asyncio
async def test_split_by_vat_rate_22(db_session, organization):
"""Тест разбивки по ставке 22%"""
# Создаём документ со ставкой 22%
doc = Document(
organization_id=organization.id,
document_type='UPD_OUT',
document_number='УПД-002',
document_date=date(2026, 4, 10),
amount=Decimal('500000.00'),
vat_amount=Decimal('110000.00'),
total_amount=Decimal('610000.00'),
status='issued',
)
db_session.add(doc)
db_session.flush()
item = DocumentItem(
document_id=doc.id,
sequence=1,
name='Оборудование',
unit='шт',
quantity=Decimal('1'),
price=Decimal('500000.00'),
amount=Decimal('500000.00'),
vat_rate=Decimal('22'),
vat_amount=Decimal('110000.00'),
total_amount=Decimal('610000.00'),
)
db_session.add(item)
db_session.flush()
amounts = SalesBookService._split_by_vat_rate([item])
assert amounts['total_with_vat_22'] == Decimal('610000.00')
assert amounts['vat_22'] == Decimal('110000.00')
assert amounts['total_with_vat_5'] == Decimal('0')
@pytest.mark.asyncio
async def test_rebuild_from_documents(db_session, organization, sample_upd_out):
"""Тест полной перестройки книги"""
count = await SalesBookService.rebuild_from_documents(
db_session, organization.id, 2026, 1
)
assert count == 1
entries = await SalesBookService.get_entries(
db_session, organization.id, 2026, 1
)
assert len(entries) == 1
```
### 8.2. Тесты книги покупок и вычета
```python
# tests/test_vat/test_purchase_book.py
"""
Тесты книги покупок и механизма вычета НДС.
"""
import pytest
from decimal import Decimal
from datetime import date
from app.models.documents import Document
from app.models.vat_rate_periods import VATRatePeriod
from app.services.purchase_book_service import PurchaseBookService
@pytest.fixture
def org_on_22(db_session, organization):
"""Организация на ставке 22% (с правом вычета)"""
period = VATRatePeriod(
organization_id=organization.id,
start_date=date(2026, 1, 1),
vat_rate_code='22',
)
db_session.add(period)
db_session.flush()
return organization
@pytest.fixture
def org_on_5(db_session, organization):
"""Организация на ставке 5% (без права вычета)"""
period = VATRatePeriod(
organization_id=organization.id,
start_date=date(2026, 1, 1),
vat_rate_code='5',
)
db_session.add(period)
db_session.flush()
return organization
@pytest.fixture
def sample_upd_in(db_session, organization):
"""Входящий УПД от поставщика"""
doc = Document(
organization_id=organization.id,
document_type='UPD_IN',
document_number='ВХ-042',
document_date=date(2026, 3, 20),
contractor_id=1,
amount=Decimal('200000.00'),
vat_amount=Decimal('44000.00'), # 22% от 200k
total_amount=Decimal('244000.00'),
status='issued',
)
db_session.add(doc)
db_session.flush()
return doc
@pytest.mark.asyncio
async def test_purchase_entry_with_22(db_session, org_on_22, sample_upd_in):
"""При 22% — запись создаётся, вычет доступен"""
entry = await PurchaseBookService.create_entry_from_document(
db_session, sample_upd_in
)
assert entry is not None
assert entry.vat_deductible == Decimal('44000.00')
assert entry.is_deducted is False
@pytest.mark.asyncio
async def test_purchase_entry_blocked_at_5(db_session, org_on_5, sample_upd_in):
"""При 5% — запись НЕ создаётся (нет права на вычет)"""
entry = await PurchaseBookService.create_entry_from_document(
db_session, sample_upd_in
)
assert entry is None
@pytest.mark.asyncio
async def test_can_deduct_vat(db_session, org_on_22, org_on_5):
"""Тест проверки права на вычет"""
can_22 = await PurchaseBookService.can_deduct_vat(db_session, org_on_22.id)
can_5 = await PurchaseBookService.can_deduct_vat(db_session, org_on_5.id)
assert can_22 is True
assert can_5 is False
@pytest.mark.asyncio
async def test_apply_deduction(db_session, org_on_22, sample_upd_in):
"""Тест применения вычета"""
entry = await PurchaseBookService.create_entry_from_document(
db_session, sample_upd_in
)
assert entry.is_deducted is False
applied = await PurchaseBookService.apply_deduction(
db_session, entry.id, org_on_22.id
)
assert applied is not None
assert applied.is_deducted is True
assert applied.deduction_date is not None
@pytest.mark.asyncio
async def test_no_double_deduction(db_session, org_on_22, sample_upd_in):
"""Нельзя применить вычет дважды"""
entry = await PurchaseBookService.create_entry_from_document(
db_session, sample_upd_in
)
await PurchaseBookService.apply_deduction(db_session, entry.id, org_on_22.id)
# Повторная попытка
result = await PurchaseBookService.apply_deduction(
db_session, entry.id, org_on_22.id
)
assert result is None # Уже вычтен
```
### 8.3. Тесты расчёта НДС
```python
# tests/test_vat/test_vat_calculation.py
"""
Тесты расчёта НДС к уплате.
"""
import pytest
from decimal import Decimal
from datetime import date
from app.services.vat_calculation_service import VATCalculationService
@pytest.mark.asyncio
async def test_calculate_quarter_5_percent(db_session, org_on_5_with_sales):
"""Расчёт при 5%: только исходящий, без вычетов"""
calc = await VATCalculationService.calculate_quarter(
db_session, org_on_5_with_sales.id, 2026, 1
)
assert calc['vat_rate_code'] == '5'
assert calc['can_deduct'] is False
assert calc['input_vat_deducted'] == Decimal('0')
assert calc['vat_payable'] == calc['output_vat_total']
@pytest.mark.asyncio
async def test_calculate_quarter_22_with_deductions(
db_session, org_on_22_with_sales_and_purchases
):
"""Расчёт при 22%: исходящий минус входящий"""
calc = await VATCalculationService.calculate_quarter(
db_session, org_on_22_with_sales_and_purchases.id, 2026, 1
)
assert calc['vat_rate_code'] == '22'
assert calc['can_deduct'] is True
assert calc['vat_payable'] == (
calc['output_vat_total'] - calc['input_vat_deducted']
)
@pytest.mark.asyncio
async def test_monthly_payment_split(db_session, org_with_vat):
"""Тест разбивки на 3 ежемесячных платежа"""
calc = await VATCalculationService.calculate_quarter(
db_session, org_with_vat.id, 2026, 1
)
total = calc['monthly_payment_1'] + calc['monthly_payment_2'] + calc['monthly_payment_3']
assert total == calc['vat_payable']
@pytest.mark.asyncio
async def test_payment_dates(db_session):
"""Тест сроков уплаты"""
dates = VATCalculationService._payment_dates(2026, 1)
assert dates[0] == date(2026, 4, 28)
assert dates[1] == date(2026, 5, 28)
assert dates[2] == date(2026, 6, 28)
# Q4 → следующий год
dates_q4 = VATCalculationService._payment_dates(2026, 4)
assert dates_q4[0] == date(2027, 1, 28)
```
### 8.4. Тесты vat_utils
```python
# tests/test_vat/test_vat_utils.py
"""
Тесты утилит расчёта НДС.
"""
import pytest
from decimal import Decimal
from app.utils.vat_utils import resolve_item_vat_rate, calculate_item_vat
class TestResolveItemVatRate:
"""Тесты определения ставки НДС позиции"""
def test_none_rate(self):
"""Без НДС → None"""
assert resolve_item_vat_rate('none', None) is None
def test_5_percent_ignores_exemption(self):
"""5% — игнорирует льготу номенклатуры"""
assert resolve_item_vat_rate('5', None) == Decimal('5')
assert resolve_item_vat_rate('5', Decimal('10')) == Decimal('5')
assert resolve_item_vat_rate('5', Decimal('-1')) == Decimal('5')
def test_7_percent_ignores_exemption(self):
"""7% — игнорирует льготу номенклатуры"""
assert resolve_item_vat_rate('7', None) == Decimal('7')
assert resolve_item_vat_rate('7', Decimal('10')) == Decimal('7')
def test_22_percent_no_exemption(self):
"""22% без льготы → 22%"""
assert resolve_item_vat_rate('22', None) == Decimal('22')
def test_22_percent_with_10_exemption(self):
"""22% с льготой 10% → 10%"""
assert resolve_item_vat_rate('22', Decimal('10')) == Decimal('10')
def test_22_percent_without_vat_exemption(self):
"""22% с льготой -1 (без НДС) → None"""
assert resolve_item_vat_rate('22', Decimal('-1')) is None
def test_20_percent_legacy(self):
"""20% (старая ставка) → 20%"""
assert resolve_item_vat_rate('20', None) == Decimal('20')
class TestCalculateItemVat:
"""Тесты расчёта суммы НДС"""
def test_5_percent(self):
vat, total = calculate_item_vat(Decimal('100000'), Decimal('5'))
assert vat == Decimal('5000.00')
assert total == Decimal('105000.00')
def test_22_percent(self):
vat, total = calculate_item_vat(Decimal('500000'), Decimal('22'))
assert vat == Decimal('110000.00')
assert total == Decimal('610000.00')
def test_none_rate(self):
vat, total = calculate_item_vat(Decimal('100000'), None)
assert vat == Decimal('0')
assert total == Decimal('100000')
```
---
## 9. Roadmap
### 9.1. План реализации
```
┌─────────────────────────────────────────────────────────────────────┐
│ ROADMAP реализации НДС │
├─────────────┬──────────────┬────────────────────────────────────────┤
│ Фаза │ Срок │ Описание │
├─────────────┼──────────────┼────────────────────────────────────────┤
│ Фаза 0 │ 1 неделя │ Подготовка инфраструктуры │
│ (Sprint 0) │ │ - Миграции Alembic (таблицы) │
│ │ │ - Модели SQLAlchemy │
│ │ │ - Счёт 19 + субсчета │
│ │ │ - Регистрация blueprint vat_bp │
│ │ │ - VATModeService │
├─────────────┼──────────────┼────────────────────────────────────────┤
│ Фаза 1 │ 2 недели │ Книга продаж │
│ │ │ - SalesBookService │
│ │ │ - Автозаполнение из УПД │
│ │ │ - Эндпоинты + шаблон │
│ │ │ - Интеграция с проведением документов │
│ │ │ - Тесты │
├─────────────┼──────────────┼────────────────────────────────────────┤
│ Фаза 2 │ 2 недели │ Книга покупок │
│ │ │ - PurchaseBookService │
│ │ │ - Проводки Дт 19 Кт 60 │
│ │ │ - Вычет НДС (Дт 68.02 Кт 19) │
│ │ │ - Блокировка вычетов при 5%/7% │
│ │ │ - Эндпоинты + шаблон │
│ │ │ - Тесты │
├─────────────┼──────────────┼────────────────────────────────────────┤
│ Фаза 3 │ 1.5 недели │ Расчёт НДС и интерфейс │
│ │ │ - VATCalculationService │
│ │ │ - Страница «НДС» (overview) │
│ │ │ - Годовой обзор │
│ │ │ - Разбивка 1/3 ежемесячно │
│ │ │ - Проводка Дт 68.02 Кт 51 (уплата) │
├─────────────┼──────────────┼────────────────────────────────────────┤
│ Фаза 4 │ 3 недели │ Декларация НДС │
│ │ │ - XML-генератор (разделы 1,3,8,9) │
│ │ │ - Валидация по XSD │
│ │ │ - Страница просмотра декларации │
│ │ │ - Скачивание XML │
│ │ │ - Интеграция с Астрал.Отчётность │
│ │ │ - Тестирование через «Тестер» ФНС │
├─────────────┼──────────────┼────────────────────────────────────────┤
│ Фаза 5 │ 1 неделя │ Особенности 5%/7% │
│ │ │ - Блокировка вычетов в UI │
│ │ │ - Предупреждения │
│ │ │ - Включение НДС в себестоимость │
│ │ │ - Е2Е тесты упрощённого режима │
├─────────────┼──────────────┼────────────────────────────────────────┤
│ Фаза 6 │ 1 неделя │ Финализация │
│ (Polishing) │ │ - Навигация (меню, хлебные крошки) │
│ │ │ - Налоговый календарь (сроки НДС) │
│ │ │ - Документация пользователя │
│ │ │ - QA, edge-cases │
├─────────────┼──────────────┼────────────────────────────────────────┤
│ ИТОГО │ ~11 недель │ ≈ 2.5 месяца (1 разработчик) │
└─────────────┴──────────────┴────────────────────────────────────────┘
```
### 9.2. Оценка трудозатрат (в человеко-днях)
| Задача | Оценка | Примечание |
|--------|--------|------------|
| **Фаза 0: Инфраструктура** | **3 дня** | |
| Миграции Alembic | 1 день | 2 таблицы + счёт 19 |
| Модели SQLAlchemy | 0.5 дня | SalesBookEntry, PurchaseBookEntry |
| VATModeService | 0.5 дня | Определение режима НДС |
| Регистрация blueprint | 0.5 дня | Маршруты, навигация |
| Тесты моделей | 0.5 дня | |
| **Фаза 1: Книга продаж** | **7 дней** | |
| SalesBookService | 2 дня | Создание, разбивка по ставкам |
| Автозаполнение из УПД | 1.5 дня | Хук проведения, rebuild |
| Эндпоинты (3 шт) | 1 день | list, rebuild, delete |
| Шаблон Jinja2 | 1 день | Таблица с фильтрами |
| Тесты | 1.5 дня | 8-10 тест-кейсов |
| **Фаза 2: Книга покупок** | **8 дней** | |
| PurchaseBookService | 2 дня | С проверкой права на вычет |
| Проводки (Дт 19 Кт 60, Дт 68.02 Кт 19) | 2 дня | Интеграция с AccountingService |
| Эндпоинты (5 шт) | 1.5 дня | list, rebuild, deduct, deduct_all, delete |
| Шаблон Jinja2 | 1 день | С кнопками вычета |
| Тесты | 1.5 дня | 10-12 тест-кейсов |
| **Фаза 3: Расчёт НДС** | **6 дней** | |
| VATCalculationService | 2 дня | Квартал + год |
| Страница «НДС» (overview) | 2 дня | Карточки, графики |
| Проводка уплаты (Дт 68.02 Кт 51) | 0.5 дня | |
| Тесты | 1.5 дня | |
| **Фаза 4: Декларация** | **12 дней** | |
| Изучение XSD-схемы ФНС | 2 дня | format.nalog.ru |
| XML-генератор | 4 дня | Разделы 1, 3, 8, 9 |
| Валидация по XSD | 1.5 дня | lxml + xsd |
| Страница просмотра | 1 день | Предпросмотр разделов |
| Интеграция с Астрал | 2 дня | Отправка по API |
| Тесты | 1.5 дня | |
| **Фаза 5: Особенности 5%/7%** | **4 дня** | |
| Блокировка вычетов | 1 день | В UI и сервисах |
| Единая ставка в документах | 1 день | vat_utils |
| Предупреждения / UX | 1 день | Компоненты |
| E2E тесты | 1 день | |
| **Фаза 6: Финализация** | **5 дней** | |
| Навигация, хлебные крошки | 1 день | breadcrumbs.py |
| Налоговый календарь НДС | 1.5 дня | Автоматические события |
| Документация | 1 день | |
| QA, edge-cases | 1.5 дня | |
| **ИТОГО** | **45 дней** | **~9 недель (1 разработчик)** |
### 9.3. Приоритеты для MVP (минимальный продукт)
Если нужен MVP к **Q1 2026** (первый квартал, когда НДС вступает в силу):
**MVP (4 недели):**
1. ✅ Фаза 0: Миграции + модели
2. ✅ Фаза 1: Книга продаж (упрощённая)
3. ✅ Фаза 3: Расчёт НДС (только для 5%/7%)
4. ✅ Фаза 5: Особенности 5%/7%
**Расширение (после MVP):**
5. Фаза 2: Книга покупок (для 22%)
6. Фаза 4: Декларация XML + Астрал
> **Обоснование:** 80%+ целевой аудитории будут на ставках 5%/7% (без вычета).
> Им не нужна книга покупок и счёт 19. Для MVP достаточно книги продаж и расчёта.
### 9.4. Файловая структура
```
app/
├── models/
│ └── vat_books.py # SalesBookEntry, PurchaseBookEntry (НОВЫЙ)
├── services/
│ ├── sales_book_service.py # Сервис книги продаж (НОВЫЙ)
│ ├── purchase_book_service.py # Сервис книги покупок (НОВЫЙ)
│ ├── vat_calculation_service.py # Расчёт НДС (НОВЫЙ)
│ ├── vat_mode_service.py # Определение режима НДС (НОВЫЙ)
│ ├── nds_report_service.py # Отправка декларации (НОВЫЙ)
│ └── vat_rate_period_service.py # (СУЩЕСТВУЕТ)
├── utils/
│ ├── vat_utils.py # (СУЩЕСТВУЕТ, дополнить)
│ └── xml/
│ └── nds_declaration_xml_generator.py # XML декларации (НОВЫЙ)
├── views/
│ └── vat.py # Все эндпоинты НДС (НОВЫЙ)
├── templates/
│ └── vat/
│ ├── overview.html # Главная страница НДС (НОВЫЙ)
│ ├── not_payer.html # Заглушка для неплательщиков (НОВЫЙ)
│ ├── sales_book.html # Книга продаж (НОВЫЙ)
│ ├── purchase_book.html # Книга покупок (НОВЫЙ)
│ ├── declaration.html # Декларация (НОВЫЙ)
│ └── annual.html # Годовой обзор (НОВЫЙ)
alembic/versions/
├── xxx_create_vat_books.py # Миграция: таблицы книг (НОВЫЙ)
└── xxx_add_account_19.py # Миграция: счёт 19 (НОВЫЙ)
tests/test_vat/
├── test_sales_book.py # (НОВЫЙ)
├── test_purchase_book.py # (НОВЫЙ)
├── test_vat_calculation.py # (НОВЫЙ)
└── test_vat_utils.py # (НОВЫЙ)
```
### 9.5. Регистрация Blueprint
```python
# Добавить в app/__init__.py (или app/config/routes.py)
from app.views.vat import vat_bp
app.register_blueprint(vat_bp)
```
```python
# Добавить в навигацию (sidebar menu)
# app/templates/components/sidebar.html
# В разделе "Налоги":
{
'label': 'НДС',
'url': '/vat/',
'icon': 'receipt-tax',
'visible': organization.is_vat_payer, # Показывать только плательщикам
'children': [
{'label': 'Обзор', 'url': '/vat/'},
{'label': 'Книга продаж', 'url': '/vat/sales-book'},
{'label': 'Книга покупок', 'url': '/vat/purchase-book'},
{'label': 'Декларация', 'url': '/vat/declaration'},
]
}
```
### 9.6. Зависимости (дополнительные пакеты)
Для валидации XML по XSD:
```
# requirements.txt (дополнить)
lxml>=5.0.0 # Для валидации XML по XSD-схемам ФНС
```
---
## Приложение A: Коды видов операций (НДС)
| Код | Описание |
|-----|----------|
| 01 | Реализация товаров, работ, услуг |
| 02 | Оплата, частичная оплата (аванс) |
| 06 | Зачёт аванса при отгрузке |
| 10 | Безвозмездная передача |
| 13 | СМР для собственного потребления |
| 16 | Возврат товаров |
| 18 | Корректировочный счёт-фактура |
| 21 | Возврат аванса |
| 22 | Реализация при взаимозачёте |
| 26 | Экспорт (0%) |
---
## Приложение B: Схема проводок НДС
```
ПРОДАЖА (исходящий НДС)
========================
Дт 62 Кт 90.01 — Выручка (total_amount)
Дт 90.03 Кт 68.02 — Начислен НДС (vat_amount)
Дт 90.02 Кт 41 — Списана себестоимость (cost)
Дт 51 Кт 62 — Получена оплата (при оплате)
ПОКУПКА (входящий НДС) — только при 22%!
==========================================
Дт 41 Кт 60 — Оприходование (amount без НДС)
Дт 19.03 Кт 60 — Входящий НДС (vat_amount)
Дт 68.02 Кт 19.03 — Вычет НДС (принятие к вычету)
Дт 60 Кт 51 — Оплата поставщику (при оплате)
ПОКУПКА при 5%/7% — БЕЗ ВЫЧЕТА
================================
Дт 41 Кт 60 — Оприходование (total_amount включая НДС!)
Дт 60 Кт 51 — Оплата поставщику
❌ Счёт 19 НЕ используется
УПЛАТА НДС
==========
Дт 68.02 Кт 51 — Уплата НДС в бюджет (1/3 ежемесячно)
```
---
## Приложение C: Сроки отчётности НДС
| Квартал | Подача декларации (25-е число) | Уплата 1/3 | Уплата 2/3 | Уплата 3/3 |
|---------|-------------------------------|------------|------------|------------|
| Q1 | 25 апреля | 28 апреля | 28 мая | 28 июня |
| Q2 | 25 июля | 28 июля | 28 августа | 28 сентября |
| Q3 | 25 октября | 28 октября | 28 ноября | 28 декабря |
| Q4 | 25 января (след. год) | 28 января | 28 февраля | 28 марта |
> **Важно:** Декларация по НДС сдаётся **ТОЛЬКО в электронном виде** по ТКС (телекоммуникационным каналам связи). Бумажная форма не принимается.
---
*Документ подготовлен для проекта Конто. При реализации необходимо сверяться с актуальными XSD-схемами ФНС на https://format.nalog.ru/ и тестировать XML через программу «Тестер» ФНС.*