fix(generative/financial): VAT only on parking, residential ДДУ exempt (ст. 149 НК)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 59s
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI / backend-tests (pull_request) Successful in 11m48s

compute_financial начисляла НДС на ВСЮ валовую маржу — неверно для РФ жилья:
реализация жилых помещений и услуги застройщика по ДДУ освобождены (пп.22/пп.23.1
п.3 ст. 149 НК РФ), а невозмещаемый входной НДС по СМР уже встроен в gross-ставки
себестоимости. НДС теперь только на нежилой value-added паркинга:
max(0, revenue_parking − construction_parking) × 0.20/1.20.

Инвариант Σ cashflow == net_profit сохранён (vat прокидывается в построитель
cashflow). Обновлены docstring модуля + caveat/label в PDF-экспортёре и
concept-UI (ConceptVariantsResult). Repr. comfort: VAT 8.5М → 2.5М, net 31.8М → 36.3М.

Deep-review  (налоговый режим независимо подтверждён, edge-cases, инвариант).

Refs #1881
This commit is contained in:
Light1YT 2026-06-25 12:09:53 +05:00
parent 89509824c2
commit 2c9629a64c
4 changed files with 92 additions and 24 deletions

View file

@ -132,7 +132,7 @@ def _financial_table(variants: Sequence[ConceptVariant]) -> str:
("Земля, млн руб", [_fmt_money(v.financial.land_rub) for v in variants]),
("Итого затраты, млн руб", [_fmt_money(v.financial.cost_rub) for v in variants]),
("Валовая маржа, млн руб", [_fmt_money(v.financial.gross_margin_rub) for v in variants]),
("НДС (упрощ.), млн руб", [_fmt_money(v.financial.vat_rub) for v in variants]),
("НДС (паркинг), млн руб", [_fmt_money(v.financial.vat_rub) for v in variants]),
(
"Прибыль до налога, млн руб",
[_fmt_money(v.financial.profit_before_tax_rub) for v in variants],
@ -180,8 +180,9 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
"График фаз и темп продаж — типовые допущения, НЕ график конкретного проекта; "
"точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток "
"вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
"НДС — упрощённая оценка (встроенный НДС в положительной марже, не полная механика "
"входного/выходного НДС НК РФ). Налог на прибыль — 25% (с 2025). Цены и себестоимость "
"НДС — реализация жилья и услуги застройщика по ДДУ освобождены (ст. 149 НК РФ), "
"входной НДС по СМР встроен в себестоимость; НДС начисляется только на нежилую часть "
"(машиноместа). Налог на прибыль — 25% (с 2025). Цены и себестоимость "
"— рыночные ориентиры. Коммерческие/офисные площади не учитываются (нет в ТЭП).</p>"
"</body></html>"
)

View file

@ -52,19 +52,24 @@ Cascade (in :func:`compute_financial`):
* **БДР / taxes** = gross margin VAT profit tax net profit.
* **Metrics** = net ROI on cost, net margin on revenue, and DCF NPV/IRR/payback.
VAT DOCUMENTED SIMPLIFICATION
-------------------------------
Exact НК РФ VAT mechanics (output VAT on flats vs. input-VAT deductions on
construction/materials, эскроу specifics, residential-sale exemptions) are out of
PR-1 scope. We use a conservative, honest approximation on the project's
*value-added* (gross margin):
НДС ОСВОБОЖДЕНИЕ ЖИЛЬЯ, НДС ТОЛЬКО НА ПАРКИНГ (ст. 149 НК РФ)
------------------------------------------------------------
Реализация жилых помещений и услуги застройщика по ДДУ ОСВОБОЖДЕНЫ от НДС
(пп.22 и пп.23.1 п.3 ст. 149 НК РФ) выходной НДС на жильё = 0. Входной НДС по
СМР при этом НЕвозмещаем (п.2 ст.170 НК РФ) и уже «сидит» в gross-ставках
себестоимости (рыночные ориентиры ``_CONSTRUCTION_COST_PER_SQM`` даны С НДС)
чистая нагрузка по НДС на жилую часть 0.
vat = max(0, gross_margin) × VAT_RATE / (1 + VAT_RATE)
Машиноместа НЕЖИЛЫЕ помещения: их реализация ОБЛАГАЕТСЯ НДС 20%, входной НДС
вычитается. Поэтому НДС начисляется ТОЛЬКО на нежилой value-added паркинга
(встроенный НДС в добавленной стоимости машиномест):
i.e. the embedded VAT inside the positive margin. This deliberately *does not*
model the full input/output deduction chain it is a stand-in that keeps the net
P&L plausible and conservative. A loss-making project pays no VAT here. Replace
with proper output/input modelling when calibrating (PR-2/PR-3).
vat = max(0, revenue_parking construction_parking) × VAT_RATE / (1 + VAT_RATE)
Если паркинг тоже окажется под ДДУ-льготой оценка консервативна (чуть занижает
прибыль завышенным НДС), что соответствует honest/conservative-политике модуля.
Полную механику входного/выходного НДС с эскроу-спецификой не моделируем
жильё освобождено, входной НДС встроен в gross-СМР.
IRR REAL DCF, PROXY FALLBACK (PR-3)
-------------------------------------
@ -521,8 +526,9 @@ def compute_financial(
Полный девелоперский каскад: выручка (жильё + паркинг) каскад затрат (СМР +
ПИР + сети + услуги заказчика + резерв + маркетинг + земля) БДР с НДС и
налогом на прибыль чистая прибыль + ROI. Поверх статики помесячный DCF
(NPV/IRR/PBP) по дефолт-графику фаз (см. docstring модуля). НДС упрощение;
офисов нет в TEAP пропущены.
(NPV/IRR/PBP) по дефолт-графику фаз (см. docstring модуля). НДС жильё
освобождено (ст. 149 НК РФ), начисляется только на паркинг; офисов нет в
TEAP пропущены.
Чистая функция: НЕ ходит в БД. Калибровку цены продажи жилья делает API-слой
(lookup рынка) и прокидывает сюда через ``market_price_per_sqm``.
@ -583,9 +589,14 @@ def compute_financial(
# ── БДР / налоги → чистая прибыль ─────────────────────────────────────────
gross_margin = revenue - cost # валовая (как было)
# НДС: УПРОЩЕНО — встроенный НДС в положительной марже (value-added),
# не полная механика входного/выходного НДС НК РФ (см. docstring модуля).
vat = max(0.0, gross_margin) * _VAT_RATE / (1.0 + _VAT_RATE)
# НДС: жилые помещения и услуги застройщика по ДДУ освобождены (пп.22 / пп.23.1
# п.3 ст. 149 НК РФ) → output-НДС на жильё = 0, а невозмещаемый входной НДС по СМР
# уже сидит в gross-ставках себестоимости. НДС начисляется ТОЛЬКО на нежилую часть
# (машиноместа — нежилые, их реализация облагается): встроенный НДС в value-added
# паркинга. Если паркинг тоже окажется по ДДУ-льготе — оценка консервативна (чуть
# занижает прибыль), что соответствует «honest/conservative» политике модуля.
parking_value_added = revenue_parking - construction_parking
vat = max(0.0, parking_value_added) * _VAT_RATE / (1.0 + _VAT_RATE)
profit_before_tax = gross_margin - vat
profit_tax = max(0.0, profit_before_tax) * _PROFIT_TAX_RATE
net_profit = profit_before_tax - profit_tax

View file

@ -157,6 +157,62 @@ def test_invariant_holds_for_all_development_types() -> None:
assert abs(sum(cf) - model.net_profit_rub) < 1.0, dev_type
# ── НДС: освобождение жилья (ст.149 НК), НДС только на паркинг ───────────────────
def test_vat_zero_for_residential_only_project() -> None:
# Реализация жилья + услуги застройщика по ДДУ освобождены от НДС (пп.22/пп.23.1
# п.3 ст.149 НК РФ). Проект БЕЗ паркинга → НДС == 0 (раньше было margin×0.20/1.20).
# Profitable comfort-проект всё равно остаётся прибыльным.
t = _teap(residential=10_000.0, gfa=11_000.0, parking=0)
model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
)
# Освобождение явно: нет паркинга → нет облагаемого value-added → НДС ровно 0.
assert model.vat_rub == 0.0
assert model.revenue_parking_rub == 0.0
# Налогооблагаемая база до налога теперь == валовой марже (НДС не съедает её).
assert model.profit_before_tax_rub == pytest.approx(model.gross_margin_rub, abs=0.01)
# Проект прибыльный (освобождение НДС не делает его убыточным).
assert model.net_profit_rub > 0
def test_vat_only_on_parking_value_added() -> None:
# С паркингом → НДС > 0 и считается ТОЛЬКО на нежилой value-added машиномест:
# vat == max(0, revenue_parking construction_parking) × 0.20/1.20.
# Жилая часть (выручка/маржа жилья) в базу НДС НЕ входит (ст.149 НК РФ).
parking = 50
cls = "comfort"
t = _teap(residential=10_000.0, gfa=11_000.0, parking=parking)
model = financial.compute_financial(
teap=t, housing_class=cls, land_cost_rub=200_000_000.0, development_type="mid_rise"
)
# Ожидаемое из констант класса: revenue_parking construction_parking паркинга.
revenue_parking = parking * financial._PARKING_PRICE_PER_SPOT[cls]
construction_parking = parking * financial._PARKING_COST_PER_SPOT[cls]
parking_value_added = revenue_parking - construction_parking
expected_vat = max(0.0, parking_value_added) * financial._VAT_RATE / (1.0 + financial._VAT_RATE)
assert model.vat_rub > 0.0
assert model.vat_rub == pytest.approx(round(expected_vat, 2), abs=0.01)
# База НДС — именно нежилой value-added, а НЕ полная валовая маржа проекта.
full_margin_vat = (
max(0.0, model.gross_margin_rub) * financial._VAT_RATE / (1.0 + financial._VAT_RATE)
)
assert model.vat_rub < full_margin_vat # жильё освобождено → НДС много меньше margin×rate
def test_vat_invariant_holds_with_parking_vat() -> None:
# Инвариант Σ cashflow == net_profit держится с новым НДС (vat прокидывается в
# построитель cashflow без изменения формы — меняется только база НДС).
t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
)
assert model.vat_rub > 0.0
cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0)
assert abs(sum(cf) - model.net_profit_rub) < 1.0
# ── Настоящий IRR vs proxy fallback ─────────────────────────────────────────────

View file

@ -298,10 +298,10 @@ function VariantPanel({ parcel, variant }: PanelProps) {
{financial.irr_is_proxy
? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
: ""}
НДС упрощённая оценка (встроенный НДС в положительной марже, не
полная механика входного/выходного НДС НК РФ); привязан к
признанию выручки. Налог на прибыль 25% (с 2025 года). Цена
продажи жилья {" "}
НДС: жильё и услуги застройщика по ДДУ освобождены (ст. 149 НК
РФ); НДС начисляется только на паркинг (нежилые машиноместа).
Входной НДС по строительству уже учтён в себестоимости. Налог на
прибыль 25% (с 2025 года). Цена продажи жилья {" "}
{financial.price_is_calibrated
? `калибрована по рынку (${priceSourceCaption(financial)})`
: "норматив класса (нет рыночных данных по участку)"}
@ -414,7 +414,7 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
value={formatMoneyCompact(financial.gross_margin_rub)}
/>
<CascadeRow
label="НДС (упрощённо)"
label="НДС (паркинг)"
value={formatMoneyCompact(financial.vat_rub)}
/>
<CascadeRow