747 lines
34 KiB
Python
747 lines
34 KiB
Python
"""
|
||
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: 1–100 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 4–6 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", use_alter=True, name="fk_appt_rebooked_transaction"),
|
||
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: 1–5 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")
|
||
|
||
|
||
|