From 3da3100328d0e293b83f632dfe82ecdcefa4a4f4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?=
=?UTF-8?q?=D0=B8=D0=BD?=
Date: Thu, 15 Oct 2026 19:37:51 +0300
Subject: [PATCH] Separate salary participation and preserve historical
carryover rules
---
.../v1/finance/admin/period_views/catalog.py | 19 +-
backend/api/v1/finance/admin/queries.py | 6 +-
.../api/v1/finance/admin/workspace_payload.py | 11 +-
.../v1/finance/payroll_serializers/common.py | 5 +
.../v1/finance/payroll_serializers/periods.py | 1 +
.../payroll_serializers/presentation.py | 1 +
backend/api/v1/finance/serializers.py | 24 +-
..._salary_participation_and_balance_rules.py | 126 ++++++++
backend/finance/models.py | 18 ++
backend/finance/payroll/adapter.py | 6 +-
backend/finance/payroll/balances.py | 10 +-
backend/finance/payroll/components.py | 35 ++-
backend/finance/payroll/reporting.py | 32 +-
backend/finance/payroll/services.py | 53 +++-
backend/payroll_core/balances.py | 12 +
backend/payroll_core/calculator.py | 87 ++++--
backend/payroll_core/domain.py | 24 ++
.../tests/test_salary_participation.py | 107 +++++++
.../tests/finance/test_payroll_admin_api.py | 1 +
.../tests/finance/test_payroll_services.py | 2 +-
.../finance/test_salary_participation.py | 283 ++++++++++++++++++
.../finance/PayrollStatementDetailModal.tsx | 15 +
.../finance/admin/PayrollPeriodTable.tsx | 97 ++++--
frontend/src/lib/api/finance.ts | 10 +-
frontend/src/lib/payroll-point-base.test.js | 9 +
frontend/src/lib/payroll-point-base.ts | 7 +-
26 files changed, 932 insertions(+), 69 deletions(-)
create mode 100644 backend/finance/migrations/0041_salary_participation_and_balance_rules.py
create mode 100644 backend/payroll_core/balances.py
create mode 100644 backend/payroll_core/tests/test_salary_participation.py
create mode 100644 backend/tests/finance/test_salary_participation.py
diff --git a/backend/api/v1/finance/admin/period_views/catalog.py b/backend/api/v1/finance/admin/period_views/catalog.py
index 7c9a6c67..bb6a3dfe 100644
--- a/backend/api/v1/finance/admin/period_views/catalog.py
+++ b/backend/api/v1/finance/admin/period_views/catalog.py
@@ -148,7 +148,14 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
current = get_payroll_components().get(code=code)
except LookupError as exc:
raise ValidationError({"code": "Зарплатная колонка не найдена."}) from exc
- serializer = PayrollCustomComponentWriteSerializer(data=request.data)
+ serializer = PayrollCustomComponentWriteSerializer(
+ data={
+ **request.data,
+ "include_in_salary": request.data.get(
+ "include_in_salary", current.include_in_salary
+ ),
+ }
+ )
serializer.is_valid(raise_exception=True)
operation_period = PayrollPeriod.objects.filter(
pk=request.data.get("period_id")
@@ -156,6 +163,16 @@ class PayrollAdminComponentDetailView(PayrollAdminAPIView):
_reject_closed_component_period(operation_period)
values = dict(serializer.validated_data)
scope_period = values.pop("scope_period", None)
+ if (
+ operation_period is not None
+ and operation_period.balance_rules_version == 1
+ and values["include_in_salary"] != current.include_in_salary
+ ):
+ _conflict(
+ "PERIOD_COMPONENTS_LOCKED",
+ "Участие в зарплате можно изменить только в периоде с новыми правилами. Предыдущий расчёт сохранён.",
+ period_id=operation_period.pk,
+ )
if (
values["kind"] == PayrollComponentKind.SCHEDULED_CHANGE
and values.get("scheduled_target_component_code") == code
diff --git a/backend/api/v1/finance/admin/queries.py b/backend/api/v1/finance/admin/queries.py
index 5b612291..bab98d54 100644
--- a/backend/api/v1/finance/admin/queries.py
+++ b/backend/api/v1/finance/admin/queries.py
@@ -19,8 +19,8 @@ from finance.models import (
PayrollPeriod,
PayrollStatement,
)
-from finance.payroll.balances import CARRYOVER_DEBIT_KINDS
from finance.payroll.components import get_payroll_components
+from payroll_core.balances import carryover_contribution
from ..admin_serializers import (
PayrollCellCommentSerializer,
@@ -160,8 +160,8 @@ def _carryover_ledger(period):
and line.status == ApprovalStatus.DRAFT
)
counts_in_balance = bool(line.include_in_carryover and active_status)
- raw_contribution = (
- line.amount if component.kind in CARRYOVER_DEBIT_KINDS else -line.amount
+ raw_contribution = carryover_contribution(
+ component.kind, line.amount, version=line.period.balance_rules_version
)
contribution = raw_contribution if counts_in_balance else Decimal("0.00")
balances_by_employee[line.employee_id] = (
diff --git a/backend/api/v1/finance/admin/workspace_payload.py b/backend/api/v1/finance/admin/workspace_payload.py
index 816ad8b2..0d294cde 100644
--- a/backend/api/v1/finance/admin/workspace_payload.py
+++ b/backend/api/v1/finance/admin/workspace_payload.py
@@ -9,6 +9,7 @@ from finance.models import (
PayrollRun,
PayrollWorkRecord,
)
+from finance.payroll.components import get_payroll_components
from finance.payroll.config import (
base_rate_code,
build_rules,
@@ -111,7 +112,15 @@ def _period_input_state(period) -> PeriodInputState:
employee__is_hidden_in_ui=False,
).count()
approved_input_outside_roster_employee_ids = sorted(
- set(approved_inputs.values_list("employee_id", flat=True))
+ set(
+ approved_inputs.exclude(
+ component_code__in=[
+ component.code
+ for component in get_payroll_components(period)
+ if not component.include_in_salary
+ ]
+ ).values_list("employee_id", flat=True)
+ )
- set(roster_employee_ids)
)
draft_inputs = PayrollInputLine.objects.filter(
diff --git a/backend/api/v1/finance/payroll_serializers/common.py b/backend/api/v1/finance/payroll_serializers/common.py
index 60eb675e..260346f8 100644
--- a/backend/api/v1/finance/payroll_serializers/common.py
+++ b/backend/api/v1/finance/payroll_serializers/common.py
@@ -122,6 +122,7 @@ class PayrollComponentSerializer(serializers.Serializer):
period_scope = serializers.CharField(read_only=True)
scope_period_id = serializers.IntegerField(read_only=True, allow_null=True)
include_in_point_base = serializers.BooleanField(read_only=True)
+ include_in_salary = serializers.BooleanField(read_only=True)
include_in_carryover = serializers.BooleanField(read_only=True)
visible_to_employee = serializers.BooleanField(read_only=True)
is_custom = serializers.BooleanField(read_only=True)
@@ -169,6 +170,7 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
"kind",
"requires_reason",
"include_in_point_base",
+ "include_in_salary",
"include_in_carryover",
"visible_to_employee",
"highlight_color",
@@ -187,6 +189,7 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
{"period_id": "Для выбранной области укажите расчётный период."}
)
if attrs["kind"] == PayrollComponentKind.SCHEDULED_CHANGE:
+ attrs["include_in_salary"] = False
attrs["include_in_point_base"] = False
attrs["include_in_carryover"] = False
attrs["visible_to_employee"] = False
@@ -232,6 +235,8 @@ class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
)
else:
attrs["scheduled_target_component_code"] = ""
+ if attrs.get("include_in_salary") is False:
+ attrs["include_in_point_base"] = False
return attrs
diff --git a/backend/api/v1/finance/payroll_serializers/periods.py b/backend/api/v1/finance/payroll_serializers/periods.py
index f817d3a6..c0040c85 100644
--- a/backend/api/v1/finance/payroll_serializers/periods.py
+++ b/backend/api/v1/finance/payroll_serializers/periods.py
@@ -45,6 +45,7 @@ class PayrollPeriodSerializer(serializers.ModelSerializer):
"status",
"status_label",
"current_run_id",
+ "balance_rules_version",
"lock_version",
"created_by_id",
"created_at",
diff --git a/backend/api/v1/finance/payroll_serializers/presentation.py b/backend/api/v1/finance/payroll_serializers/presentation.py
index 46d8e893..20bae5ee 100644
--- a/backend/api/v1/finance/payroll_serializers/presentation.py
+++ b/backend/api/v1/finance/payroll_serializers/presentation.py
@@ -109,6 +109,7 @@ class PayrollPeriodTableSerializer(serializers.Serializer):
recorded_payments = serializers.JSONField(read_only=True)
period_id = serializers.IntegerField(read_only=True)
period_status = serializers.CharField(read_only=True)
+ balance_rules_version = serializers.IntegerField(read_only=True)
currency = serializers.CharField(read_only=True)
calculation_rules = serializers.JSONField(read_only=True)
run = serializers.JSONField(read_only=True, allow_null=True)
diff --git a/backend/api/v1/finance/serializers.py b/backend/api/v1/finance/serializers.py
index a7c0f768..b32dafc8 100644
--- a/backend/api/v1/finance/serializers.py
+++ b/backend/api/v1/finance/serializers.py
@@ -14,6 +14,11 @@ from finance.models import (
class PayrollStatementLineSerializer(serializers.ModelSerializer):
+ include_in_salary = serializers.SerializerMethodField()
+
+ def get_include_in_salary(self, obj) -> bool:
+ return self.context.get("salary_flags", {}).get(obj.line_id, True)
+
class Meta:
model = PayrollStatementLine
fields = [
@@ -25,6 +30,7 @@ class PayrollStatementLineSerializer(serializers.ModelSerializer):
"source_period_to",
"is_retro",
"calculated",
+ "include_in_salary",
]
@@ -140,6 +146,15 @@ class OwnPayrollStatementSummarySerializer(serializers.ModelSerializer):
class OwnPayrollStatementSerializer(serializers.ModelSerializer):
+ non_salary_payment_total = serializers.SerializerMethodField()
+
+ def get_non_salary_payment_total(self, obj) -> str:
+ return str(
+ (obj.result_snapshot or {})
+ .get("totals", {})
+ .get("non_salary_payment_total", "0")
+ )
+
period = serializers.SerializerMethodField()
revision = serializers.IntegerField(source="run.revision", read_only=True)
published_at = serializers.DateTimeField(
@@ -166,6 +181,7 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer):
"deduction_total",
"net_pay",
"payment_total",
+ "non_salary_payment_total",
"payable",
"lines",
"acknowledgement",
@@ -192,7 +208,13 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer):
@extend_schema_field(PayrollStatementLineSerializer(many=True))
def get_lines(self, obj):
lines = obj.lines.filter(visible_to_employee=True)
- return PayrollStatementLineSerializer(lines, many=True).data
+ salary_flags = {
+ line["line_id"]: line.get("include_in_salary", True)
+ for line in (obj.result_snapshot or {}).get("lines", [])
+ }
+ return PayrollStatementLineSerializer(
+ lines, many=True, context={**self.context, "salary_flags": salary_flags}
+ ).data
class OwnPayrollWorkRecordSerializer(serializers.ModelSerializer):
diff --git a/backend/finance/migrations/0041_salary_participation_and_balance_rules.py b/backend/finance/migrations/0041_salary_participation_and_balance_rules.py
new file mode 100644
index 00000000..15a8b9b1
--- /dev/null
+++ b/backend/finance/migrations/0041_salary_participation_and_balance_rules.py
@@ -0,0 +1,126 @@
+"""Preserve all historical rules; opt in only uncalculated periods after them."""
+
+from importlib import import_module
+
+import django.core.validators
+from django.db import migrations, models
+from django.db.models import Max, Q
+
+
+def activate_current_rules(apps, schema_editor):
+ Period = apps.get_model("finance", "PayrollPeriod")
+ for currency in Period.objects.values_list("currency", flat=True).distinct():
+ frozen_end = (
+ Period.objects.filter(currency=currency)
+ .filter(~Q(status="open") | Q(current_run__isnull=False))
+ .aggregate(value=Max("date_to"))["value"]
+ )
+ current = Period.objects.filter(
+ currency=currency, status="open", current_run__isnull=True
+ )
+ if frozen_end:
+ current = current.filter(date_from__gt=frozen_end)
+ current.update(balance_rules_version=2)
+
+ # Preserve live configuration for older unclosed/calculated periods too.
+ # Reuse the immutable definitions shipped in the earlier snapshot migration.
+ legacy = import_module("finance.migrations.0038_payrollperiod_component_snapshot")
+ Settings = apps.get_model("finance", "FinanceSettings")
+ Custom = apps.get_model("finance", "PayrollCustomComponent")
+ settings = Settings.objects.first()
+ overrides = settings.payroll_component_overrides if settings else {}
+ builtins = []
+ for values in legacy.BUILTIN_COMPONENTS:
+ payload = dict(
+ zip(
+ (
+ "code",
+ "name",
+ "kind",
+ "requires_reason",
+ "display_order",
+ "include_in_point_base",
+ "include_in_carryover",
+ "visible_to_employee",
+ "highlight_color",
+ ),
+ values,
+ strict=True,
+ )
+ )
+ payload.update(
+ period_scope="all",
+ scope_period_id=None,
+ is_custom=False,
+ scheduled_target_component_code="",
+ )
+ payload.update(overrides.get(payload["code"], {}))
+ builtins.append({field: payload[field] for field in legacy.SNAPSHOT_FIELDS})
+ custom = []
+ for component in Custom.objects.filter(is_active=True).prefetch_related(
+ "excluded_periods"
+ ):
+ payload = {
+ field: getattr(component, field)
+ for field in legacy.SNAPSHOT_FIELDS
+ if field != "is_custom"
+ }
+ payload.update(
+ is_custom=True,
+ hidden_from_period_id=component.hidden_from_period_id,
+ excluded_period_ids={item.pk for item in component.excluded_periods.all()},
+ )
+ custom.append(payload)
+ dates = dict(Period.objects.values_list("pk", "date_from"))
+ for period in Period.objects.filter(balance_rules_version=1):
+ if period.component_snapshot:
+ continue
+ components = [
+ payload
+ for payload in builtins
+ if legacy._applies_to_period(payload, period, dates)
+ ]
+ components.extend(
+ {field: payload[field] for field in legacy.SNAPSHOT_FIELDS}
+ for payload in custom
+ if legacy._applies_to_period(
+ payload, period, dates, payload["excluded_period_ids"]
+ )
+ )
+ Period.objects.filter(pk=period.pk).update(
+ component_snapshot={"version": 1, "components": components}
+ )
+
+
+class Migration(migrations.Migration):
+ dependencies = [("finance", "0040_payrollbalanceadjustment")]
+ operations = [
+ migrations.AddField(
+ model_name="payrollcustomcomponent",
+ name="include_in_salary",
+ field=models.BooleanField(
+ default=True, verbose_name="Учитывать в расчёте зарплаты"
+ ),
+ ),
+ migrations.AddField(
+ model_name="payrollperiod",
+ name="balance_rules_version",
+ field=models.PositiveSmallIntegerField(
+ default=1,
+ editable=False,
+ validators=[django.core.validators.MinValueValidator(1)],
+ verbose_name="Версия правил переходящего остатка",
+ ),
+ ),
+ migrations.RunPython(activate_current_rules, migrations.RunPython.noop),
+ migrations.AlterField(
+ model_name="payrollperiod",
+ name="balance_rules_version",
+ field=models.PositiveSmallIntegerField(
+ default=2,
+ editable=False,
+ validators=[django.core.validators.MinValueValidator(1)],
+ verbose_name="Версия правил переходящего остатка",
+ ),
+ ),
+ ]
diff --git a/backend/finance/models.py b/backend/finance/models.py
index 8bb7cf7f..11bc34dd 100644
--- a/backend/finance/models.py
+++ b/backend/finance/models.py
@@ -105,6 +105,12 @@ class PayrollPeriod(models.Model):
name = models.CharField("Название", max_length=120, blank=True)
date_from = models.DateField("Начало периода")
date_to = models.DateField("Конец периода")
+ balance_rules_version = models.PositiveSmallIntegerField(
+ "Версия правил переходящего остатка",
+ default=2,
+ editable=False,
+ validators=[MinValueValidator(1)],
+ )
pay_date = models.DateField("Дата выплаты", null=True, blank=True)
currency = models.CharField(
"Валюта",
@@ -193,6 +199,7 @@ class PayrollPeriod(models.Model):
self.date_to,
self.pay_date,
self.currency,
+ self.balance_rules_version,
)
original_values = (
original.code,
@@ -201,6 +208,7 @@ class PayrollPeriod(models.Model):
original.date_to,
original.pay_date,
original.currency,
+ original.balance_rules_version,
)
if immutable_values != original_values:
raise ValidationError(
@@ -230,6 +238,10 @@ class PayrollPeriod(models.Model):
update_fields = kwargs.get("update_fields")
if self.pk and not self._state.adding:
original = PayrollPeriod.objects.select_for_update().get(pk=self.pk)
+ if original.balance_rules_version != self.balance_rules_version:
+ raise ValidationError(
+ "Версию правил существующего периода нельзя изменять."
+ )
self._validate_immutable_fields(original)
if (
original.status == PayrollPeriodStatus.CLOSED
@@ -1019,6 +1031,9 @@ class PayrollCustomComponent(models.Model):
"Учитывать в балльной базе",
default=False,
)
+ include_in_salary = models.BooleanField(
+ "Учитывать в расчёте зарплаты", default=True
+ )
include_in_carryover = models.BooleanField(
"Учитывать в переходящих остатках",
default=False,
@@ -1101,6 +1116,8 @@ class PayrollCustomComponent(models.Model):
def clean(self):
super().clean()
self.name = self.name.strip()
+ if not self.include_in_salary:
+ self.include_in_point_base = False
if not self.name:
raise ValidationError({"name": "Укажите название колонки."})
if self.period_scope == PayrollComponentPeriodScope.ALL:
@@ -1149,6 +1166,7 @@ class PayrollCustomComponent(models.Model):
}
)
self.include_in_point_base = False
+ self.include_in_salary = False
self.include_in_carryover = False
self.visible_to_employee = False
elif self.scheduled_target_component_code:
diff --git a/backend/finance/payroll/adapter.py b/backend/finance/payroll/adapter.py
index 5d04d81a..258bc956 100644
--- a/backend/finance/payroll/adapter.py
+++ b/backend/finance/payroll/adapter.py
@@ -102,6 +102,7 @@ def _mapped_input_lines(
kind=LineKind(component.kind),
amount=amount,
source_ref="scheduled-effective-value",
+ include_in_salary=component.include_in_salary,
reason="; ".join(
dict.fromkeys(record.reason for record in records if record.reason)
),
@@ -131,6 +132,7 @@ def _mapped_input_lines(
reason=record.reason,
source_period=source_period,
is_retro=source_period is not None,
+ include_in_salary=component.include_in_salary,
include_in_carryover=(
component.include_in_carryover and record.include_in_carryover
),
@@ -175,7 +177,7 @@ def _point_base_accrual(
point_base_lines = Decimal("0")
for record in input_lines:
component = component_definitions[record.component_code]
- if not component.include_in_point_base:
+ if not component.include_in_point_base or not component.include_in_salary:
continue
direction = -1 if component.kind in POINT_BASE_NEGATIVE_KINDS else 1
if component.kind == PayrollComponentKind.SCHEDULED_CHANGE:
@@ -271,6 +273,7 @@ def build_core_request(
)
return PayrollRequest(
employee_ref=f"employee:{rate.employee_id}",
+ balance_rules_version=period.balance_rules_version,
period=_core_period(period),
currency=period.currency,
base_accrual=rate.amount,
@@ -315,6 +318,7 @@ def build_core_preview_request(
)
return PayrollRequest(
employee_ref=f"employee:{employee_id}",
+ balance_rules_version=period.balance_rules_version,
period=_core_period(period),
currency=period.currency,
base_accrual=rate.amount if rate is not None else Decimal("0"),
diff --git a/backend/finance/payroll/balances.py b/backend/finance/payroll/balances.py
index 3c660a6f..fdb268eb 100644
--- a/backend/finance/payroll/balances.py
+++ b/backend/finance/payroll/balances.py
@@ -8,8 +8,7 @@ from decimal import Decimal
from finance.enums import ApprovalStatus, InputSource
from finance.models import PayrollBalanceAdjustment, PayrollInputLine, PayrollPeriod
from finance.payroll.components import get_payroll_components
-
-CARRYOVER_DEBIT_KINDS = frozenset({"adjustment_debit", "deduction", "payment"})
+from payroll_core.balances import carryover_contribution
def opening_balances(
@@ -56,10 +55,13 @@ def opening_balances(
)
if component is None:
continue
- direction = 1 if component.kind in CARRYOVER_DEBIT_KINDS else -1
balances[line["employee_id"]] = balances.get(
line["employee_id"], Decimal("0.00")
- ) + (line["amount"] * direction)
+ ) + carryover_contribution(
+ component.kind,
+ line["amount"],
+ version=source_periods[line["period_id"]].balance_rules_version,
+ )
# Effective-period corrections enter the opening balance exactly once per
# cumulative reconstruction. They never become payroll input lines.
for adjustment in PayrollBalanceAdjustment.objects.filter(
diff --git a/backend/finance/payroll/components.py b/backend/finance/payroll/components.py
index 2ee89f79..f9f3ac5e 100644
--- a/backend/finance/payroll/components.py
+++ b/backend/finance/payroll/components.py
@@ -8,7 +8,7 @@ from finance.enums import (
PayrollPeriodStatus,
)
-PAYROLL_COMPONENT_SNAPSHOT_VERSION = 1
+PAYROLL_COMPONENT_SNAPSHOT_VERSION = 2
PAYROLL_COMPONENT_SNAPSHOT_FIELDS = (
"code",
"name",
@@ -16,6 +16,7 @@ PAYROLL_COMPONENT_SNAPSHOT_FIELDS = (
"requires_reason",
"display_order",
"include_in_point_base",
+ "include_in_salary",
"include_in_carryover",
"visible_to_employee",
"period_scope",
@@ -34,6 +35,7 @@ class PayrollComponentDefinition:
requires_reason: bool
display_order: int
include_in_point_base: bool = False
+ include_in_salary: bool = True
include_in_carryover: bool = False
visible_to_employee: bool = True
period_scope: str = PayrollComponentPeriodScope.ALL
@@ -198,6 +200,7 @@ def _get_live_payroll_components(period=None) -> _PayrollComponentCollection:
include_in_point_base=override.get(
"include_in_point_base", component.include_in_point_base
),
+ include_in_salary=override.get("include_in_salary", True),
include_in_carryover=override.get(
"include_in_carryover", component.include_in_carryover
),
@@ -265,13 +268,27 @@ def _get_live_payroll_components(period=None) -> _PayrollComponentCollection:
custom = [
component for component in custom if component.applies_to_period(period)
]
+ # Old periods retain their original salary semantics even if a live
+ # column is subsequently excluded from salary in the current period.
+ if period.balance_rules_version == 1:
+ return _PayrollComponentCollection(
+ PayrollComponentDefinition(
+ **{
+ field: True
+ if field == "include_in_salary"
+ else getattr(component, field)
+ for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS
+ }
+ )
+ for component in (*builtins, *custom)
+ )
return _PayrollComponentCollection((*builtins, *custom))
def build_payroll_component_snapshot(period) -> dict[str, object]:
"""Serialize the exact component configuration visible when a period closes."""
- components = _get_live_payroll_components(period)
+ components = get_payroll_components(period)
return {
"version": PAYROLL_COMPONENT_SNAPSHOT_VERSION,
"components": [
@@ -287,7 +304,7 @@ def build_payroll_component_snapshot(period) -> dict[str, object]:
def _components_from_snapshot(snapshot) -> _PayrollComponentCollection:
if not isinstance(snapshot, dict):
raise ValueError("Некорректный снимок зарплатных колонок.")
- if snapshot.get("version") != PAYROLL_COMPONENT_SNAPSHOT_VERSION:
+ if snapshot.get("version") not in (1, PAYROLL_COMPONENT_SNAPSHOT_VERSION):
raise ValueError("Неподдерживаемая версия снимка зарплатных колонок.")
payloads = snapshot.get("components")
if not isinstance(payloads, list):
@@ -295,7 +312,12 @@ def _components_from_snapshot(snapshot) -> _PayrollComponentCollection:
try:
return _PayrollComponentCollection(
PayrollComponentDefinition(
- **{field: payload[field] for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS}
+ **{
+ field: payload.get(field, True)
+ if field == "include_in_salary" and snapshot["version"] == 1
+ else payload[field]
+ for field in PAYROLL_COMPONENT_SNAPSHOT_FIELDS
+ }
)
for payload in payloads
)
@@ -308,7 +330,10 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection:
if (
period is not None
- and period.status == PayrollPeriodStatus.CLOSED
+ and (
+ period.status == PayrollPeriodStatus.CLOSED
+ or period.balance_rules_version == 1
+ )
and period.component_snapshot
):
return _components_from_snapshot(period.component_snapshot)
diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py
index 339a030e..e1340508 100644
--- a/backend/finance/payroll/reporting.py
+++ b/backend/finance/payroll/reporting.py
@@ -81,7 +81,11 @@ def _point_base_amount(rate_amount, component_amounts, component_definitions):
amount = Decimal(str(rate_amount))
for code, component_amount in component_amounts.items():
component = component_definitions.get(code)
- if not component or not component["include_in_point_base"]:
+ if (
+ not component
+ or not component["include_in_point_base"]
+ or not component["include_in_salary"]
+ ):
continue
direction = -1 if component["kind"] in POINT_BASE_NEGATIVE_KINDS else 1
amount += Decimal(str(component_amount)) * direction
@@ -184,6 +188,7 @@ def _component_definition(
*,
requires_reason=False,
include_in_point_base=False,
+ include_in_salary=True,
include_in_carryover=False,
visible_to_employee=True,
period_scope="all",
@@ -199,6 +204,7 @@ def _component_definition(
"display_order": order,
"requires_reason": requires_reason,
"include_in_point_base": include_in_point_base,
+ "include_in_salary": include_in_salary,
"include_in_carryover": include_in_carryover,
"visible_to_employee": visible_to_employee,
"period_scope": period_scope,
@@ -413,6 +419,7 @@ def build_payroll_period_table(period: PayrollPeriod):
component.display_order,
requires_reason=component.requires_reason,
include_in_point_base=component.include_in_point_base,
+ include_in_salary=component.include_in_salary,
include_in_carryover=component.include_in_carryover,
visible_to_employee=component.visible_to_employee,
period_scope=component.period_scope,
@@ -451,6 +458,18 @@ def build_payroll_period_table(period: PayrollPeriod):
raw_source_lines,
scheduled_codes=scheduled_component_codes,
)
+ salary_excluded_only = bool(
+ statement is None
+ and work is None
+ and source_lines
+ and all(
+ not components_by_code[line.component_code].include_in_salary
+ for line in source_lines
+ )
+ )
+ if salary_excluded_only:
+ # A former employee's debt payment is not a new salary or payslip.
+ rate = None
display_source_lines = [
line
for line in raw_source_lines
@@ -497,6 +516,11 @@ def build_payroll_period_table(period: PayrollPeriod):
"deduction_total": str(statement.deduction_total),
"net_pay": str(statement.net_pay),
"payment_total": str(statement.payment_total),
+ "non_salary_payment_total": str(
+ (statement.result_snapshot or {})
+ .get("totals", {})
+ .get("non_salary_payment_total", "0")
+ ),
"payable": str(statement.payable),
}
rate_amount = snapshot.get("base_accrual")
@@ -587,6 +611,9 @@ def build_payroll_period_table(period: PayrollPeriod):
"deduction_total": str(preview_totals.deduction_total),
"net_pay": str(preview_totals.net_pay),
"payment_total": str(preview_totals.payment_total),
+ "non_salary_payment_total": str(
+ preview_totals.non_salary_payment_total
+ ),
"payable": str(preview_totals.payable),
}
totals_preliminary = True
@@ -606,6 +633,8 @@ def build_payroll_period_table(period: PayrollPeriod):
status = "ready"
if calculation_issue is not None:
status = "incomplete"
+ if salary_excluded_only:
+ status = "balance_only"
projection_target_points = Decimal(str(target_points or "0"))
daily_point_value = (
@@ -689,6 +718,7 @@ def build_payroll_period_table(period: PayrollPeriod):
return {
"period_id": period.pk,
+ "balance_rules_version": period.balance_rules_version,
"period_status": period.status,
"currency": period.currency,
"calculation_rules": active_rules.to_dict(),
diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py
index e4d2c210..59000359 100644
--- a/backend/finance/payroll/services.py
+++ b/backend/finance/payroll/services.py
@@ -109,6 +109,42 @@ def _hash_items(items: list[str]) -> str:
return hashlib.sha256(payload).hexdigest()
+def _period_input_hash(period, results):
+ hashes = [result.input_hash for _, result in results]
+ if period.balance_rules_version >= 2:
+ # Balance-only inputs do not produce payslips, but must still invalidate
+ # an approved calculation when their amounts/settings change.
+ components = {
+ component.code: component for component in get_payroll_components(period)
+ }
+ outside_lines = (
+ PayrollInputLine.objects.filter(
+ period=period,
+ status=ApprovalStatus.APPROVED,
+ )
+ .exclude(employee_id__in=[rate.employee_id for rate, _ in results])
+ .order_by("pk")
+ )
+ for line in outside_lines:
+ component = components[line.component_code]
+ payload = {
+ "id": line.pk,
+ "employee_id": line.employee_id,
+ "component_code": line.component_code,
+ "amount": str(line.amount),
+ "include_in_carryover": line.include_in_carryover
+ and component.include_in_carryover,
+ "include_in_salary": component.include_in_salary,
+ "kind": component.kind,
+ }
+ hashes.append(
+ hashlib.sha256(
+ json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
+ ).hexdigest()
+ )
+ return _hash_items(hashes)
+
+
def _audit(
*,
actor,
@@ -301,12 +337,21 @@ def _calculate_current_period_inputs(
)
employee_ids = sorted(work_records)
rates = _resolve_rates(period, employee_ids)
+ component_definitions = {
+ component.code: component for component in get_payroll_components(period)
+ }
+ excluded_salary_codes = [
+ code
+ for code, component in component_definitions.items()
+ if not component.include_in_salary
+ ]
outside_roster = list(
PayrollInputLine.objects.filter(
period=period,
status=ApprovalStatus.APPROVED,
)
.exclude(employee_id__in=employee_ids)
+ .exclude(component_code__in=excluded_salary_codes)
.values_list("employee_id", flat=True)
.distinct()[:20]
)
@@ -318,10 +363,6 @@ def _calculate_current_period_inputs(
)
input_lines = _input_lines_by_employee(period, employee_ids)
balances = opening_balances(period, employee_ids)
- component_definitions = {
- component.code: component for component in get_payroll_components(period)
- }
-
requests = []
results = []
for rate in rates:
@@ -384,7 +425,7 @@ def _ensure_run_is_fresh(run: PayrollRun, period: PayrollPeriod) -> None:
"""Fail closed if approved inputs, rules or persisted snapshots changed."""
_, _, results = _calculate_current_period_inputs(period)
- current_input_hash = _hash_items([result.input_hash for _, result in results])
+ current_input_hash = _period_input_hash(period, results)
current_result_hash = _hash_items([result.result_hash for _, result in results])
first_result = results[0][1]
statements = {
@@ -487,7 +528,7 @@ def calculate_period(
]
or 0
) + 1
- aggregate_input_hash = _hash_items([result.input_hash for _, result in results])
+ aggregate_input_hash = _period_input_hash(period, results)
aggregate_result_hash = _hash_items([result.result_hash for _, result in results])
gross_total = sum(
(result.totals.gross_after_adjustments for _, result in results),
diff --git a/backend/payroll_core/balances.py b/backend/payroll_core/balances.py
new file mode 100644
index 00000000..a2146c2f
--- /dev/null
+++ b/backend/payroll_core/balances.py
@@ -0,0 +1,12 @@
+"""Versioned contribution shared by calculation, reconstruction and ledger."""
+
+from decimal import Decimal
+
+
+def carryover_contribution(kind: str, amount: Decimal, *, version: int = 2) -> Decimal:
+ if version not in (1, 2):
+ raise ValueError("Unsupported payroll balance rules version")
+ increases = kind in {"adjustment_debit", "deduction"} or (
+ kind == "payment" and version == 1
+ )
+ return amount if increases else -amount
diff --git a/backend/payroll_core/calculator.py b/backend/payroll_core/calculator.py
index 51e3414e..97630fe6 100644
--- a/backend/payroll_core/calculator.py
+++ b/backend/payroll_core/calculator.py
@@ -9,6 +9,7 @@ from datetime import date, datetime
from decimal import Context, Decimal, DecimalException, localcontext
from typing import Protocol
+from .balances import carryover_contribution
from .domain import (
CalculationWarning,
ExpectedTotals,
@@ -58,7 +59,7 @@ def _is_finite_decimal(value: object) -> bool:
class DeterministicPayrollCalculator:
"""Pure calculator with explicit Decimal and rounding semantics."""
- CALCULATOR_VERSION = "1.3.0"
+ CALCULATOR_VERSION = "1.4.0"
DECIMAL_PRECISION = 80
MAX_DECIMAL_DIGITS = 30
MAX_FRACTIONAL_PLACES = 12
@@ -158,7 +159,11 @@ class DeterministicPayrollCalculator:
):
lines.append(self._to_result_line(input_line, quantize))
- totals = self._calculate_totals(lines, opening_balance=opening_balance)
+ totals = self._calculate_totals(
+ lines,
+ opening_balance=opening_balance,
+ balance_rules_version=request.balance_rules_version,
+ )
if totals.payable < 0 and not rules.allow_negative_payable:
raise PayrollValidationError(
[
@@ -185,6 +190,11 @@ class DeterministicPayrollCalculator:
input_hash = _canonical_hash(request.calculation_dict())
rules_payload = rules.to_dict()
ruleset_hash = _canonical_hash(rules_payload)
+ calculator_version = (
+ "1.3.0"
+ if request.balance_rules_version == 1
+ else self.CALCULATOR_VERSION
+ )
result_payload = {
"employee_ref": request.employee_ref,
"period": request.period.to_dict(),
@@ -197,7 +207,7 @@ class DeterministicPayrollCalculator:
"ruleset_id": rules.ruleset_id,
"ruleset_version": rules.version,
"ruleset_hash": ruleset_hash,
- "calculator_version": self.CALCULATOR_VERSION,
+ "calculator_version": calculator_version,
"rounding_policy": rules.rounding.value,
"input_hash": input_hash,
}
@@ -219,7 +229,7 @@ class DeterministicPayrollCalculator:
ruleset_id=rules.ruleset_id,
ruleset_version=rules.version,
ruleset_hash=ruleset_hash,
- calculator_version=self.CALCULATOR_VERSION,
+ calculator_version=calculator_version,
rounding_policy=rules.rounding.value,
input_hash=input_hash,
result_hash=result_hash,
@@ -262,6 +272,7 @@ class DeterministicPayrollCalculator:
is_retro=input_line.is_retro,
calculated=False,
include_in_carryover=input_line.include_in_carryover,
+ include_in_salary=input_line.include_in_salary,
)
@staticmethod
@@ -269,45 +280,59 @@ class DeterministicPayrollCalculator:
lines: list[PayrollLine],
*,
opening_balance: Decimal = Decimal("0"),
+ balance_rules_version: int = 2,
) -> PayrollTotals:
earnings = sum(
- (line.amount for line in lines if line.kind is LineKind.EARNING),
+ (
+ line.amount
+ for line in lines
+ if line.kind is LineKind.EARNING and line.include_in_salary
+ ),
Decimal("0"),
)
adjustment_credits = sum(
- (line.amount for line in lines if line.kind is LineKind.ADJUSTMENT_CREDIT),
+ (
+ line.amount
+ for line in lines
+ if line.kind is LineKind.ADJUSTMENT_CREDIT and line.include_in_salary
+ ),
Decimal("0"),
)
adjustment_debits = sum(
- (line.amount for line in lines if line.kind is LineKind.ADJUSTMENT_DEBIT),
+ (
+ line.amount
+ for line in lines
+ if line.kind is LineKind.ADJUSTMENT_DEBIT and line.include_in_salary
+ ),
Decimal("0"),
)
deductions = sum(
- (line.amount for line in lines if line.kind is LineKind.DEDUCTION),
+ (
+ line.amount
+ for line in lines
+ if line.kind is LineKind.DEDUCTION and line.include_in_salary
+ ),
Decimal("0"),
)
payments = sum(
(line.amount for line in lines if line.kind is LineKind.PAYMENT),
Decimal("0"),
)
- manual_carryover_credits = sum(
+ non_salary_payments = sum(
(
line.amount
for line in lines
- if line.kind in (LineKind.EARNING, LineKind.ADJUSTMENT_CREDIT)
- and not line.calculated
- and line.include_in_carryover
+ if line.kind is LineKind.PAYMENT and not line.include_in_salary
),
Decimal("0"),
)
- manual_carryover_debits = sum(
+ carryover_change = sum(
(
- line.amount
+ carryover_contribution(
+ line.kind.value, line.amount, version=balance_rules_version
+ )
for line in lines
- if line.kind
- in (LineKind.ADJUSTMENT_DEBIT, LineKind.DEDUCTION, LineKind.PAYMENT)
- and not line.calculated
- and line.include_in_carryover
+ if not line.calculated and line.include_in_carryover
),
Decimal("0"),
)
@@ -321,10 +346,9 @@ class DeterministicPayrollCalculator:
deduction_total=deductions,
net_pay=net_pay,
payment_total=payments,
- payable=net_pay - payments,
- closing_balance=(
- opening_balance + manual_carryover_debits - manual_carryover_credits
- ),
+ payable=net_pay - payments + non_salary_payments,
+ non_salary_payment_total=non_salary_payments,
+ closing_balance=opening_balance + carryover_change,
)
@staticmethod
@@ -428,6 +452,16 @@ class DeterministicPayrollCalculator:
message="Employee reference is required.",
)
)
+ if type(
+ request.balance_rules_version
+ ) is not int or request.balance_rules_version not in (1, 2):
+ issues.append(
+ PayrollIssue(
+ code="INVALID_BALANCE_RULES_VERSION",
+ field="balance_rules_version",
+ message="Unsupported carryover rules version.",
+ )
+ )
period_is_valid = self._validate_period(request.period, "period", issues)
if not isinstance(request.currency, str) or not _CURRENCY_RE.fullmatch(
request.currency
@@ -693,6 +727,15 @@ class DeterministicPayrollCalculator:
)
return
+ for flag in ("include_in_salary", "include_in_carryover"):
+ if not isinstance(getattr(line, flag), bool):
+ issues.append(
+ PayrollIssue(
+ code="INVALID_COMPONENT_FLAG",
+ field=f"{prefix}.{flag}",
+ message="Component participation flags must be boolean.",
+ )
+ )
if not self._is_nonempty_string(line.line_id):
issues.append(
PayrollIssue(
diff --git a/backend/payroll_core/domain.py b/backend/payroll_core/domain.py
index 2372c231..92e19764 100644
--- a/backend/payroll_core/domain.py
+++ b/backend/payroll_core/domain.py
@@ -89,6 +89,7 @@ class InputLine:
source_period: PayrollPeriod | None = None
is_retro: bool = False
include_in_carryover: bool = True
+ include_in_salary: bool = True
def to_dict(self) -> dict[str, object]:
return {
@@ -104,6 +105,7 @@ class InputLine:
),
"is_retro": self.is_retro,
"include_in_carryover": self.include_in_carryover,
+ **({"include_in_salary": False} if not self.include_in_salary else {}),
}
@@ -152,11 +154,17 @@ class PayrollRequest:
point_rate_overridden: bool = True
lines: tuple[InputLine, ...] = dataclass_field(default_factory=tuple)
expected_totals: ExpectedTotals | None = None
+ balance_rules_version: int = 2
def to_dict(self) -> dict[str, object]:
ordered_lines = sorted(self.lines, key=lambda line: line.line_id)
return {
"employee_ref": self.employee_ref,
+ **(
+ {"balance_rules_version": self.balance_rules_version}
+ if self.balance_rules_version != 1
+ else {}
+ ),
"period": self.period.to_dict(),
"currency": self.currency,
"base_accrual": decimal_text(self.base_accrual),
@@ -236,6 +244,7 @@ class PayrollLine:
is_retro: bool = False
calculated: bool = False
include_in_carryover: bool = True
+ include_in_salary: bool = True
def to_dict(self) -> dict[str, object]:
return {
@@ -252,6 +261,7 @@ class PayrollLine:
"is_retro": self.is_retro,
"calculated": self.calculated,
"include_in_carryover": self.include_in_carryover,
+ **({"include_in_salary": False} if not self.include_in_salary else {}),
}
@@ -281,6 +291,11 @@ class PayrollTotals:
payment_total: Decimal
payable: Decimal
closing_balance: Decimal
+ non_salary_payment_total: Decimal = Decimal("0")
+
+ @property
+ def salary_payment_total(self):
+ return self.payment_total - self.non_salary_payment_total
@property
def recalculated_gross(self) -> Decimal:
@@ -299,6 +314,15 @@ class PayrollTotals:
"payment_total": decimal_text(self.payment_total),
"payable": decimal_text(self.payable),
"closing_balance": decimal_text(self.closing_balance),
+ **(
+ {
+ "non_salary_payment_total": decimal_text(
+ self.non_salary_payment_total
+ )
+ }
+ if self.non_salary_payment_total
+ else {}
+ ),
}
diff --git a/backend/payroll_core/tests/test_salary_participation.py b/backend/payroll_core/tests/test_salary_participation.py
new file mode 100644
index 00000000..62ab67a6
--- /dev/null
+++ b/backend/payroll_core/tests/test_salary_participation.py
@@ -0,0 +1,107 @@
+from datetime import date
+from decimal import Decimal
+
+import pytest
+
+from payroll_core import (
+ DeterministicPayrollCalculator,
+ InputLine,
+ LineKind,
+ PayrollPeriod,
+ PayrollRequest,
+ PayrollRules,
+)
+
+
+def request(*, kind=LineKind.PAYMENT, salary=True, carryover=True, version=2):
+ return PayrollRequest(
+ employee_ref="employee:42",
+ period=PayrollPeriod(date(2026, 8, 1), date(2026, 8, 31)),
+ currency="RUB",
+ base_accrual=Decimal("80000"),
+ base_source_ref="rate:42",
+ opening_balance=Decimal("5000"),
+ balance_rules_version=version,
+ lines=(
+ InputLine(
+ line_id="pay:1",
+ code="ADVANCE",
+ label="Аванс",
+ kind=kind,
+ amount=Decimal("10000"),
+ source_ref="fixture:1",
+ include_in_salary=salary,
+ include_in_carryover=carryover,
+ ),
+ ),
+ )
+
+
+RULES = PayrollRules(
+ ruleset_id="standard", version="1", effective_from=date(2026, 1, 1)
+)
+
+
+@pytest.mark.parametrize(
+ "kind,sign",
+ [
+ (LineKind.EARNING, -1),
+ (LineKind.ADJUSTMENT_CREDIT, -1),
+ (LineKind.ADJUSTMENT_DEBIT, 1),
+ (LineKind.DEDUCTION, 1),
+ (LineKind.PAYMENT, -1),
+ ],
+)
+@pytest.mark.parametrize("carryover", [True, False])
+def test_salary_exclusion_is_independent_of_carryover(kind, sign, carryover):
+ result = DeterministicPayrollCalculator().calculate(
+ request(kind=kind, salary=False, carryover=carryover), RULES
+ )
+ totals = result.totals
+ assert (
+ totals.gross_before_adjustments
+ == totals.gross_after_adjustments
+ == totals.net_pay
+ == totals.payable
+ == Decimal("80000")
+ )
+ assert totals.adjustment_total == totals.deduction_total == 0
+ assert totals.payment_total == (Decimal("10000") if kind == LineKind.PAYMENT else 0)
+ assert totals.non_salary_payment_total == totals.payment_total
+ assert totals.closing_balance == Decimal("5000") + (
+ Decimal("10000") * sign if carryover else 0
+ )
+ assert result.lines[-1].include_in_salary is False
+
+
+def test_salary_payment_still_subtracts_from_payable():
+ result = DeterministicPayrollCalculator().calculate(request(), RULES)
+ assert result.totals.payable == Decimal("70000")
+ assert result.totals.payment_total == Decimal("10000")
+ assert result.totals.non_salary_payment_total == 0
+ assert result.totals.closing_balance == Decimal("-5000")
+
+
+def test_old_request_and_result_hashes_are_byte_compatible():
+ result = DeterministicPayrollCalculator().calculate(request(version=1), RULES)
+ # Golden hashes produced by the unmodified 1.3.0 calculator.
+ assert (
+ result.input_hash
+ == "d398df4ebbed1f5469479dceea0a9a9fbb8dff00b57b1debab2256282ec22d06"
+ )
+ assert (
+ result.result_hash
+ == "ead44fd4cac168734c24e58de20f654e2c0406d4862ef3d9aaad68b800320b27"
+ )
+ assert result.calculator_version == "1.3.0"
+ assert result.totals.closing_balance == Decimal("15000")
+
+
+@pytest.mark.parametrize("kind", list(LineKind))
+def test_only_payment_changes_carryover_direction(kind):
+ calculator = DeterministicPayrollCalculator()
+ old = calculator.calculate(request(kind=kind, version=1), RULES)
+ current = calculator.calculate(request(kind=kind), RULES)
+ assert current.totals.payable == old.totals.payable
+ difference = Decimal("-20000") if kind == LineKind.PAYMENT else Decimal("0")
+ assert current.totals.closing_balance - old.totals.closing_balance == difference
diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py
index 1776b383..3d8caa69 100644
--- a/backend/tests/finance/test_payroll_admin_api.py
+++ b/backend/tests/finance/test_payroll_admin_api.py
@@ -209,6 +209,7 @@ def test_manager_can_customize_system_component_behavior(
"period_scope": "current_and_future",
"scope_period_id": period.pk,
"include_in_point_base": False,
+ "include_in_salary": True,
"include_in_carryover": True,
"visible_to_employee": False,
"is_custom": False,
diff --git a/backend/tests/finance/test_payroll_services.py b/backend/tests/finance/test_payroll_services.py
index 1b5d7fcd..c45a1fee 100644
--- a/backend/tests/finance/test_payroll_services.py
+++ b/backend/tests/finance/test_payroll_services.py
@@ -644,7 +644,7 @@ def test_closing_period_freezes_component_configuration(
closed_components = {
component.code: component for component in get_payroll_components(june_period)
}
- assert june_period.component_snapshot["version"] == 1
+ assert june_period.component_snapshot["version"] == 2
assert closed_components["BONUS"].name == "Премия до закрытия"
assert closed_components["BONUS"].highlight_color == "green"
assert closed_components[custom.code].name == "Историческая колонка"
diff --git a/backend/tests/finance/test_salary_participation.py b/backend/tests/finance/test_salary_participation.py
new file mode 100644
index 00000000..ec4a8312
--- /dev/null
+++ b/backend/tests/finance/test_salary_participation.py
@@ -0,0 +1,283 @@
+import calendar
+from datetime import date
+from decimal import Decimal
+from importlib import import_module
+
+import pytest
+from django.apps import apps
+from django.core.exceptions import ValidationError
+from django.urls import reverse
+from django.utils import timezone
+
+from api.v1.finance.admin.workspace_payload import _period_input_state
+from api.v1.finance.serializers import OwnPayrollStatementSerializer
+from finance.models import (
+ PayrollCustomComponent,
+ PayrollInputLine,
+ PayrollPeriod,
+)
+from finance.payroll.balances import opening_balances
+from finance.payroll.components import (
+ build_payroll_component_snapshot,
+ get_payroll_components,
+)
+from finance.payroll.exceptions import PayrollOperationError
+from finance.payroll.services import (
+ _ensure_run_is_fresh,
+ calculate_period,
+)
+from tests.finance.test_payroll_admin_api import grant, make_approved_period_inputs
+
+pytestmark = pytest.mark.django_db
+
+
+@pytest.fixture
+def setup(user_factory, auth_client_factory):
+ manager, employee, checker = user_factory(), user_factory(), user_factory()
+ grant(manager, "manage_payroll_inputs", "view_all_payroll", "calculate_payroll")
+ periods = {}
+ for month in (8, 9, 10, 11):
+ periods[month] = PayrollPeriod.objects.create(
+ code=f"2026-{month:02}",
+ date_from=date(2026, month, 1),
+ date_to=date(2026, month, calendar.monthrange(2026, month)[1]),
+ created_by=manager,
+ status="closed" if month < 10 else "open",
+ balance_rules_version=1 if month < 10 else 2,
+ )
+ component = PayrollCustomComponent.objects.create(
+ name="Выплата долга",
+ kind="payment",
+ include_in_salary=False,
+ include_in_carryover=True,
+ period_scope="all",
+ created_by=manager,
+ )
+ return manager, employee, checker, periods, component, auth_client_factory(manager)
+
+
+def line(setup, period, **overrides):
+ manager, employee, _, _, component, _ = setup
+ return PayrollInputLine.objects.create(
+ period=period,
+ employee=employee,
+ component_code=component.code,
+ amount="5000",
+ status="approved",
+ created_by=manager,
+ approved_by=setup[2],
+ approved_at=timezone.now(),
+ **overrides,
+ )
+
+
+def test_previous_period_payments_keep_legacy_sign_and_current_payments_reduce_debt(
+ setup,
+):
+ _, employee, _, periods, component, client = setup
+ for month in (8, 9):
+ # An old v1 snapshot contains no salary-participation flag.
+ snapshot = build_payroll_component_snapshot(periods[month])
+ snapshot["version"] = 1
+ for payload in snapshot["components"]:
+ payload.pop("include_in_salary")
+ PayrollPeriod.objects.filter(pk=periods[month].pk).update(
+ component_snapshot=snapshot
+ )
+ line(setup, periods[month])
+ line(setup, periods[10])
+ assert opening_balances(periods[10], [employee.pk])[employee.pk] == Decimal("10000")
+ assert opening_balances(periods[11], [employee.pk])[employee.pk] == Decimal("5000")
+ ledger = client.get(
+ reverse(
+ "api:v1:finance-payroll:admin-period-carryovers",
+ kwargs={"pk": periods[10].pk},
+ )
+ )
+ assert ledger.status_code == 200, ledger.content
+ contributions = {
+ entry["period"]["id"]: Decimal(entry["contribution"])
+ for entry in ledger.json()["entries"]
+ }
+ assert contributions == {
+ periods[8].pk: Decimal("5000"),
+ periods[9].pk: Decimal("5000"),
+ periods[10].pk: Decimal("-5000"),
+ }
+ for month in (8, 9):
+ periods[month].refresh_from_db()
+ assert (
+ get_payroll_components(periods[month])
+ .get(code=component.code)
+ .include_in_salary
+ is True
+ )
+
+
+def test_excluded_payment_does_not_reduce_current_salary_or_point_base(setup):
+ manager, employee, checker, periods, component, _ = setup
+ make_approved_period_inputs(
+ period=periods[10], employee=employee, creator=manager, approver=checker
+ )
+ component.include_in_point_base = (
+ True # Defensive adapter protection, even against bad ORM data.
+ )
+ component.save(update_fields=["include_in_point_base"])
+ line(setup, periods[10])
+ run = calculate_period(periods[10].pk, actor=manager)
+ statement = run.statements.get(employee=employee)
+ assert statement.payable == Decimal("80000")
+ assert statement.gross_total == Decimal("80000")
+ assert statement.payment_total == Decimal("5000")
+ assert statement.closing_balance == Decimal("-5000")
+ assert statement.input_snapshot["point_base_accrual"] == "80000"
+ assert statement.result_snapshot["totals"]["non_salary_payment_total"] == "5000"
+ assert statement.input_snapshot["lines"][0]["include_in_salary"] is False
+ detail = OwnPayrollStatementSerializer(statement).data
+ assert Decimal(detail["non_salary_payment_total"]) == Decimal("5000")
+ assert (
+ next(item for item in detail["lines"] if item["code"] == component.code)[
+ "include_in_salary"
+ ]
+ is False
+ )
+
+
+def test_inactive_employee_payment_without_work_does_not_block_or_generate_payslip(
+ setup, user_factory
+):
+ manager, employee, checker, periods, component, client = setup
+ make_approved_period_inputs(
+ period=periods[10], employee=employee, creator=manager, approver=checker
+ )
+ former = user_factory(active=False)
+ PayrollInputLine.objects.create(
+ period=periods[10],
+ employee=former,
+ component_code=component.code,
+ amount="3500",
+ status="approved",
+ created_by=manager,
+ approved_by=checker,
+ approved_at=timezone.now(),
+ )
+ state = _period_input_state(periods[10])
+ assert state.approved_input_outside_roster_employee_ids == []
+ run = calculate_period(periods[10].pk, actor=manager)
+ assert not run.statements.filter(employee=former).exists()
+ table = client.get(
+ reverse(
+ "api:v1:finance-payroll:admin-period-table", kwargs={"pk": periods[10].pk}
+ )
+ ).json()
+ row = next(item for item in table["rows"] if item["employee"]["id"] == former.pk)
+ assert row["status"] == "balance_only"
+ assert Decimal(row["payment_total"]) == Decimal("3500")
+ assert Decimal(row["payable"]) == 0
+ assert Decimal(row["closing_balance"]) == Decimal("-3500")
+ assert Decimal(table["recorded_payments"]["total_recorded"]) == Decimal("3500")
+ _ensure_run_is_fresh(run, periods[10])
+ PayrollInputLine.objects.create(
+ period=periods[10],
+ employee=former,
+ component_code=component.code,
+ amount="100",
+ status="approved",
+ created_by=manager,
+ approved_by=checker,
+ approved_at=timezone.now(),
+ )
+ with pytest.raises(PayrollOperationError) as error:
+ _ensure_run_is_fresh(run, periods[10])
+ assert error.value.code == "RUN_STALE_RECALCULATION_REQUIRED"
+
+
+@pytest.mark.parametrize("builtin", [False, True])
+def test_component_setting_is_saved_and_point_base_is_cleared(setup, builtin):
+ _, _, _, periods, component, client = setup
+ code = "BONUS" if builtin else component.code
+ payload = {
+ "name": "Отдельная выплата" if builtin else component.name,
+ "kind": "payment",
+ "period_scope": "current_and_future",
+ "period_id": periods[10].pk,
+ "requires_reason": False,
+ "include_in_salary": False,
+ "include_in_carryover": True,
+ "include_in_point_base": True,
+ "visible_to_employee": False,
+ }
+ url = reverse(
+ "api:v1:finance-payroll:admin-component-detail", kwargs={"code": code}
+ )
+ response = client.patch(url, payload, format="json")
+ assert response.status_code == 200, response.content
+ assert response.json()["include_in_salary"] is False
+ assert response.json()["include_in_point_base"] is False
+ payload.pop("include_in_salary")
+ response = client.patch(url, payload, format="json")
+ assert response.status_code == 200, response.content
+ assert response.json()["include_in_salary"] is False
+ assert response.json()["include_in_point_base"] is False
+
+
+def test_migration_preserves_old_snapshots_and_only_activates_new_open_periods(setup):
+ _, _, _, periods, _, _ = setup
+ old_snapshot = {"version": 1, "components": []}
+ PayrollPeriod.objects.filter(pk=periods[8].pk).update(
+ component_snapshot=old_snapshot
+ )
+ PayrollPeriod.objects.all().update(balance_rules_version=1)
+ migration = import_module(
+ "finance.migrations.0041_salary_participation_and_balance_rules"
+ )
+ migration.activate_current_rules(apps, None)
+ for month, period in periods.items():
+ period.refresh_from_db()
+ assert period.balance_rules_version == (1 if month < 10 else 2)
+ assert periods[8].component_snapshot == old_snapshot
+ september = periods[9]
+ assert september.component_snapshot["version"] == 1
+ assert all(
+ component.include_in_salary for component in get_payroll_components(september)
+ )
+ assert not periods[10].component_snapshot
+ with pytest.raises(ValidationError):
+ september.balance_rules_version = 2
+ september.save(update_fields=["balance_rules_version"])
+
+
+def test_migration_keeps_existing_unclosed_run_fresh_and_freezes_its_settings(setup):
+ manager, employee, checker, periods, component, _ = setup
+ PayrollPeriod.objects.all().update(balance_rules_version=1)
+ current = PayrollPeriod.objects.get(pk=periods[10].pk)
+ component.include_in_salary = True
+ component.save(update_fields=["include_in_salary"])
+ make_approved_period_inputs(
+ period=current, employee=employee, creator=manager, approver=checker
+ )
+ line(setup, current)
+ run = calculate_period(current.pk, actor=manager)
+ previous = list(
+ run.statements.values(
+ "input_snapshot", "result_snapshot", "input_hash", "result_hash"
+ )
+ )
+ import_module(
+ "finance.migrations.0041_salary_participation_and_balance_rules"
+ ).activate_current_rules(apps, None)
+ current.refresh_from_db()
+ periods[11].refresh_from_db()
+ assert current.balance_rules_version == 1
+ assert periods[11].balance_rules_version == 2
+ component.include_in_salary = False
+ component.save(update_fields=["include_in_salary"])
+ _ensure_run_is_fresh(run, current)
+ assert (
+ list(
+ run.statements.values(
+ "input_snapshot", "result_snapshot", "input_hash", "result_hash"
+ )
+ )
+ == previous
+ )
diff --git a/frontend/src/components/finance/PayrollStatementDetailModal.tsx b/frontend/src/components/finance/PayrollStatementDetailModal.tsx
index f7e84f16..c99645b1 100644
--- a/frontend/src/components/finance/PayrollStatementDetailModal.tsx
+++ b/frontend/src/components/finance/PayrollStatementDetailModal.tsx
@@ -330,6 +330,16 @@ export function PayrollStatementDetailModal({
+ {Number(detail.non_salary_payment_total || 0) > 0 ? (
+
+ В том числе вне расчёта зарплаты:{" "}
+ {formatPayrollMoney(
+ detail.non_salary_payment_total!,
+ detail.currency,
+ )}
+ . Эта выплата не вычитается из зарплаты текущего периода.
+
+ ) : null}
@@ -415,6 +425,11 @@ export function PayrollStatementDetailModal({
Перерасчёт
) : null}
+ {line.include_in_salary === false ? (
+
+ Вне расчёта зарплаты
+
+ ) : null}
{sourcePeriod ? (
diff --git a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx
index c49f1aad..937c1644 100644
--- a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx
+++ b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx
@@ -80,6 +80,10 @@ const statusMeta: Record<
ready: { label: "Готово", className: "app-selected" },
draft: { label: "Есть черновики", className: "app-feedback-warning" },
incomplete: { label: "Не заполнено", className: "app-feedback-danger" },
+ balance_only: {
+ label: "Вне расчёта зарплаты",
+ className: "app-feedback-info",
+ },
};
const kindLabels = {
@@ -723,6 +727,7 @@ function PayrollComponentSettingsModal({
rows,
components,
currency,
+ balanceRulesVersion,
onSave,
onDelete,
onClose,
@@ -731,6 +736,7 @@ function PayrollComponentSettingsModal({
rows: PayrollPeriodTableRow[];
components: PayrollPeriodTableComponent[];
currency: string;
+ balanceRulesVersion?: number;
onSave: (payload: PayrollCustomComponentWrite) => Promise;
onDelete?: (mode: "current" | "current_and_future") => Promise;
onClose: () => void;
@@ -740,6 +746,9 @@ function PayrollComponentSettingsModal({
component?.kind || "earning",
);
const requiresReason = component?.requires_reason || false;
+ const [includeInSalary, setIncludeInSalary] = useState(
+ component?.include_in_salary ?? true,
+ );
const [includeInPointBase, setIncludeInPointBase] = useState(
component?.include_in_point_base || false,
);
@@ -814,7 +823,9 @@ function PayrollComponentSettingsModal({
name: normalizedName,
kind,
requires_reason: requiresReason,
- include_in_point_base: isScheduled ? false : includeInPointBase,
+ include_in_salary: !isScheduled && includeInSalary,
+ include_in_point_base:
+ !isScheduled && includeInSalary && includeInPointBase,
include_in_carryover: isScheduled ? false : includeInCarryover,
visible_to_employee: isScheduled ? false : visibleToEmployee,
highlight_color: highlightColor,
@@ -858,7 +869,9 @@ function PayrollComponentSettingsModal({
kind,
display_order: component?.display_order || 0,
requires_reason: requiresReason,
- include_in_point_base: isScheduled ? false : includeInPointBase,
+ include_in_salary: !isScheduled && includeInSalary,
+ include_in_point_base:
+ !isScheduled && includeInSalary && includeInPointBase,
include_in_carryover: isScheduled ? false : includeInCarryover,
visible_to_employee: isScheduled ? false : visibleToEmployee,
period_scope: isScheduled ? "current_and_future" : periodScope,
@@ -870,11 +883,13 @@ function PayrollComponentSettingsModal({
const calculationPreview = componentCalculationHelp(
previewComponent,
components,
+ balanceRulesVersion,
);
const pointBaseAssessment = assessPayrollPointBaseChange(rows, components, {
code: component?.code || "PREVIEW",
kind,
include_in_point_base: includeInPointBase,
+ include_in_salary: !isScheduled && includeInSalary,
});
if (typeof document === "undefined") return null;
@@ -1068,6 +1083,28 @@ function PayrollComponentSettingsModal({
автоматически не копируются.
+
+ {
+ setIncludeInSalary(event.target.checked);
+ if (!event.target.checked) setIncludeInPointBase(false);
+ }}
+ className="mt-0.5 h-4 w-4 rounded"
+ disabled={pending || isScheduled}
+ />
+
+
+ Учитывать в расчёте зарплаты
+
+
+ Без галочки сумма не меняет зарплату и «К выплате». Фактические
+ выплаты остаются в учёте выданных денег. Переходящие остатки
+ настраиваются отдельно.
+
+
+
setIncludeInPointBase(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded"
- disabled={pending || isScheduled}
+ disabled={pending || isScheduled || !includeInSalary}
/>
@@ -1457,13 +1494,14 @@ function calculationHelps(
formula:
"Выплачено = Аванс + Фактическая выплата + Σ прочих операций типа «Уже выплачено»",
manual:
- "Сам итог не редактируется. Введите сумму в ячейке «Аванс» или «Фактическая выплата» — она уменьшит остаток к выплате.",
+ "Сам итог не редактируется. Все фактические выплаты учитываются здесь, в том числе исключённые из расчёта зарплаты.",
},
payable: {
title: "К выплате",
logic:
"Остаток зарплаты только за выбранный период. Зарплата, рассчитанная по баллам, может быть ниже или выше оклада — это нормальный результат периода и в переходящий остаток не попадает.",
- formula: "К выплате = После удержаний − Выплачено",
+ formula:
+ "К выплате = После удержаний − Σ выплат с галочкой «Учитывать в расчёте зарплаты»",
manual:
"Нет. Начисления и корректировки меняют зарплату периода; удержания уменьшают её; «Аванс» и «Фактическая выплата» фиксируют уже выданные деньги.",
},
@@ -1482,6 +1520,7 @@ function calculationHelps(
function componentCalculationHelp(
component: PayrollPeriodTableComponent,
components: PayrollPeriodTableComponent[] = [],
+ balanceRulesVersion = 2,
): CalculationHelp {
if (component.kind === "scheduled_change") {
const target = components.find(
@@ -1497,7 +1536,7 @@ function componentCalculationHelp(
"Да. В ячейке маркера задаётся итоговая знаковая сумма для сотрудника. После применения в целевой ячейке отображается отметка с источником изменения.",
};
}
- const flow = {
+ const salaryFlow = {
earning: {
effect:
"увеличивает «До корректировок», затем «Начислено», «После удержаний» и остаток «К выплате»",
@@ -1528,22 +1567,34 @@ function componentCalculationHelp(
formula: `${component.label} → без влияния`,
},
}[component.kind];
- const decreasesCarryover = ["earning", "adjustment_credit"].includes(
- component.kind,
- );
+ const flow =
+ component.include_in_salary === false
+ ? {
+ effect:
+ component.kind === "payment"
+ ? "увеличивает «Выплачено», но не меняет зарплату и «К выплате»"
+ : "не меняет зарплату, удержания и «К выплате»",
+ formula: `${component.label} → вне расчёта зарплаты${component.kind === "payment" ? "; Выплачено (+)" : ""}`,
+ }
+ : salaryFlow;
+ const decreasesCarryover =
+ (component.kind === "payment" && balanceRulesVersion >= 2) ||
+ ["earning", "adjustment_credit"].includes(component.kind);
const decreasesPointBase = [
"adjustment_debit",
"deduction",
"payment",
].includes(component.kind);
- const pointBaseEffect = component.include_in_point_base
- ? decreasesPointBase
- ? "В балльной базе вычитается: меняет цену балла и перерасчёт по фактическим баллам."
- : "В балльной базе прибавляется: меняет цену балла и перерасчёт по фактическим баллам."
- : "На балльную базу, цену балла и перерасчёт по баллам не влияет.";
- const pointBaseFormula = component.include_in_point_base
- ? `Вклад в балльную базу: ${decreasesPointBase ? "−" : "+"} сумма «${component.label}»`
- : `Балльная база: «${component.label}» не участвует`;
+ const pointBaseEffect =
+ component.include_in_point_base && component.include_in_salary !== false
+ ? decreasesPointBase
+ ? "В балльной базе вычитается: меняет цену балла и перерасчёт по фактическим баллам."
+ : "В балльной базе прибавляется: меняет цену балла и перерасчёт по фактическим баллам."
+ : "На балльную базу, цену балла и перерасчёт по баллам не влияет.";
+ const pointBaseFormula =
+ component.include_in_point_base && component.include_in_salary !== false
+ ? `Вклад в балльную базу: ${decreasesPointBase ? "−" : "+"} сумма «${component.label}»`
+ : `Балльная база: «${component.label}» не участвует`;
const carryoverEffect = component.include_in_carryover
? decreasesCarryover
? "Уменьшает переходящий остаток следующего периода; значение ячейки не копируется."
@@ -2348,7 +2399,11 @@ export function PayrollPeriodTableView({
>
{
@@ -2520,8 +2575,7 @@ export function PayrollPeriodTableView({
title: "Остаток к переносу",
logic:
"Накопленная разница из строк колонок с включённой настройкой «Учитывать в переходящих остатках» и действующих ручных корректировок остатка. Ручная корректировка учитывается только с выбранного открытого периода и далее, без изменения закрытых снимков.\n\nЗначения ячеек не копируются в колонки следующего периода. Плюс — накопленная недоплата, минус — накопленная переплата. Зарплату и рассылку ручная корректировка не меняет.",
- formula:
- "Остаток на конец = Остаток на начало\n+ учитываемые отрицательные корректировки, удержания и выплаты\n− учитываемые начисления и положительные корректировки\n\nОстаток на начало учитывает утверждённые строки предыдущих периодов и действующие ручные корректировки, начиная с указанного для них периода",
+ formula: `Остаток на конец = Остаток на начало\n+ учитываемые отрицательные корректировки и удержания${data.balance_rules_version === 1 ? " и выплаты" : ""}\n− учитываемые начисления и положительные корректировки${data.balance_rules_version === 1 ? "" : " и выплаты"}\n\nОстаток на начало учитывает утверждённые строки предыдущих периодов по их сохранённым правилам и действующие ручные корректировки, начиная с указанного для них периода`,
manual:
"Карандаш в заголовке открывает реестр переходящих остатков. В нём можно добавить ручную корректировку с открытого периода без расчёта или аннулировать её до расчёта. Исходную строку открытого периода можно включить или исключить из остатка. Участие всей колонки настраивается её карандашом в блоке «Начисления и выплаты».",
}}
@@ -3809,6 +3863,7 @@ export function PayrollPeriodTableView({
rows={data.rows}
components={data.component_columns}
currency={data.currency}
+ balanceRulesVersion={data.balance_rules_version}
onSave={(payload) =>
editingComponent && onUpdateComponent
? onUpdateComponent(editingComponent, payload)
diff --git a/frontend/src/lib/api/finance.ts b/frontend/src/lib/api/finance.ts
index b9f0d287..53c980d4 100644
--- a/frontend/src/lib/api/finance.ts
+++ b/frontend/src/lib/api/finance.ts
@@ -48,6 +48,7 @@ export type PayrollStatementSummary = {
};
export type PayrollStatementLine = {
+ include_in_salary?: boolean;
code: string;
label: string;
kind: PayrollLineKind;
@@ -59,6 +60,7 @@ export type PayrollStatementLine = {
};
export type PayrollStatement = PayrollStatementSummary & {
+ non_salary_payment_total?: string;
point_delta: string | null;
gross_before_adjustments: string;
adjustment_total: string;
@@ -404,9 +406,11 @@ export type PayrollPeriodTableStatus =
| "calculated"
| "ready"
| "draft"
- | "incomplete";
+ | "incomplete"
+ | "balance_only";
export type PayrollPeriodTableComponent = {
+ include_in_salary?: boolean;
code: string;
label: string;
kind: PayrollLineKind;
@@ -457,6 +461,7 @@ export type PayrollAttendanceAlert = {
};
export type PayrollPeriodTableRow = {
+ non_salary_payment_total?: string;
off_cycle_settlement?: PayrollOffCycleBalance | null;
employee: PayrollAdminEmployee & {
first_name: string;
@@ -518,6 +523,7 @@ export type PayrollPeriodTableCarryoverSummary = {
};
export type PayrollPeriodTable = {
+ balance_rules_version?: number;
recorded_payments?: {
payroll_recorded: string;
outside_period: string;
@@ -644,6 +650,7 @@ export type PayrollPointBreakdown = {
};
export type PayrollComponent = {
+ include_in_salary?: boolean;
id: string;
code: string;
name: string;
@@ -671,6 +678,7 @@ export type PayrollColumnHighlightColor =
| "violet";
export type PayrollCustomComponentWrite = {
+ include_in_salary?: boolean;
name: string;
kind: PayrollLineKind;
requires_reason: boolean;
diff --git a/frontend/src/lib/payroll-point-base.test.js b/frontend/src/lib/payroll-point-base.test.js
index a4fe2a63..26739f1d 100644
--- a/frontend/src/lib/payroll-point-base.test.js
+++ b/frontend/src/lib/payroll-point-base.test.js
@@ -20,6 +20,15 @@ const payment = {
include_in_point_base: false,
};
+test("salary-excluded columns never affect the point base", () => {
+ const result = assessPayrollPointBaseChange(
+ [employee(1, "5000", { SALARY_PAYMENT: "10000" })],
+ [bonus, payment],
+ { ...payment, include_in_point_base: true, include_in_salary: false },
+ );
+ assert.deepEqual(result, { negative: [], zero: [] });
+});
+
test("blocks a component setting that makes an affected point base negative", () => {
const result = assessPayrollPointBaseChange(
[employee(1, null, { SALARY_PAYMENT: "5000.00" })],
diff --git a/frontend/src/lib/payroll-point-base.ts b/frontend/src/lib/payroll-point-base.ts
index 1d9d1d9f..db2418c9 100644
--- a/frontend/src/lib/payroll-point-base.ts
+++ b/frontend/src/lib/payroll-point-base.ts
@@ -2,6 +2,7 @@ type PointBaseComponent = {
code: string;
kind: string;
include_in_point_base: boolean;
+ include_in_salary?: boolean;
};
type PointBaseRow = {
@@ -68,7 +69,11 @@ export function assessPayrollPointBaseChange(
let pointBase = decimalToScaled(row.rate_amount);
for (const component of effectiveComponents) {
- if (!component.include_in_point_base) continue;
+ if (
+ !component.include_in_point_base ||
+ component.include_in_salary === false
+ )
+ continue;
const amount = decimalToScaled(row.component_amounts[component.code]);
pointBase += NEGATIVE_KINDS.has(component.kind) ? -amount : amount;
}