05/06/2026 Initial commit

This commit is contained in:
2026-05-06 14:19:07 -04:00
parent 9da1dffd9a
commit dde18a2cd2
116 changed files with 4276 additions and 7 deletions
+744
View File
@@ -0,0 +1,744 @@
"""
models/salon.py — Tenant-level models (all carry tenant_id).
Soft delete convention: all tenant models include deleted_at.
Queries always filter WHERE deleted_at IS NULL.
"""
import logging
from datetime import datetime, timezone
from app.extensions import db
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────
# User & Location
# ─────────────────────────────────────────────────────────────
class User(db.Model):
"""Tenant admin and manager accounts (email + password login)."""
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
email = db.Column(db.String(255), nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(30), nullable=False)
# role: 'tenant_admin' | 'tenant_manager'
is_active = db.Column(db.Boolean, nullable=False, default=True)
failed_login_attempts = db.Column(db.Integer, nullable=False, default=0)
locked_until = db.Column(db.DateTime, nullable=True)
password_reset_token = db.Column(db.String(255), nullable=True)
password_reset_expires_at = db.Column(db.DateTime, nullable=True)
last_login_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
deleted_at = db.Column(db.DateTime, nullable=True)
__table_args__ = (
db.UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
)
# Flask-Login interface
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
def get_id(self):
return f"user:{self.id}"
def is_locked(self):
if self.locked_until is None:
return False
return datetime.now(timezone.utc) < self.locked_until.replace(tzinfo=timezone.utc)
def record_login(self):
self.failed_login_attempts = 0
self.locked_until = None
self.last_login_at = datetime.now(timezone.utc)
def record_failed_login(self, max_attempts: int, lockout_minutes: int):
from datetime import timedelta
self.failed_login_attempts = (self.failed_login_attempts or 0) + 1
if self.failed_login_attempts >= max_attempts:
self.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_minutes)
def __repr__(self):
return f"<User {self.email} tenant={self.tenant_id}>"
class TenantSetting(db.Model):
__tablename__ = "tenant_settings"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
setting_key = db.Column(db.String(100), nullable=False)
setting_value = db.Column(db.Text, nullable=True)
__table_args__ = (
db.UniqueConstraint("tenant_id", "setting_key",
name="uq_tenant_settings_key"),
)
def __repr__(self):
return f"<TenantSetting {self.setting_key}={self.setting_value}>"
class Location(db.Model):
__tablename__ = "locations"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
address = db.Column(db.String(255), nullable=True)
phone = db.Column(db.String(30), nullable=True)
email = db.Column(db.String(255), nullable=True)
timezone = db.Column(db.String(60), nullable=False, default="America/New_York")
is_active = db.Column(db.Boolean, nullable=False, default=True)
is_primary = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
deleted_at = db.Column(db.DateTime, nullable=True)
settings = db.relationship("LocationSetting", back_populates="location",
lazy="dynamic")
def __repr__(self):
return f"<Location {self.name} tenant={self.tenant_id}>"
class LocationSetting(db.Model):
__tablename__ = "location_settings"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
setting_key = db.Column(db.String(100), nullable=False)
setting_value = db.Column(db.Text, nullable=True)
location = db.relationship("Location", back_populates="settings")
__table_args__ = (
db.UniqueConstraint("location_id", "setting_key",
name="uq_location_settings_key"),
)
# ─────────────────────────────────────────────────────────────
# Customers
# ─────────────────────────────────────────────────────────────
class Customer(db.Model):
__tablename__ = "customers"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
phone = db.Column(db.String(30), nullable=True)
email = db.Column(db.String(255), nullable=True)
date_of_birth = db.Column(db.Date, nullable=True)
preferred_staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=True)
notes = db.Column(db.Text, nullable=True)
loyalty_points = db.Column(db.Integer, nullable=False, default=0)
is_active = db.Column(db.Boolean, nullable=False, default=True)
no_show_count = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
deleted_at = db.Column(db.DateTime, nullable=True)
def soft_delete(self):
self.deleted_at = datetime.now(timezone.utc)
def __repr__(self):
return f"<Customer {self.name} tenant={self.tenant_id}>"
# ─────────────────────────────────────────────────────────────
# Services, Products & Promotions
# ─────────────────────────────────────────────────────────────
class Service(db.Model):
__tablename__ = "services"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
category = db.Column(db.String(100), nullable=True)
duration_min = db.Column(db.Integer, nullable=False, default=30)
price = db.Column(db.Numeric(8, 2), nullable=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
deleted_at = db.Column(db.DateTime, nullable=True)
def __repr__(self):
return f"<Service {self.name}>"
class Product(db.Model):
__tablename__ = "products"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
sku = db.Column(db.String(100), nullable=True)
category = db.Column(db.String(100), nullable=True)
sale_price = db.Column(db.Numeric(8, 2), nullable=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
deleted_at = db.Column(db.DateTime, nullable=True)
def __repr__(self):
return f"<Product {self.name}>"
class Promotion(db.Model):
"""
applies_to: 'service' | 'product' | 'all_services' | 'all_products' | 'all'
target_ids_json: list of IDs when applies_to is 'service' or 'product'; else null.
discount_percent: 1100 integer.
Active window: starts_at <= NOW() <= ends_at AND is_active = true.
"""
__tablename__ = "promotions"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
discount_percent = db.Column(db.Integer, nullable=False)
applies_to = db.Column(db.String(30), nullable=False)
target_ids_json = db.Column(db.JSON, nullable=True)
starts_at = db.Column(db.DateTime, nullable=False)
ends_at = db.Column(db.DateTime, nullable=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
def __repr__(self):
return f"<Promotion {self.name} {self.discount_percent}%>"
# ─────────────────────────────────────────────────────────────
# Staff
# ─────────────────────────────────────────────────────────────
class Staff(db.Model):
"""
staff_type: 'salon_manager' | 'full_time' | 'part_time' | 'seasonal' | 'receptionist'
pay_type: 'hourly' | 'salary' | 'guarantee'
pay_period: 'weekly' | 'biweekly' | 'monthly'
passcode_hash: bcrypt-hashed 46 digit PIN; set by tenant_admin at creation.
phone: unique within tenant; used for staff-login.
"""
__tablename__ = "staff"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
name = db.Column(db.String(150), nullable=False)
phone = db.Column(db.String(30), nullable=False)
passcode_hash = db.Column(db.String(255), nullable=False)
staff_type = db.Column(db.String(30), nullable=False)
pay_type = db.Column(db.String(20), nullable=False, default="hourly")
hourly_rate = db.Column(db.Numeric(8, 2), nullable=True)
salary_amount = db.Column(db.Numeric(10, 2), nullable=True)
guarantee_amount = db.Column(db.Numeric(10, 2), nullable=True)
pay_period = db.Column(db.String(20), nullable=False, default="biweekly")
commission_rate = db.Column(db.Numeric(5, 2), nullable=True)
commission_enabled = db.Column(db.Boolean, nullable=False, default=True)
is_active = db.Column(db.Boolean, nullable=False, default=True)
passcode_failed_attempts = db.Column(db.Integer, nullable=False, default=0)
passcode_locked_until = db.Column(db.DateTime, nullable=True)
deleted_at = db.Column(db.DateTime, nullable=True)
locations = db.relationship("StaffLocation", back_populates="staff",
lazy="dynamic")
schedules = db.relationship("StaffSchedule", back_populates="staff",
lazy="dynamic")
__table_args__ = (
db.UniqueConstraint("tenant_id", "phone", name="uq_staff_tenant_phone"),
)
# Flask-Login interface (for staff portal session)
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
def get_id(self):
return f"staff:{self.id}"
def is_passcode_locked(self):
if self.passcode_locked_until is None:
return False
return datetime.now(timezone.utc) < self.passcode_locked_until.replace(
tzinfo=timezone.utc
)
def record_passcode_success(self):
self.passcode_failed_attempts = 0
self.passcode_locked_until = None
def record_passcode_failure(self, max_attempts: int, lockout_minutes: int):
from datetime import timedelta
self.passcode_failed_attempts = (self.passcode_failed_attempts or 0) + 1
if self.passcode_failed_attempts >= max_attempts:
self.passcode_locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_minutes)
def soft_delete(self):
self.deleted_at = datetime.now(timezone.utc)
def is_assigned_to_location(self, location_id: int) -> bool:
return any(loc.id == location_id for loc in self.locations)
def __repr__(self):
return f"<Staff {self.name} tenant={self.tenant_id}>"
class StaffLocation(db.Model):
__tablename__ = "staff_locations"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
staff = db.relationship("Staff", back_populates="locations")
location = db.relationship("Location")
__table_args__ = (
db.UniqueConstraint("staff_id", "location_id",
name="uq_staff_location"),
)
class StaffSchedule(db.Model):
__tablename__ = "staff_schedules"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
day_of_week = db.Column(db.Integer, nullable=False) # 0=Mon … 6=Sun
start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False)
staff = db.relationship("Staff", back_populates="schedules")
# ─────────────────────────────────────────────────────────────
# Appointments
# ─────────────────────────────────────────────────────────────
class Appointment(db.Model):
"""
status: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled' | 'no_show'
rebook_source: 'checkout' | 'online' | 'manual' | 'kiosk' | null
"""
__tablename__ = "appointments"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
start_time = db.Column(db.DateTime, nullable=False)
end_time = db.Column(db.DateTime, nullable=True)
is_walk_in = db.Column(db.Boolean, nullable=False, default=False)
status = db.Column(db.String(20), nullable=False, default="pending")
notes = db.Column(db.Text, nullable=True)
cancellation_reason = db.Column(db.Text, nullable=True)
cancelled_at = db.Column(db.DateTime, nullable=True)
rebook_source = db.Column(db.String(20), nullable=True)
rebooked_from_transaction_id = db.Column(
db.Integer, db.ForeignKey("transactions.id"), nullable=True
)
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
customer = db.relationship("Customer")
staff = db.relationship("Staff")
service = db.relationship("Service")
def __repr__(self):
return f"<Appointment {self.id} status={self.status}>"
# ─────────────────────────────────────────────────────────────
# Transactions & POS
# ─────────────────────────────────────────────────────────────
class Transaction(db.Model):
"""
payment_method: 'cash' | 'zelle' | 'venmo' | 'cashapp' | 'gift_card' | 'other'
"""
__tablename__ = "transactions"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
appointment_id = db.Column(db.Integer, db.ForeignKey("appointments.id"),
nullable=True)
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
subtotal = db.Column(db.Numeric(10, 2), nullable=False)
discount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
tip_amount = db.Column(db.Numeric(8, 2), nullable=False, default=0)
gift_card_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total = db.Column(db.Numeric(10, 2), nullable=False)
payment_method = db.Column(db.String(20), nullable=False)
payment_reference = db.Column(db.String(255), nullable=True)
gift_card_id = db.Column(db.Integer, db.ForeignKey("gift_cards.id"),
nullable=True)
review_request_sent_at = db.Column(db.DateTime, nullable=True)
voided_at = db.Column(db.DateTime, nullable=True)
voided_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
void_reason = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
items = db.relationship("TransactionItem", back_populates="transaction",
lazy="dynamic")
def __repr__(self):
return f"<Transaction {self.id} total={self.total}>"
class TransactionItem(db.Model):
__tablename__ = "transaction_items"
id = db.Column(db.Integer, primary_key=True)
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
nullable=False, index=True)
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=True)
qty = db.Column(db.Integer, nullable=False, default=1)
unit_price = db.Column(db.Numeric(8, 2), nullable=False)
# unit_price: final price after promotion
original_price = db.Column(db.Numeric(8, 2), nullable=False)
# original_price: price before promotion
discount_percent = db.Column(db.Numeric(5, 2), nullable=False, default=0)
promotion_id = db.Column(db.Integer, db.ForeignKey("promotions.id"),
nullable=True)
transaction = db.relationship("Transaction", back_populates="items")
def __repr__(self):
return f"<TransactionItem tx={self.transaction_id}>"
# ─────────────────────────────────────────────────────────────
# Inventory
# ─────────────────────────────────────────────────────────────
class Inventory(db.Model):
__tablename__ = "inventory"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
sku = db.Column(db.String(100), nullable=True)
category = db.Column(db.String(100), nullable=True)
qty_on_hand = db.Column(db.Integer, nullable=False, default=0)
reorder_level = db.Column(db.Integer, nullable=False, default=5)
cost_price = db.Column(db.Numeric(8, 2), nullable=True)
sale_price = db.Column(db.Numeric(8, 2), nullable=True)
def __repr__(self):
return f"<Inventory {self.name} qty={self.qty_on_hand}>"
class InventoryLog(db.Model):
__tablename__ = "inventory_log"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
inventory_id = db.Column(db.Integer, db.ForeignKey("inventory.id"),
nullable=False)
delta = db.Column(db.Integer, nullable=False)
reason = db.Column(db.String(255), nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
# ─────────────────────────────────────────────────────────────
# Commission & Pay
# ─────────────────────────────────────────────────────────────
class CommissionLog(db.Model):
__tablename__ = "commission_log"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
nullable=False)
amount = db.Column(db.Numeric(10, 2), nullable=False)
period = db.Column(db.String(20), nullable=True)
class StaffPayPeriod(db.Model):
"""
status: 'draft' | 'approved' | 'paid'
pay_type: mirrors staff.pay_type at the time of the period.
guarantee_topup: max(0, guarantee_amount - commission_amount) for 'guarantee' pay_type.
"""
__tablename__ = "staff_pay_periods"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
period_start = db.Column(db.Date, nullable=False)
period_end = db.Column(db.Date, nullable=False)
pay_type = db.Column(db.String(20), nullable=False)
base_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
commission_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
guarantee_topup = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
status = db.Column(db.String(20), nullable=False, default="draft")
notes = db.Column(db.Text, nullable=True)
class StaffClocking(db.Model):
"""One row per shift. clocked_out_at NULL = currently clocked in."""
__tablename__ = "staff_clockings"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
clocked_in_at = db.Column(db.DateTime, nullable=False)
clocked_out_at = db.Column(db.DateTime, nullable=True)
total_minutes = db.Column(db.Integer, nullable=True)
notes = db.Column(db.Text, nullable=True)
# ─────────────────────────────────────────────────────────────
# Marketing
# ─────────────────────────────────────────────────────────────
class MarketingCampaign(db.Model):
"""
channel: 'email' (SMS deferred)
status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled'
"""
__tablename__ = "marketing_campaigns"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
channel = db.Column(db.String(20), nullable=False, default="email")
status = db.Column(db.String(20), nullable=False, default="draft")
audience_filter_json = db.Column(db.JSON, nullable=True)
subject = db.Column(db.String(255), nullable=True)
message_body = db.Column(db.Text, nullable=True)
scheduled_at = db.Column(db.DateTime, nullable=True)
sent_at = db.Column(db.DateTime, nullable=True)
sent_count = db.Column(db.Integer, nullable=False, default=0)
open_count = db.Column(db.Integer, nullable=False, default=0)
# ─────────────────────────────────────────────────────────────
# Gift Cards
# ─────────────────────────────────────────────────────────────
class GiftCard(db.Model):
__tablename__ = "gift_cards"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
code = db.Column(db.String(50), nullable=False)
original_value = db.Column(db.Numeric(10, 2), nullable=False)
remaining_balance = db.Column(db.Numeric(10, 2), nullable=False)
issued_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
issued_to_customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
expires_at = db.Column(db.DateTime, nullable=True)
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.UniqueConstraint("tenant_id", "code", name="uq_gift_card_tenant_code"),
)
# ─────────────────────────────────────────────────────────────
# Check-in Kiosk & Waitlist
# ─────────────────────────────────────────────────────────────
class CheckinQueue(db.Model):
"""
status: 'waiting' | 'acknowledged' | 'seated' | 'expired'
Surfaced to receptionist dashboard via 5-second polling.
"""
__tablename__ = "checkin_queue"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
customer_name = db.Column(db.String(150), nullable=False)
customer_phone = db.Column(db.String(30), nullable=False)
service_requested = db.Column(db.String(150), nullable=True)
checked_in_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
status = db.Column(db.String(20), nullable=False, default="waiting")
acknowledged_by = db.Column(db.Integer, db.ForeignKey("users.id"),
nullable=True)
acknowledged_at = db.Column(db.DateTime, nullable=True)
class Waitlist(db.Model):
"""status: 'waiting' | 'notified' | 'booked' | 'expired'"""
__tablename__ = "waitlist"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
customer_name = db.Column(db.String(150), nullable=False)
customer_phone = db.Column(db.String(30), nullable=True)
customer_email = db.Column(db.String(255), nullable=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
requested_date = db.Column(db.Date, nullable=True)
status = db.Column(db.String(20), nullable=False, default="waiting")
notified_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
# ─────────────────────────────────────────────────────────────
# Reviews & Reconciliation
# ─────────────────────────────────────────────────────────────
class CheckoutReview(db.Model):
"""
rating: 15 integer.
is_public_suggested: True if rating >= 4 (platform links shown).
"""
__tablename__ = "checkout_reviews"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
nullable=False)
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
rating = db.Column(db.Integer, nullable=False)
comment = db.Column(db.Text, nullable=True)
is_public_suggested = db.Column(db.Boolean, nullable=False, default=False)
google_clicked = db.Column(db.Boolean, nullable=False, default=False)
facebook_clicked = db.Column(db.Boolean, nullable=False, default=False)
yelp_clicked = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
class DailyReconciliation(db.Model):
"""variance = actual_cash_counted - expected_cash_in_drawer (negative = shortage)"""
__tablename__ = "daily_reconciliations"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
date = db.Column(db.Date, nullable=False)
total_cash = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_app_payments = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_tips = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_gift_card_redemptions = db.Column(db.Numeric(10, 2), nullable=False,
default=0)
expected_cash_in_drawer = db.Column(db.Numeric(10, 2), nullable=False, default=0)
actual_cash_counted = db.Column(db.Numeric(10, 2), nullable=True)
variance = db.Column(db.Numeric(10, 2), nullable=True)
closed_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
closed_at = db.Column(db.DateTime, nullable=True)
notes = db.Column(db.Text, nullable=True)
__table_args__ = (
db.UniqueConstraint("tenant_id", "location_id", "date",
name="uq_reconciliation_location_date"),
)
class AppointmentReminder(db.Model):
"""
reminder_type: '24h' | '2h'
channel: 'email' (SMS deferred)
status: 'pending' | 'sent' | 'failed' | 'cancelled'
"""
__tablename__ = "appointment_reminders"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
appointment_id = db.Column(db.Integer, db.ForeignKey("appointments.id"),
nullable=False, index=True)
reminder_type = db.Column(db.String(10), nullable=False)
scheduled_for = db.Column(db.DateTime, nullable=False)
sent_at = db.Column(db.DateTime, nullable=True)
channel = db.Column(db.String(20), nullable=False, default="email")
status = db.Column(db.String(20), nullable=False, default="pending")