from decimal import Decimal from django.contrib.auth import get_user_model from django.db import IntegrityError, transaction from django.db.models import Sum from django.utils import timezone from finance.enums import PayrollPeriodStatus from finance.models import ( PayrollAuditEvent, PayrollOffCyclePayment, PayrollPeriod, PayrollStatement, ) from finance.payroll.access import can_access_payroll_management, has_payroll_permission from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied ZERO = Decimal("0.00") def _require_write_access(actor): if not can_access_payroll_management(actor) or not has_payroll_permission( actor, "finance.manage_payroll_inputs" ): raise PayrollPermissionDenied("finance.manage_payroll_inputs") def statement_settlement(statement): paid = ( statement.off_cycle_payments.filter(voided_at__isnull=True).aggregate( total=Sum("amount") )["total"] or ZERO ) confirmed = hasattr(statement, "payment_confirmation") return { "original_payable": statement.payable, "paid_outside_period": paid, "remaining": ZERO if confirmed else max(statement.payable - paid, ZERO), "confirmed": confirmed, } def _audit(payment, actor, action, **metadata): PayrollAuditEvent.objects.create( actor=actor, action=action, object_type=payment._meta.label_lower, object_id=str(payment.pk), period=payment.source_period, metadata={"channel": "off_cycle_payments", **metadata}, ) @transaction.atomic def record_payment(*, actor, employee, payee_name, source_period, **fields): _require_write_access(actor) # The actor lock serializes retries with the same idempotency key. get_user_model().objects.select_for_update().get(pk=actor.pk) existing = PayrollOffCyclePayment.objects.filter( request_id=fields["request_id"] ).first() if existing: same = ( existing.created_by_id == actor.pk and existing.employee_id == (employee.pk if employee else None) and existing.payee_name == payee_name and existing.source_period_id == (source_period.pk if source_period else None) and all(getattr(existing, key) == value for key, value in fields.items()) ) if not same: raise PayrollOperationError( "PAYMENT_REQUEST_CONFLICT", "Этот запрос уже использован для другой выплаты.", ) return existing, False statement = None if source_period: source_period = PayrollPeriod.objects.select_for_update().get( pk=source_period.pk ) if source_period.status != PayrollPeriodStatus.CLOSED: raise PayrollOperationError( "PAYMENT_SOURCE_NOT_CLOSED", "Для погашения долга выберите закрытый период.", ) if fields["currency"] != source_period.currency: raise PayrollOperationError( "PAYMENT_CURRENCY_MISMATCH", "Валюта выплаты не совпадает с периодом." ) if employee: statement = ( PayrollStatement.objects.select_for_update() .filter( run_id=source_period.current_run_id, employee=employee, ) .first() ) if statement is None: raise PayrollOperationError( "PAYMENT_STATEMENT_NOT_FOUND", "У сотрудника нет итогового листка в выбранном периоде.", ) settlement = statement_settlement(statement) if fields["amount"] > settlement["remaining"]: raise PayrollOperationError( "PAYMENT_EXCEEDS_REMAINING", f"Сумма превышает остаток долга: {settlement['remaining']:.2f} {fields['currency']}.", ) payment = PayrollOffCyclePayment( employee=employee, payee_name=payee_name, source_period=source_period, source_statement=statement, created_by=actor, **fields, ) payment.full_clean(validate_unique=False) try: with transaction.atomic(): payment.save() except IntegrityError: if PayrollOffCyclePayment.objects.filter( request_id=fields["request_id"] ).exists(): raise PayrollOperationError( "PAYMENT_REQUEST_CONFLICT", "Этот запрос уже использован для другой выплаты.", ) raise _audit(payment, actor, "payroll.off_cycle_payment_created") return payment, True @transaction.atomic def void_payment(payment_id, *, actor, reason): _require_write_access(actor) # Same lock order as creation/confirmation; nullable relations are not joined. initial = PayrollOffCyclePayment.objects.get(pk=payment_id) if initial.source_period_id: PayrollPeriod.objects.select_for_update().get(pk=initial.source_period_id) if initial.source_statement_id: PayrollStatement.objects.select_for_update().get(pk=initial.source_statement_id) payment = PayrollOffCyclePayment.objects.select_for_update().get(pk=payment_id) if payment.voided_at: return payment payment.voided_at = timezone.now() payment.voided_by = actor payment.void_reason = reason payment.full_clean() payment.save(update_fields=["voided_at", "voided_by", "void_reason"]) _audit(payment, actor, "payroll.off_cycle_payment_voided", reason=reason) return payment