""" app/tenant/pos/routes.py POS / Checkout: new transaction, line item entry, promotion auto-apply, tip, gift card redemption, void, receipt. Rebook-at-checkout creates a new pending appointment. """ import logging from datetime import datetime, timezone, timedelta from decimal import Decimal, ROUND_HALF_UP from flask import Blueprint, render_template, redirect, url_for, flash, request, g, jsonify from flask_login import login_required from app.extensions import db from app.models.salon import ( Transaction, TransactionItem, Appointment, Customer, Staff, Service, Product, GiftCard, AppointmentReminder, ) from app.decorators import require_role, demo_readonly from app.tenant.utils import log_tenant_action, get_active_promotion, apply_promotion_to_price logger = logging.getLogger(__name__) pos_bp = Blueprint("pos", __name__, url_prefix="/pos") PAYMENT_METHODS = ["cash", "zelle", "venmo", "cashapp", "gift_card", "other"] @pos_bp.route("/") @login_required @require_role("tenant_admin", "tenant_manager") def checkout(): """New POS transaction entry form.""" customers = Customer.query.filter_by( tenant_id=g.tenant.id, is_active=True).filter( Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).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() services = Service.query.filter_by( tenant_id=g.tenant.id, is_active=True).filter( Service.deleted_at.is_(None)).order_by(Service.category, Service.name).all() products = Product.query.filter_by( tenant_id=g.tenant.id, is_active=True).filter( Product.deleted_at.is_(None)).order_by(Product.name).all() # Pre-fill from appointment if provided appt_id = request.args.get("appointment_id", type=int) appointment = None if appt_id: appointment = Appointment.query.filter_by( id=appt_id, tenant_id=g.tenant.id).first() return render_template("tenant/pos/checkout.html", customers=customers, staff_list=staff_list, services=services, products=products, appointment=appointment, payment_methods=PAYMENT_METHODS) @pos_bp.route("/submit", methods=["POST"]) @login_required @require_role("tenant_admin", "tenant_manager") @demo_readonly def submit(): """Process a completed checkout.""" customer_id = request.form.get("customer_id", type=int) or None staff_id = request.form.get("staff_id", type=int) or None appt_id = request.form.get("appointment_id", type=int) or None payment_method = request.form.get("payment_method", "cash") payment_reference = request.form.get("payment_reference", "").strip() or None tip_raw = request.form.get("tip_amount", "0") gc_code = request.form.get("gift_card_code", "").strip().upper() or None if payment_method not in PAYMENT_METHODS: flash("Invalid payment method.", "danger") return redirect(url_for("pos.checkout")) try: tip_amount = Decimal(tip_raw or "0").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) except Exception: tip_amount = Decimal("0") # Parse line items from form: service_ids[] and product_ids[] service_ids = request.form.getlist("service_ids") product_ids = request.form.getlist("product_ids") if not service_ids and not product_ids: flash("Please add at least one service or product.", "danger") return redirect(url_for("pos.checkout")) # Resolve gift card gift_card = None gc_applied = Decimal("0") if gc_code: gift_card = GiftCard.query.filter_by( tenant_id=g.tenant.id, code=gc_code, is_active=True).first() if not gift_card or gift_card.remaining_balance <= 0: flash(f"Gift card {gc_code} is invalid or has no balance.", "danger") return redirect(url_for("pos.checkout")) # Build transaction txn = Transaction( tenant_id=g.tenant.id, location_id=g.location.id, appointment_id=appt_id, customer_id=customer_id, staff_id=staff_id, payment_method=payment_method, payment_reference=payment_reference, tip_amount=float(tip_amount), gift_card_id=gift_card.id if gift_card else None, subtotal=0, discount=0, gift_card_amount=0, total=0, ) db.session.add(txn) db.session.flush() subtotal = Decimal("0") total_discount = Decimal("0") # Add service line items for sid_str in service_ids: try: sid = int(sid_str) except ValueError: continue svc = Service.query.filter_by( id=sid, tenant_id=g.tenant.id).first() if not svc: continue original_price = Decimal(str(svc.price)) promo = get_active_promotion(g.tenant.id, sid, "service") unit_price_f, disc_pct = apply_promotion_to_price(float(original_price), promo) unit_price = Decimal(str(unit_price_f)) discount_amt = original_price - unit_price item = TransactionItem( transaction_id=txn.id, service_id=sid, product_id=None, qty=1, unit_price=float(unit_price), original_price=float(original_price), discount_percent=disc_pct, promotion_id=promo.id if promo else None, ) db.session.add(item) subtotal += unit_price total_discount += discount_amt # Add product line items for pid_str in product_ids: try: pid = int(pid_str) except ValueError: continue prod = Product.query.filter_by( id=pid, tenant_id=g.tenant.id).first() if not prod: continue original_price = Decimal(str(prod.sale_price)) promo = get_active_promotion(g.tenant.id, pid, "product") unit_price_f, disc_pct = apply_promotion_to_price(float(original_price), promo) unit_price = Decimal(str(unit_price_f)) discount_amt = original_price - unit_price item = TransactionItem( transaction_id=txn.id, service_id=None, product_id=pid, qty=1, unit_price=float(unit_price), original_price=float(original_price), discount_percent=disc_pct, promotion_id=promo.id if promo else None, ) db.session.add(item) subtotal += unit_price total_discount += discount_amt # Apply gift card if gift_card: gc_available = Decimal(str(gift_card.remaining_balance)) gc_applied = min(gc_available, subtotal + tip_amount) gift_card.remaining_balance = float(gc_available - gc_applied) if gift_card.remaining_balance <= 0: gift_card.is_active = False total = subtotal + tip_amount - gc_applied if total < 0: total = Decimal("0") txn.subtotal = float(subtotal) txn.discount = float(total_discount) txn.tip_amount = float(tip_amount) txn.gift_card_amount = float(gc_applied) txn.total = float(total) # Mark appointment completed if linked if appt_id: Appointment.query.filter_by( id=appt_id, tenant_id=g.tenant.id ).update({"status": "completed"}) # Commission log if staff has commission enabled if staff_id: staff = Staff.query.get(staff_id) if staff and staff.commission_enabled and staff.commission_rate: from app.models.salon import CommissionLog commission_amount = float( (Decimal(str(subtotal)) * Decimal(str(staff.commission_rate)) / Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) ) from datetime import date period = date.today().strftime("%Y-W%U") comm_log = CommissionLog( tenant_id=g.tenant.id, location_id=g.location.id, staff_id=staff_id, transaction_id=txn.id, amount=commission_amount, period=period, ) db.session.add(comm_log) log_tenant_action("transaction.create", "transaction", txn.id, {"total": float(total), "payment": payment_method}) db.session.commit() flash(f"Checkout complete — Total: ${float(total):.2f}", "success") # Rebook at checkout rebook = request.form.get("rebook") == "1" if rebook: return redirect(url_for("pos.rebook", transaction_id=txn.id)) return redirect(url_for("pos.receipt", transaction_id=txn.id)) @pos_bp.route("/receipt/") @login_required @require_role("tenant_admin", "tenant_manager") def receipt(transaction_id): txn = Transaction.query.filter_by( id=transaction_id, tenant_id=g.tenant.id).first_or_404() items = list(txn.items) return render_template("tenant/pos/receipt.html", transaction=txn, items=items, tenant=g.tenant, location=g.location) @pos_bp.route("/rebook/", methods=["GET", "POST"]) @login_required @require_role("tenant_admin", "tenant_manager") @demo_readonly def rebook(transaction_id): """Next-visit scheduling at checkout.""" txn = Transaction.query.filter_by( id=transaction_id, tenant_id=g.tenant.id).first_or_404() 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() services = Service.query.filter_by( tenant_id=g.tenant.id, is_active=True).filter( Service.deleted_at.is_(None)).order_by(Service.name).all() if request.method == "POST": start_raw = request.form.get("start_time", "") service_id = request.form.get("service_id", type=int) staff_id = request.form.get("staff_id", type=int) or None try: start_time = datetime.fromisoformat(start_raw) if start_time.tzinfo is None: start_time = start_time.replace(tzinfo=timezone.utc) except ValueError: return render_template("tenant/pos/rebook.html", transaction=txn, staff_list=staff_list, services=services, error="Invalid date/time.") svc = Service.query.get(service_id) if service_id else None duration = svc.duration_min if svc else 30 end_time = start_time + timedelta(minutes=duration) appt = Appointment( tenant_id=g.tenant.id, location_id=g.location.id, customer_id=txn.customer_id, staff_id=staff_id, service_id=service_id, start_time=start_time, end_time=end_time, is_walk_in=False, status="pending", rebook_source="checkout", rebooked_from_transaction_id=txn.id, created_by=_user_db_id(), ) db.session.add(appt) db.session.flush() # Schedule 24h reminder reminder_time = start_time - timedelta(hours=24) if reminder_time > datetime.now(timezone.utc): reminder = AppointmentReminder( tenant_id=g.tenant.id, location_id=g.location.id, appointment_id=appt.id, reminder_type="24h", scheduled_for=reminder_time, channel="email", status="pending", ) db.session.add(reminder) log_tenant_action("appointment.rebook", "appointment", appt.id, {"from_transaction": txn.id, "start": start_raw}) db.session.commit() flash("Next visit scheduled.", "success") return redirect(url_for("pos.receipt", transaction_id=txn.id)) return render_template("tenant/pos/rebook.html", transaction=txn, staff_list=staff_list, services=services) @pos_bp.route("/void/", methods=["GET", "POST"]) @login_required @require_role("tenant_admin") @demo_readonly def void(transaction_id): txn = Transaction.query.filter_by( id=transaction_id, tenant_id=g.tenant.id).first_or_404() if txn.voided_at: flash("This transaction is already voided.", "warning") return redirect(url_for("pos.receipt", transaction_id=transaction_id)) if request.method == "POST": reason = request.form.get("reason", "").strip() if not reason: return render_template("tenant/pos/void.html", transaction=txn, error="Reason is required.") txn.voided_at = datetime.now(timezone.utc) txn.voided_by = _user_db_id() txn.void_reason = reason # Reverse gift card balance if applicable if txn.gift_card_id and txn.gift_card_amount > 0: gc = GiftCard.query.get(txn.gift_card_id) if gc: gc.remaining_balance = float( Decimal(str(gc.remaining_balance)) + Decimal(str(txn.gift_card_amount)) ) gc.is_active = True log_tenant_action("transaction.void", "transaction", txn.id, {"reason": reason, "total": txn.total}) db.session.commit() flash("Transaction voided.", "success") return redirect(url_for("pos.receipt", transaction_id=transaction_id)) return render_template("tenant/pos/void.html", transaction=txn) @pos_bp.route("/transactions") @login_required @require_role("tenant_admin", "tenant_manager") def transactions(): from datetime import date date_str = request.args.get("date", date.today().isoformat()) try: view_date = date.fromisoformat(date_str) except ValueError: view_date = date.today() day_start = datetime.combine(view_date, 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), ).order_by(Transaction.created_at.desc()).all() return render_template("tenant/pos/transactions.html", transactions=txns, view_date=view_date) 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