128 lines
5.0 KiB
Python
128 lines
5.0 KiB
Python
"""
|
|
app/tenant/reconciliation/routes.py
|
|
End-of-day reconciliation: close day, cash count, variance calculation.
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timezone, timedelta, date
|
|
from decimal import Decimal
|
|
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 DailyReconciliation, Transaction
|
|
from app.decorators import require_role, demo_readonly
|
|
from app.tenant.utils import log_tenant_action
|
|
|
|
logger = logging.getLogger(__name__)
|
|
reconciliation_bp = Blueprint("reconciliation", __name__, url_prefix="/reconciliation")
|
|
|
|
|
|
@reconciliation_bp.route("/")
|
|
@login_required
|
|
@require_role("tenant_admin", "tenant_manager")
|
|
def index():
|
|
records = DailyReconciliation.query.filter_by(
|
|
tenant_id=g.tenant.id, location_id=g.location.id
|
|
).order_by(DailyReconciliation.date.desc()).limit(30).all()
|
|
return render_template("tenant/reconciliation/index.html", records=records)
|
|
|
|
|
|
@reconciliation_bp.route("/close", methods=["GET", "POST"])
|
|
@login_required
|
|
@require_role("tenant_admin", "tenant_manager")
|
|
@demo_readonly
|
|
def close_day():
|
|
today = date.today()
|
|
|
|
# Check if already closed for today
|
|
existing = DailyReconciliation.query.filter_by(
|
|
tenant_id=g.tenant.id, location_id=g.location.id, date=today
|
|
).first()
|
|
if existing and existing.closed_at:
|
|
flash("Today has already been reconciled.", "info")
|
|
return redirect(url_for("reconciliation.index"))
|
|
|
|
# Compute expected totals from today's transactions
|
|
day_start = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
|
|
day_end = day_start + timedelta(days=1)
|
|
|
|
txns = Transaction.query.filter_by(
|
|
tenant_id=g.tenant.id, location_id=g.location.id
|
|
).filter(
|
|
Transaction.created_at >= day_start,
|
|
Transaction.created_at < day_end,
|
|
Transaction.voided_at.is_(None),
|
|
).all()
|
|
|
|
total_cash = sum(
|
|
Decimal(str(t.total)) for t in txns if t.payment_method == "cash"
|
|
)
|
|
total_app = sum(
|
|
Decimal(str(t.total)) for t in txns
|
|
if t.payment_method in ("zelle", "venmo", "cashapp", "other")
|
|
)
|
|
total_tips = sum(Decimal(str(t.tip_amount)) for t in txns)
|
|
total_gc = sum(Decimal(str(t.gift_card_amount)) for t in txns)
|
|
expected_cash = total_cash # Starting float not tracked in MVP
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
actual_cash = Decimal(request.form.get("actual_cash", "0"))
|
|
except Exception:
|
|
return render_template("tenant/reconciliation/close.html",
|
|
today=today, total_cash=float(total_cash),
|
|
total_app=float(total_app),
|
|
total_tips=float(total_tips),
|
|
total_gc=float(total_gc),
|
|
expected_cash=float(expected_cash),
|
|
error="Invalid cash amount.")
|
|
variance = actual_cash - expected_cash
|
|
notes = request.form.get("notes", "").strip() or None
|
|
|
|
if existing:
|
|
existing.actual_cash_counted = float(actual_cash)
|
|
existing.variance = float(variance)
|
|
existing.closed_by = _user_db_id()
|
|
existing.closed_at = datetime.now(timezone.utc)
|
|
existing.notes = notes
|
|
rec = existing
|
|
else:
|
|
rec = DailyReconciliation(
|
|
tenant_id=g.tenant.id,
|
|
location_id=g.location.id,
|
|
date=today,
|
|
total_cash=float(total_cash),
|
|
total_app_payments=float(total_app),
|
|
total_tips=float(total_tips),
|
|
total_gift_card_redemptions=float(total_gc),
|
|
expected_cash_in_drawer=float(expected_cash),
|
|
actual_cash_counted=float(actual_cash),
|
|
variance=float(variance),
|
|
closed_by=_user_db_id(),
|
|
closed_at=datetime.now(timezone.utc),
|
|
notes=notes,
|
|
)
|
|
db.session.add(rec)
|
|
|
|
log_tenant_action("reconciliation.close", "reconciliation", None,
|
|
{"date": str(today), "variance": float(variance)})
|
|
db.session.commit()
|
|
flash(f"Day closed. Variance: ${float(variance):.2f}", "success")
|
|
return redirect(url_for("reconciliation.index"))
|
|
|
|
return render_template("tenant/reconciliation/close.html",
|
|
today=today,
|
|
total_cash=float(total_cash),
|
|
total_app=float(total_app),
|
|
total_tips=float(total_tips),
|
|
total_gc=float(total_gc),
|
|
expected_cash=float(expected_cash))
|
|
|
|
|
|
def _user_db_id():
|
|
from flask_login import current_user
|
|
try:
|
|
uid_str = current_user.get_id()
|
|
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
|
|
except Exception:
|
|
return None
|