""" 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"" 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"" 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"" 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"" # ───────────────────────────────────────────────────────────── # 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"" 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"" 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"" # ───────────────────────────────────────────────────────────── # 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"" 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"" # ───────────────────────────────────────────────────────────── # 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"" 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"" # ───────────────────────────────────────────────────────────── # 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"" 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")