""" app/tenant/pay_periods/routes.py Pay period management: calculate, list, approve, mark paid. Engine: hourly (rate Ɨ hours), salary (fixed), guarantee (max of guarantee vs commission). """ import logging from datetime import datetime, timezone, date, timedelta from decimal import Decimal, ROUND_HALF_UP from flask import Blueprint, render_template, redirect, url_for, flash, request, g from flask_login import login_required from app.extensions import db from app.models.salon import Staff, StaffPayPeriod, StaffClocking, CommissionLog from app.decorators import require_role, demo_readonly from app.tenant.utils import log_tenant_action logger = logging.getLogger(__name__) pay_periods_bp = Blueprint("pay_periods", __name__, url_prefix="/pay-periods") def _period_bounds(period_type: str, ref_date: date): """Return (start, end) date for the pay period containing ref_date.""" if period_type == "weekly": start = ref_date - timedelta(days=ref_date.weekday()) end = start + timedelta(days=6) elif period_type == "biweekly": # Anchor biweekly to 2025-01-06 (a Monday) anchor = date(2025, 1, 6) delta = (ref_date - anchor).days week_num = delta // 7 period_num = week_num // 2 start = anchor + timedelta(weeks=period_num * 2) end = start + timedelta(days=13) else: # monthly start = ref_date.replace(day=1) if start.month == 12: end = date(start.year + 1, 1, 1) - timedelta(days=1) else: end = date(start.year, start.month + 1, 1) - timedelta(days=1) return start, end def calculate_pay_period(staff: Staff, period_start: date, period_end: date) -> dict: """ Calculate pay for a staff member over the given period. Returns a dict with base_amount, commission_amount, guarantee_topup, total_amount. """ # Total minutes clocked in the period start_dt = datetime.combine(period_start, datetime.min.time()).replace(tzinfo=timezone.utc) end_dt = datetime.combine(period_end, datetime.max.time()).replace(tzinfo=timezone.utc) clockings = StaffClocking.query.filter_by( tenant_id=staff.tenant_id, staff_id=staff.id ).filter( StaffClocking.clocked_in_at >= start_dt, StaffClocking.clocked_in_at <= end_dt, StaffClocking.clocked_out_at.isnot(None), ).all() total_minutes = sum(c.total_minutes or 0 for c in clockings) total_hours = Decimal(total_minutes) / Decimal(60) # Commission for the period comm_logs = CommissionLog.query.filter_by( tenant_id=staff.tenant_id, staff_id=staff.id ).filter( CommissionLog.period.isnot(None), ).all() # Filter by date range using the transaction's created_at via join from app.models.salon import Transaction comm_rows = ( db.session.query(CommissionLog) .join(Transaction, CommissionLog.transaction_id == Transaction.id) .filter( CommissionLog.tenant_id == staff.tenant_id, CommissionLog.staff_id == staff.id, Transaction.created_at >= start_dt, Transaction.created_at <= end_dt, Transaction.voided_at.is_(None), ).all() ) commission_amount = sum( Decimal(str(c.amount)) for c in comm_rows ) pay_type = staff.pay_type base_amount = Decimal("0") guarantee_topup = Decimal("0") if pay_type == "hourly": rate = Decimal(str(staff.hourly_rate or 0)) base_amount = (rate * total_hours).quantize( Decimal("0.01"), rounding=ROUND_HALF_UP ) elif pay_type == "salary": base_amount = Decimal(str(staff.salary_amount or 0)) elif pay_type == "guarantee": guarantee = Decimal(str(staff.guarantee_amount or 0)) if commission_amount >= guarantee: base_amount = commission_amount commission_amount = Decimal("0") # rolled into base else: base_amount = guarantee guarantee_topup = (guarantee - commission_amount).quantize( Decimal("0.01"), rounding=ROUND_HALF_UP ) commission_amount = Decimal("0") # topup covers the gap # Commission on top of hourly/salary (if enabled) if pay_type in ("hourly", "salary") and not staff.commission_enabled: commission_amount = Decimal("0") total_amount = (base_amount + commission_amount + guarantee_topup).quantize( Decimal("0.01"), rounding=ROUND_HALF_UP ) return { "pay_type": pay_type, "base_amount": float(base_amount), "commission_amount": float(commission_amount), "guarantee_topup": float(guarantee_topup), "total_amount": float(total_amount), "total_hours": float(total_hours), } @pay_periods_bp.route("/") @login_required @require_role("tenant_admin", "tenant_manager") def index(): periods = ( StaffPayPeriod.query.filter_by(tenant_id=g.tenant.id) .order_by(StaffPayPeriod.period_start.desc()) .limit(100) .all() ) staff_list = Staff.query.filter_by( tenant_id=g.tenant.id, is_active=True ).filter(Staff.deleted_at.is_(None)).order_by(Staff.name).all() return render_template( "tenant/pay_periods/index.html", periods=periods, staff_list=staff_list, ) @pay_periods_bp.route("/calculate", methods=["GET", "POST"]) @login_required @require_role("tenant_admin") @demo_readonly def calculate(): staff_list = Staff.query.filter_by( tenant_id=g.tenant.id, is_active=True ).filter(Staff.deleted_at.is_(None)).order_by(Staff.name).all() results = [] period_start = None period_end = None if request.method == "POST": period_start_raw = request.form.get("period_start", "") period_end_raw = request.form.get("period_end", "") staff_ids = request.form.getlist("staff_ids") try: period_start = date.fromisoformat(period_start_raw) period_end = date.fromisoformat(period_end_raw) except ValueError: flash("Invalid date range.", "danger") return render_template( "tenant/pay_periods/calculate.html", staff_list=staff_list, results=[], period_start=None, period_end=None, ) if period_end < period_start: flash("End date must be after start date.", "danger") return render_template( "tenant/pay_periods/calculate.html", staff_list=staff_list, results=[], period_start=period_start, period_end=period_end, ) target_staff = [s for s in staff_list if not staff_ids or str(s.id) in staff_ids] for member in target_staff: calc = calculate_pay_period(member, period_start, period_end) # Check if a period record already exists existing = StaffPayPeriod.query.filter_by( tenant_id=g.tenant.id, staff_id=member.id, period_start=period_start, period_end=period_end, ).first() if existing: # Update draft; never overwrite approved/paid if existing.status == "draft": existing.pay_type = calc["pay_type"] existing.base_amount = calc["base_amount"] existing.commission_amount = calc["commission_amount"] existing.guarantee_topup = calc["guarantee_topup"] existing.total_amount = calc["total_amount"] pp = existing else: pp = existing else: pp = StaffPayPeriod( tenant_id=g.tenant.id, staff_id=member.id, period_start=period_start, period_end=period_end, **{k: v for k, v in calc.items()}, ) db.session.add(pp) results.append({"staff": member, "calc": calc, "pay_period": pp}) db.session.commit() log_tenant_action( "pay_period.calculate", "pay_period", None, {"period": f"{period_start}–{period_end}", "staff_count": len(results)}, ) logger.info( "Pay periods calculated: tenant=%s period=%s–%s count=%d", g.tenant.id, period_start, period_end, len(results), ) flash(f"Calculated pay for {len(results)} staff member(s).", "success") return render_template( "tenant/pay_periods/calculate.html", staff_list=staff_list, results=results, period_start=period_start, period_end=period_end, ) @pay_periods_bp.route("//approve", methods=["POST"]) @login_required @require_role("tenant_admin") @demo_readonly def approve(pp_id): pp = StaffPayPeriod.query.filter_by( id=pp_id, tenant_id=g.tenant.id ).first_or_404() if pp.status != "draft": flash("Only draft periods can be approved.", "warning") return redirect(url_for("pay_periods.index")) pp.status = "approved" log_tenant_action("pay_period.approve", "pay_period", pp.id, {"staff": pp.staff_id, "total": float(pp.total_amount)}) db.session.commit() logger.info("Pay period approved: id=%s staff=%s", pp.id, pp.staff_id) flash("Pay period approved.", "success") return redirect(url_for("pay_periods.index")) @pay_periods_bp.route("//mark-paid", methods=["POST"]) @login_required @require_role("tenant_admin") @demo_readonly def mark_paid(pp_id): pp = StaffPayPeriod.query.filter_by( id=pp_id, tenant_id=g.tenant.id ).first_or_404() if pp.status != "approved": flash("Only approved periods can be marked as paid.", "warning") return redirect(url_for("pay_periods.index")) pp.status = "paid" pp.notes = request.form.get("notes", pp.notes) log_tenant_action("pay_period.mark_paid", "pay_period", pp.id, {"staff": pp.staff_id, "total": float(pp.total_amount)}) db.session.commit() logger.info("Pay period marked paid: id=%s staff=%s", pp.id, pp.staff_id) flash("Pay period marked as paid.", "success") return redirect(url_for("pay_periods.index"))