Files
2026-05-08 10:15:40 -04:00

285 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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("/<int:pp_id>/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("/<int:pp_id>/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"))