"""
Command Center - SQLAlchemy Models
21 tables based on Lauren Kingsley schema, fully self-hosted.
"""
from typing import Optional
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from datetime import datetime, date, timezone
from werkzeug.security import generate_password_hash, check_password_hash
import uuid
db = SQLAlchemy()
def gen_uuid():
"""Generate UUID for Supabase-compatible tables."""
return str(uuid.uuid4())
class User(UserMixin, db.Model):
"""User accounts - replaces Supabase auth.users"""
__tablename__ = 'profiles'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
full_name = db.Column(db.String(255), default='')
company = db.Column(db.String(255), default='')
phone = db.Column(db.String(50), default='')
role = db.Column(db.String(50), default='user') # super_admin, partner, user
last_login = db.Column(db.DateTime, nullable=True)
is_active = db.Column(db.Boolean, default=True)
failed_login_attempts = db.Column(db.Integer, default=0, nullable=False)
locked_until = db.Column(db.DateTime, nullable=True)
totp_secret = db.Column(db.String(255), nullable=True)
totp_enabled = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"role IN ('super_admin', 'admin', 'partner', 'user')",
name='ck_profiles_role',
),
)
# Relationships
user_companies = db.relationship('UserCompany', back_populates='user', cascade='all, delete-orphan')
coaching_assignments_given = db.relationship('CoachingAssignment', foreign_keys='CoachingAssignment.coach_id', back_populates='coach')
coaching_assignments_received = db.relationship('CoachingAssignment', foreign_keys='CoachingAssignment.rep_id', back_populates='rep')
user_settings = db.relationship('UserSetting', back_populates='user', cascade='all, delete-orphan')
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.email}>'
class Company(db.Model):
"""Home improvement company records"""
__tablename__ = 'companies'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
name = db.Column(db.String(255), nullable=False, unique=True)
industry = db.Column(db.String(100), default='') # roofing, hvac, plumbing, etc.
size = db.Column(db.String(50), default='') # single, multi, enterprise
annual_revenue = db.Column(db.Float, nullable=True)
target_revenue = db.Column(db.Float, nullable=True)
address = db.Column(db.String(500), default='')
city = db.Column(db.String(100), default='')
state = db.Column(db.String(50), default='')
zip_code = db.Column(db.String(20), default='')
website = db.Column(db.String(255), default='')
logo_url = db.Column(db.String(500), default='')
stripe_customer_id = db.Column(db.String(255), default=None, index=True, unique=True)
settings_json = db.Column(db.JSON, default=dict)
is_partner = db.Column(db.Boolean, default=False, nullable=False) # partner org flag (audit M1)
is_deleted = db.Column(db.Boolean, default=False, nullable=False, index=True) # soft delete (audit L4)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"size IN ('single', 'multi', 'enterprise', '')",
name='ck_companies_size',
),
)
# Relationships
users = db.relationship('UserCompany', back_populates='company', cascade='all, delete-orphan')
projects = db.relationship('Project', back_populates='company', cascade='all, delete-orphan')
goals = db.relationship('Goal', back_populates='company', cascade='all, delete-orphan')
kpi_values = db.relationship('KPIValue', back_populates='company', cascade='all, delete-orphan')
forecasts = db.relationship('Forecast', back_populates='company', cascade='all, delete-orphan')
revenue_leaks = db.relationship('RevenueLeak', back_populates='company', cascade='all, delete-orphan')
optimization_moves = db.relationship('OptimizationMove', back_populates='company', cascade='all, delete-orphan')
coaching_assignments = db.relationship('CoachingAssignment', back_populates='company', cascade='all, delete-orphan')
coaching_scorecards = db.relationship('CoachingScorecard', back_populates='company', cascade='all, delete-orphan')
connectors = db.relationship('Connector', back_populates='company', cascade='all, delete-orphan')
connector_logs = db.relationship('ConnectorLog', back_populates='company', cascade='all, delete-orphan')
activity_logs = db.relationship('ActivityLog', back_populates='company', cascade='all, delete-orphan')
notifications = db.relationship('Notification', back_populates='company', cascade='all, delete-orphan')
company_settings = db.relationship('Setting', back_populates='company', cascade='all, delete-orphan')
templates = db.relationship('Template', back_populates='company', cascade='all, delete-orphan')
custom_fields = db.relationship('CustomField', back_populates='company', cascade='all, delete-orphan')
roi_calculations = db.relationship('ROICalculation', back_populates='company', cascade='all, delete-orphan')
angi_leads = db.relationship('AngiLead', back_populates='company', cascade='all, delete-orphan')
sms_messages = db.relationship('SmsMessage', back_populates='company', cascade='all, delete-orphan')
calls = db.relationship('Call', back_populates='company', cascade='all, delete-orphan')
lead_touchpoints = db.relationship('LeadTouchpoint', back_populates='company', cascade='all, delete-orphan')
opt_outs = db.relationship('OptOut', back_populates='company', cascade='all, delete-orphan')
revenue_records = db.relationship('RevenueRecord', back_populates='company', cascade='all, delete-orphan')
def __repr__(self):
return f'<Company {self.name}>'
class UserCompany(db.Model):
"""Many-to-many userβcompany"""
__tablename__ = 'users_companies'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
user_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False, index=True)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
role = db.Column(db.String(50), default='member') # owner, admin, manager, member, viewer
joined_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.UniqueConstraint('user_id', 'company_id', name='uq_users_companies_user_company'),
db.CheckConstraint(
"role IN ('owner', 'admin', 'manager', 'member', 'viewer')",
name='ck_users_companies_role',
),
)
user = db.relationship('User', back_populates='user_companies')
company = db.relationship('Company', back_populates='users')
def __repr__(self):
return f'<UserCompany user={self.user_id} company={self.company_id}>'
class Invite(db.Model):
"""Team/company invites β created by owners/admins to add members."""
__tablename__ = 'invites'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
email = db.Column(db.String(255), nullable=False, index=True)
role = db.Column(db.String(50), default='member') # owner, admin, manager, member, viewer
token = db.Column(db.String(128), unique=True, nullable=False, index=True)
status = db.Column(db.String(20), default='pending') # pending, accepted, expired, revoked
expires_at = db.Column(db.DateTime, nullable=False)
created_by = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='SET NULL'), nullable=True)
accepted_by = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='SET NULL'), nullable=True)
accepted_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"role IN ('owner', 'admin', 'manager', 'member', 'viewer')",
name='ck_invites_role',
),
db.CheckConstraint(
"status IN ('pending', 'accepted', 'expired', 'revoked')",
name='ck_invites_status',
),
)
company = db.relationship('Company')
def __repr__(self):
return f'<Invite {self.email} @ {self.company_id}>'
class UserGroup(db.Model):
"""User groups for organizing team members."""
__tablename__ = 'user_groups'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, default='')
created_by = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='SET NULL'), nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company')
def __repr__(self):
return f'<UserGroup {self.name}>'
class GroupMember(db.Model):
"""Many-to-many userβgroup membership."""
__tablename__ = 'group_members'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
group_id = db.Column(db.String(36), db.ForeignKey('user_groups.id', ondelete='CASCADE'), nullable=False, index=True)
user_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False, index=True)
added_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.UniqueConstraint('group_id', 'user_id', name='uq_group_members'),
)
group = db.relationship('UserGroup')
user = db.relationship('User')
def __repr__(self):
return f'<GroupMember group={self.group_id} user={self.user_id}>'
class Project(db.Model):
"""Individual projects within companies"""
__tablename__ = 'projects'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, default='')
status = db.Column(db.String(50), default='planning') # planning, active, completed, on_hold, cancelled
budget = db.Column(db.Float, nullable=True)
actual_cost = db.Column(db.Float, nullable=True)
revenue = db.Column(db.Float, nullable=True)
start_date = db.Column(db.DateTime, nullable=True)
end_date = db.Column(db.DateTime, nullable=True)
completed_date = db.Column(db.DateTime, nullable=True)
priority = db.Column(db.String(20), default='medium') # low, medium, high, critical
assigned_to = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='SET NULL'), nullable=True)
custom_fields_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"status IN ('planning', 'active', 'completed', 'on_hold', 'cancelled')",
name='ck_projects_status',
),
db.CheckConstraint(
"priority IN ('low', 'medium', 'high', 'critical')",
name='ck_projects_priority',
),
)
company = db.relationship('Company', back_populates='projects')
def __repr__(self):
return f'<Project {self.name}>'
class Goal(db.Model):
"""Cascading goals (org β dept β team β rep)"""
__tablename__ = 'goals'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
parent_goal_id = db.Column(db.String(36), db.ForeignKey('goals.id', ondelete='CASCADE'), nullable=True, index=True)
name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, default='')
level = db.Column(db.String(50), default='org') # org, department, team, rep
target_value = db.Column(db.Float, nullable=True)
current_value = db.Column(db.Float, default=0.0)
unit = db.Column(db.String(50), default='$') # $, %, count, etc.
start_date = db.Column(db.DateTime, nullable=True)
end_date = db.Column(db.DateTime, nullable=True)
status = db.Column(db.String(50), default='active') # active, completed, paused, cancelled
assigned_to = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='SET NULL'), nullable=True) # user_id for rep-level goals
weight = db.Column(db.Float, default=1.0) # relative importance
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"level IN ('org', 'department', 'team', 'rep')",
name='ck_goals_level',
),
db.CheckConstraint(
"status IN ('active', 'completed', 'paused', 'cancelled')",
name='ck_goals_status',
),
)
company = db.relationship('Company', back_populates='goals')
parent_goal = db.relationship('Goal', remote_side=[id], backref='sub_goals')
metrics = db.relationship('GoalMetric', back_populates='goal', cascade='all, delete-orphan')
def progress_percentage(self):
if self.target_value is None or self.target_value == 0:
return 0.0
return min(100.0, (self.current_value / self.target_value) * 100)
def __repr__(self):
return f'<Goal {self.name}>'
class GoalMetric(db.Model):
"""KPI tracking against goals"""
__tablename__ = 'goal_metrics'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
goal_id = db.Column(db.String(36), db.ForeignKey('goals.id', ondelete='CASCADE'), nullable=False, index=True)
metric_name = db.Column(db.String(255), nullable=False)
target_value = db.Column(db.Float, nullable=True)
actual_value = db.Column(db.Float, default=0.0)
unit = db.Column(db.String(50), default='')
period = db.Column(db.String(20), default='monthly') # daily, weekly, monthly, quarterly
period_start = db.Column(db.DateTime, nullable=True)
period_end = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"period IN ('daily', 'weekly', 'monthly', 'quarterly')",
name='ck_goal_metrics_period',
),
)
goal = db.relationship('Goal', back_populates='metrics')
def __repr__(self):
return f'<GoalMetric {self.metric_name}>'
class KPIValue(db.Model):
"""Time-series KPI measurements"""
__tablename__ = 'kpi_values'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
kpi_name = db.Column(db.String(255), nullable=False, index=True)
value = db.Column(db.Float, nullable=False)
unit = db.Column(db.String(50), default='')
category = db.Column(db.String(100), default='') # revenue, marketing, ops, etc.
period = db.Column(db.String(20), default='monthly')
period_start = db.Column(db.DateTime, nullable=True)
period_end = db.Column(db.DateTime, nullable=True)
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', back_populates='kpi_values')
def __repr__(self):
return f'<KPIValue {self.kpi_name}={self.value}>'
class Forecast(db.Model):
"""Revenue forecasting data"""
__tablename__ = 'forecasts'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
forecast_type = db.Column(db.String(50), default='revenue') # revenue, pipeline, cost
period = db.Column(db.String(20), nullable=False) # monthly, quarterly, annually
period_start = db.Column(db.DateTime, nullable=True)
period_end = db.Column(db.DateTime, nullable=True)
projected_value = db.Column(db.Float, nullable=False)
actual_value = db.Column(db.Float, nullable=True)
confidence = db.Column(db.Float, nullable=True) # 0.0 to 1.0
methodology = db.Column(db.String(100), default='') # historical, weighted_pipeline, etc.
assumptions_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"forecast_type IN ('revenue', 'pipeline', 'cost')",
name='ck_forecasts_forecast_type',
),
)
company = db.relationship('Company', back_populates='forecasts')
@property
def variance(self):
if self.actual_value is None:
return None
return self.actual_value - self.projected_value
@property
def variance_pct(self):
if self.actual_value is None or self.projected_value == 0:
return None
return ((self.actual_value - self.projected_value) / abs(self.projected_value)) * 100
def __repr__(self):
return f'<Forecast {self.forecast_type} {self.period_start}>'
class RevenueLeak(db.Model):
"""Identified revenue leakage points"""
__tablename__ = 'revenue_leaks'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
source = db.Column(db.String(255), nullable=False) # pipeline_stage, channel, product
description = db.Column(db.Text, default='')
estimated_loss = db.Column(db.Float, nullable=True)
severity = db.Column(db.String(20), default='medium') # low, medium, high, critical
detected_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
resolved = db.Column(db.Boolean, default=False)
resolved_at = db.Column(db.DateTime, nullable=True)
resolution_notes = db.Column(db.Text, default='')
metadata_json = db.Column(db.JSON, default=dict)
# Auto-detection fields (Phase 0 β leak detection framework, July 2026)
detection_type = db.Column(db.String(10), nullable=False, default='manual') # 'manual' | 'auto'
detector_id = db.Column(db.String(64), nullable=True, index=True) # e.g. 'qb_overdue_30d'
source_connector = db.Column(db.String(32), nullable=True) # connector service name
rule_params_json = db.Column(db.JSON, nullable=True) # threshold params used for this detection
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"severity IN ('low', 'medium', 'high', 'critical')",
name='ck_revenue_leaks_severity',
),
)
company = db.relationship('Company', back_populates='revenue_leaks')
def __repr__(self):
return f'<RevenueLeak {self.source}>'
class OptimizationMove(db.Model):
"""Recommended budget/action moves"""
__tablename__ = 'optimization_moves'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
move_type = db.Column(db.String(100), default='budget_shift') # budget_shift, channel_change, target_audience
description = db.Column(db.Text, default='')
source_channel = db.Column(db.String(100), default='')
target_channel = db.Column(db.String(100), default='')
current_spend = db.Column(db.Float, nullable=True)
recommended_spend = db.Column(db.Float, nullable=True)
expected_impact = db.Column(db.Float, nullable=True)
confidence = db.Column(db.Float, nullable=True) # 0.0 to 1.0
status = db.Column(db.String(20), default='recommended') # recommended, accepted, rejected, implemented
implemented_at = db.Column(db.DateTime, nullable=True)
actual_result = db.Column(db.Float, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"status IN ('recommended', 'accepted', 'rejected', 'implemented')",
name='ck_optimization_moves_status',
),
)
company = db.relationship('Company', back_populates='optimization_moves')
def __repr__(self):
return f'<OptimizationMove {self.move_type}>'
class CoachingAssignment(db.Model):
"""Coach/rep pairings"""
__tablename__ = 'coaching_assignments'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
coach_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False, index=True)
rep_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False, index=True)
focus_area = db.Column(db.String(100), default='') # closing, qualifying, follow-up, etc.
description = db.Column(db.Text, default='')
start_date = db.Column(db.DateTime, nullable=True)
end_date = db.Column(db.DateTime, nullable=True)
status = db.Column(db.String(20), default='active') # active, completed, paused
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"status IN ('active', 'completed', 'paused')",
name='ck_coaching_assignments_status',
),
)
company = db.relationship('Company', back_populates='coaching_assignments')
coach = db.relationship('User', foreign_keys=[coach_id], back_populates='coaching_assignments_given')
rep = db.relationship('User', foreign_keys=[rep_id], back_populates='coaching_assignments_received')
def __repr__(self):
return f'<CoachingAssignment {self.coach_id}β{self.rep_id}>'
class CoachingScorecard(db.Model):
"""Performance scorecards"""
__tablename__ = 'coaching_scorecards'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
assignment_id = db.Column(db.String(36), db.ForeignKey('coaching_assignments.id', ondelete='CASCADE'), nullable=False, index=True)
rep_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False, index=True)
evaluation_date = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
overall_score = db.Column(db.Float, nullable=True) # 0-100
communication_score = db.Column(db.Float, nullable=True)
technical_score = db.Column(db.Float, nullable=True)
closing_score = db.Column(db.Float, nullable=True)
follow_up_score = db.Column(db.Float, nullable=True)
strengths = db.Column(db.Text, default='')
improvement_areas = db.Column(db.Text, default='')
notes = db.Column(db.Text, default='')
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', back_populates='coaching_scorecards')
assignment = db.relationship('CoachingAssignment', backref='scorecards')
def __repr__(self):
return f'<CoachingScorecard {self.rep_id} score={self.overall_score}>'
class Connector(db.Model):
"""Integration connector configs"""
__tablename__ = 'connectors'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
service = db.Column(db.String(100), nullable=False) # servicetitan, hubspot, google_ads, quickbooks
status = db.Column(db.String(20), default='inactive') # inactive, connected, syncing, error
config_encrypted = db.Column(db.Text, nullable=True) # NEW: Fernet-encrypted config
config_json = db.Column(db.JSON, nullable=True) # DEPRECATED: plaintext (kept for migration)
last_sync_at = db.Column(db.DateTime, nullable=True)
sync_frequency = db.Column(db.String(20), default='hourly') # real_time, hourly, daily
error_message = db.Column(db.Text, default='')
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"status IN ('inactive', 'connected', 'syncing', 'error')",
name='ck_connectors_status',
),
)
company = db.relationship('Company', back_populates='connectors')
@property
def config(self) -> dict:
"""Read config β decrypt if encrypted, fall back to plaintext for migration."""
if self.config_encrypted:
from app.utils.encryption import decrypt_config
return decrypt_config(self.config_encrypted)
return self.config_json or {}
@config.setter
def config(self, value: dict) -> None:
"""Write config β always encrypt."""
from app.utils.encryption import encrypt_config
self.config_encrypted = encrypt_config(value)
self.config_json = None # Clear plaintext
def __repr__(self):
return f'<Connector {self.service}>'
class ConnectorLog(db.Model):
"""Sync logs for integrations"""
__tablename__ = 'connector_logs'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
connector_id = db.Column(db.String(36), db.ForeignKey('connectors.id', ondelete='CASCADE'), nullable=False, index=True)
event_type = db.Column(db.String(50), nullable=False) # sync_start, sync_complete, sync_error
record_count = db.Column(db.Integer, default=0)
duration_ms = db.Column(db.Integer, nullable=True)
status = db.Column(db.String(20), default='pending') # pending, success, error
error_message = db.Column(db.Text, default='')
details_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"status IN ('pending', 'success', 'error')",
name='ck_connector_logs_status',
),
)
company = db.relationship('Company', back_populates='connector_logs')
connector = db.relationship('Connector', backref='logs')
def __repr__(self):
return f'<ConnectorLog {self.event_type}>'
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'connector_id': self.connector_id,
'event_type': self.event_type,
'record_count': self.record_count,
'duration_ms': self.duration_ms,
'status': self.status,
'error_message': self.error_message,
'details_json': self.details_json,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
class ActivityLog(db.Model):
"""User/company activity feed"""
__tablename__ = 'activity_logs'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
user_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='SET NULL'), nullable=True, index=True)
action = db.Column(db.String(100), nullable=False) # login, create_company, update_goal, etc.
entity_type = db.Column(db.String(50), default='')
entity_id = db.Column(db.String(36), default='')
details = db.Column(db.Text, default='')
ip_address = db.Column(db.String(50), default='')
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', back_populates='activity_logs')
user = db.relationship('User', backref='activities')
def __repr__(self):
return f'<ActivityLog {self.action}>'
class AuditLog(db.Model):
"""System audit trail"""
__tablename__ = 'audit_logs'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
user_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='SET NULL'), nullable=True, index=True)
action = db.Column(db.String(100), nullable=False)
entity_type = db.Column(db.String(50), default='')
entity_id = db.Column(db.String(36), default='')
old_values = db.Column(db.JSON, default=dict)
new_values = db.Column(db.JSON, default=dict)
ip_address = db.Column(db.String(50), default='')
user_agent = db.Column(db.String(500), default='')
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
user = db.relationship('User', backref='audits')
def __repr__(self):
return f'<AuditLog {self.action}>'
class Notification(db.Model):
"""In-app notifications"""
__tablename__ = 'notifications'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
user_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False, index=True)
type = db.Column(db.String(50), nullable=False) # alert, reminder, update, coaching
title = db.Column(db.String(255), nullable=False)
message = db.Column(db.Text, default='')
link = db.Column(db.String(500), default='')
is_read = db.Column(db.Boolean, default=False)
read_at = db.Column(db.DateTime, nullable=True)
expires_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"type IN ('alert', 'reminder', 'update', 'coaching')",
name='ck_notifications_type',
),
)
company = db.relationship('Company', back_populates='notifications')
user = db.relationship('User', backref='notifications')
def __repr__(self):
return f'<Notification {self.type}>'
class Setting(db.Model):
"""Company-level settings"""
__tablename__ = 'settings'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
key = db.Column(db.String(100), nullable=False)
value = db.Column(db.JSON, default=dict)
description = db.Column(db.Text, default='')
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.UniqueConstraint('company_id', 'key', name='uq_settings_company_key'),
)
company = db.relationship('Company', back_populates='company_settings')
def __repr__(self):
return f'<Setting {self.key}>'
class UserSetting(db.Model):
"""Per-user settings (notification prefs, appearance, etc.)"""
__tablename__ = 'user_settings'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
user_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False, index=True)
key = db.Column(db.String(100), nullable=False)
value = db.Column(db.JSON, default=dict)
__table_args__ = (
db.UniqueConstraint('user_id', 'key', name='uq_user_settings_user_key'),
)
user = db.relationship('User', back_populates='user_settings')
def __repr__(self):
return f'<UserSetting {self.user_id}/{self.key}>'
class UserSession(db.Model):
"""Active user sessions tracking"""
__tablename__ = 'user_sessions'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
user_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False, index=True)
session_id = db.Column(db.String(255), nullable=False, index=True)
ip_address = db.Column(db.String(50), default='')
user_agent = db.Column(db.String(500), default='')
device_info = db.Column(db.String(255), default='')
is_current = db.Column(db.Boolean, default=False)
last_seen_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
expires_at = db.Column(db.DateTime, nullable=True)
def __repr__(self):
return f'<UserSession {self.user_id}/{self.session_id[:8]}>'
class PasswordResetToken(db.Model):
"""One-time password reset tokens (sha256-hashed, 1 hour expiry)."""
__tablename__ = 'password_reset_tokens'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
user_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False, index=True)
token_hash = db.Column(db.String(64), nullable=False, unique=True, index=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
expires_at = db.Column(db.DateTime, nullable=False)
used_at = db.Column(db.DateTime, nullable=True)
@property
def is_valid(self):
"""Token is valid if unused and not expired."""
if self.used_at is not None:
return False
expires = self.expires_at
if expires is not None and expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
return expires is not None and expires > datetime.now(timezone.utc)
def __repr__(self):
return f'<PasswordResetToken {self.user_id}>'
class Template(db.Model):
"""Goal/project templates"""
__tablename__ = 'templates'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
name = db.Column(db.String(255), nullable=False)
type = db.Column(db.String(50), default='goal') # goal, project, report
description = db.Column(db.Text, default='')
content_json = db.Column(db.JSON, default=dict)
is_active = db.Column(db.Boolean, default=True)
is_default = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', back_populates='templates')
def __repr__(self):
return f'<Template {self.name}>'
class CustomField(db.Model):
"""Extensible fields per entity"""
__tablename__ = 'custom_fields'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
entity_type = db.Column(db.String(50), nullable=False) # project, goal, etc.
name = db.Column(db.String(255), nullable=False)
field_type = db.Column(db.String(50), default='text') # text, number, date, select, checkbox
options_json = db.Column(db.JSON, default=list)
is_required = db.Column(db.Boolean, default=False)
display_order = db.Column(db.Integer, default=0)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', back_populates='custom_fields')
def __repr__(self):
return f'<CustomField {self.entity_type}.{self.name}>'
class ROICalculation(db.Model):
"""ROI computation results"""
__tablename__ = 'roi_calculations'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
product = db.Column(db.String(255), default='')
channel = db.Column(db.String(100), default='')
market = db.Column(db.String(100), default='')
spend = db.Column(db.Float, nullable=True)
revenue = db.Column(db.Float, nullable=True)
roi = db.Column(db.Float, nullable=True) # percentage
ltv = db.Column(db.Float, nullable=True) # lifetime value
cac = db.Column(db.Float, nullable=True) # customer acquisition cost
ltv_cac_ratio = db.Column(db.Float, nullable=True)
period = db.Column(db.String(20), default='monthly')
period_start = db.Column(db.DateTime, nullable=True)
period_end = db.Column(db.DateTime, nullable=True)
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', back_populates='roi_calculations')
@property
def net_revenue(self):
if self.spend is None or self.revenue is None:
return None
return self.revenue - self.spend
def __repr__(self):
return f'<ROICalculation {self.product}/{self.channel} roi={self.roi}>'
# =============================================================================
# CRM models β populated by connector sync (HubSpot, etc.)
# =============================================================================
class CrmContact(db.Model):
"""Contacts synced from CRM (HubSpot)."""
__tablename__ = 'crm_contacts'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
external_id = db.Column(db.String(128), nullable=False, index=True) # HubSpot object ID
email = db.Column(db.String(255), default='')
first_name = db.Column(db.String(255), default='')
last_name = db.Column(db.String(255), default='')
phone = db.Column(db.String(50), default='')
company_name = db.Column(db.String(255), default='')
lifecycle_stage = db.Column(db.String(50), default='') # subscriber, lead, marketingqualifiedlead, etc.
market = db.Column(db.String(100), default='') # geographic/service market
status = db.Column(db.String(50), default='active') # active, inactive, unsubscribed
hubspot_owner_id = db.Column(db.String(64), default='')
properties_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref='crm_contacts')
__table_args__ = (
db.UniqueConstraint('company_id', 'external_id', name='uq_crm_contact_company_external'),
)
@property
def full_name(self):
parts = [self.first_name, self.last_name]
return ' '.join(p for p in parts if p)
def __repr__(self):
return f'<CrmContact {self.full_name} ({self.external_id})>'
class CrmCompany(db.Model):
"""Companies/accounts synced from CRM (HubSpot)."""
__tablename__ = 'crm_companies'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
external_id = db.Column(db.String(128), nullable=False, index=True) # HubSpot company ID
name = db.Column(db.String(255), default='')
domain = db.Column(db.String(255), default='')
industry = db.Column(db.String(100), default='')
num_employees = db.Column(db.Integer, nullable=True)
properties_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref='crm_companies')
__table_args__ = (
db.UniqueConstraint('company_id', 'external_id', name='uq_crm_company_company_external'),
)
def __repr__(self):
return f'<CrmCompany {self.name} ({self.external_id})>'
class CrmDeal(db.Model):
"""Deals/opportunities synced from CRM (HubSpot)."""
__tablename__ = 'crm_deals'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
external_id = db.Column(db.String(128), nullable=False, index=True) # HubSpot deal ID
name = db.Column(db.String(255), default='')
amount = db.Column(db.Float, nullable=True)
stage = db.Column(db.String(100), default='') # e.g. 'appointments_set', 'closedwon'
pipeline = db.Column(db.String(100), default='')
probability = db.Column(db.Float, nullable=True) # 0.0 to 1.0
expected_close_date = db.Column(db.DateTime, nullable=True)
contact_id = db.Column(db.String(36), db.ForeignKey('crm_contacts.id', ondelete='SET NULL'), nullable=True, index=True)
deal_owner_id = db.Column(db.String(64), default='')
properties_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref='crm_deals')
contact = db.relationship('CrmContact')
__table_args__ = (
db.UniqueConstraint('company_id', 'external_id', name='uq_crm_deal_company_external'),
)
def __repr__(self):
return f'<CrmDeal {self.name} ${self.amount} ({self.external_id})>'
# =============================================================================
# Angi lead models β lead lifecycle management for Angi/HomeAdvisor
# =============================================================================
class AngiLead(db.Model):
"""Angi lead β managed from dashboard with full lifecycle tracking."""
__tablename__ = 'angi_leads'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
angi_lead_id = db.Column(db.String(128), nullable=False, index=True) # Angi's lead ID
connector_id = db.Column(db.String(36), nullable=False, index=True) # FK to connector
# Lead contact info
first_name = db.Column(db.String(255), default='')
last_name = db.Column(db.String(255), default='')
email = db.Column(db.String(255), default='')
phone = db.Column(db.String(50), default='')
# Lead project details
project_type = db.Column(db.String(255), default='')
description = db.Column(db.Text, default='')
budget = db.Column(db.String(100), default='')
budget_value = db.Column(db.Float, nullable=True) # Parsed numeric value
timeline = db.Column(db.String(100), default='')
address = db.Column(db.String(500), default='')
city = db.Column(db.String(100), default='')
state = db.Column(db.String(50), default='')
zip_code = db.Column(db.String(20), default='')
# Lead lifecycle
status = db.Column(db.String(50), default='new') # new, responded, contacted, scheduled, won, lost, expired
priority = db.Column(db.String(20), default='medium') # high, medium, low
is_premium = db.Column(db.Boolean, default=False)
# Response tracking
response_time_minutes = db.Column(db.Integer, nullable=True) # Time from lead receipt to first response
responded_at = db.Column(db.DateTime, nullable=True)
last_contacted_at = db.Column(db.DateTime, nullable=True)
# Angi-specific
is_duplicate = db.Column(db.Boolean, default=False)
lead_source = db.Column(db.String(50), default='angi') # angi, homeadvisor
cost_per_lead = db.Column(db.Float, nullable=True) # If available from Angi billing
# Internal notes
internal_notes = db.Column(db.Text, default='')
# SMS/Call integration fields
sms_opted_out = db.Column(db.Boolean, default=False) # TCPA opt-out flag synced from OptOut table
first_contact_at = db.Column(db.DateTime, nullable=True) # Timestamp of first outbound SMS or call
# Angi interview & compliance (P0 β remodeling gap plan, July 2026)
interview_qa = db.Column(db.Text, nullable=True) # Angi interview questionnaire answers (JSON)
tcpa_compliance = db.Column(db.Boolean, default=False) # TCPA consent flag
match_type = db.Column(db.String(50), default="") # e.g. 'basic', 'premium', 'exclusive'
# Timestamps
received_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', back_populates='angi_leads')
__table_args__ = (
db.UniqueConstraint('company_id', 'angi_lead_id', name='uq_angi_lead_company_angi_id'),
db.CheckConstraint(
"status IN ('new', 'accepted', 'responded', 'contacted', 'scheduled', 'won', 'lost', 'expired', 'rejected', 'sms_sent', 'replied', 'call_attempted', 'opted_out')",
name='ck_angi_leads_status',
),
db.CheckConstraint(
"priority IN ('high', 'medium', 'low')",
name='ck_angi_leads_priority',
),
)
@property
def full_name(self):
parts = [self.first_name, self.last_name]
return ' '.join(p for p in parts if p)
@property
def full_address(self):
parts = [self.address, self.city]
if self.state:
parts.append(f"{self.state} {self.zip_code}".strip())
else:
if self.zip_code:
parts.append(self.zip_code)
return ', '.join(p for p in parts if p)
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'angi_lead_id': self.angi_lead_id,
'connector_id': self.connector_id,
'first_name': self.first_name,
'last_name': self.last_name,
'full_name': self.full_name,
'email': self.email,
'phone': self.phone,
'project_type': self.project_type,
'description': self.description,
'budget': self.budget,
'budget_value': self.budget_value,
'timeline': self.timeline,
'address': self.address,
'city': self.city,
'state': self.state,
'zip_code': self.zip_code,
'full_address': self.full_address,
'status': self.status,
'priority': self.priority,
'is_premium': self.is_premium,
'response_time_minutes': self.response_time_minutes,
'responded_at': self.responded_at.isoformat() if self.responded_at else None,
'last_contacted_at': self.last_contacted_at.isoformat() if self.last_contacted_at else None,
'is_duplicate': self.is_duplicate,
'lead_source': self.lead_source,
'cost_per_lead': self.cost_per_lead,
'internal_notes': self.internal_notes,
'sms_opted_out': self.sms_opted_out,
'first_contact_at': self.first_contact_at.isoformat() if self.first_contact_at else None,
'interview_qa': self.interview_qa,
'tcpa_compliance': self.tcpa_compliance,
'match_type': self.match_type,
'received_at': self.received_at.isoformat() if self.received_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<AngiLead {self.full_name} ({self.angi_lead_id}) {self.status}>'
class AngiLeadAction(db.Model):
"""Audit log of actions taken on Angi leads (responses, status changes, etc.)"""
__tablename__ = 'angi_lead_actions'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
lead_id = db.Column(db.String(36), db.ForeignKey('angi_leads.id', ondelete='CASCADE'), nullable=False, index=True)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
action_type = db.Column(db.String(50), nullable=False) # received, accepted, responded, rejected, status_change, note_added
details = db.Column(db.Text, default='')
message_content = db.Column(db.Text, default='') # For response messages
performed_by = db.Column(db.String(36), nullable=True) # User ID who performed the action
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
lead = db.relationship('AngiLead', backref='actions')
company = db.relationship('Company')
__table_args__ = (
db.CheckConstraint(
"action_type IN ('received', 'accepted', 'responded', 'rejected', 'status_change', 'note_added')",
name='ck_angi_lead_actions_type',
),
)
def to_dict(self):
return {
'id': self.id,
'lead_id': self.lead_id,
'company_id': self.company_id,
'action_type': self.action_type,
'details': self.details,
'message_content': self.message_content,
'performed_by': self.performed_by,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self):
return f'<AngiLeadAction {self.action_type} on lead {self.lead_id}>'
# =============================================================================
# Accounting models β populated by QuickBooks, etc.
# =============================================================================
class AccountingRecord(db.Model):
"""Generic accounting records synced from QuickBooks and other accounting tools."""
__tablename__ = 'accounting_records'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
external_id = db.Column(db.String(128), nullable=False, index=True)
source_service = db.Column(db.String(50), nullable=False, default='quickbooks') # quickbooks, xero, etc.
record_type = db.Column(db.String(50), nullable=False, index=True) # Invoice, Payment, Customer, Expense
name = db.Column(db.String(255), default='')
amount = db.Column(db.Float, nullable=True)
currency = db.Column(db.String(10), default='USD')
status = db.Column(db.String(50), default='') # Paid, Pending, Draft, etc.
due_date = db.Column(db.DateTime, nullable=True)
transaction_date = db.Column(db.DateTime, nullable=True)
contact_name = db.Column(db.String(255), default='')
contact_email = db.Column(db.String(255), default='')
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref='accounting_records')
__table_args__ = (
db.UniqueConstraint('company_id', 'external_id', name='uq_accounting_company_external'),
)
def __repr__(self):
return f'<AccountingRecord {self.record_type} {self.external_id}>'
# =============================================================================
# QuickBooks models β populated by QuickBooks Online connector
# =============================================================================
class QuickbooksInvoice(db.Model):
"""Invoices synced from QuickBooks Online."""
__tablename__ = 'quickbooks_invoices'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
qb_doc_id = db.Column(db.String(100), unique=True, nullable=False, index=True)
qb_realm_id = db.Column(db.String(50), nullable=True)
customer_name = db.Column(db.String(255), default='')
invoice_num = db.Column(db.String(100), default='')
total_amount = db.Column(db.Float, nullable=True)
tax_amount = db.Column(db.Float, default=0.0)
status = db.Column(db.String(20), default='') # Draft, Sent, Paid, Void
due_date = db.Column(db.DateTime, nullable=True)
tx_date = db.Column(db.DateTime, nullable=True)
line_items_json = db.Column(db.JSON, default=dict)
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref=db.backref('quickbooks_invoices', cascade='all, delete-orphan'))
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'qb_doc_id': self.qb_doc_id,
'qb_realm_id': self.qb_realm_id,
'customer_name': self.customer_name,
'invoice_num': self.invoice_num,
'total_amount': self.total_amount,
'tax_amount': self.tax_amount,
'status': self.status,
'due_date': self.due_date.isoformat() if self.due_date else None,
'tx_date': self.tx_date.isoformat() if self.tx_date else None,
'line_items': self.line_items_json,
'metadata': self.metadata_json,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<QuickbooksInvoice {self.invoice_num} {self.qb_doc_id}>'
class QuickbooksTransaction(db.Model):
"""Transactions synced from QuickBooks Online."""
__tablename__ = 'quickbooks_transactions'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
qb_doc_id = db.Column(db.String(100), unique=True, nullable=False, index=True)
qb_realm_id = db.Column(db.String(50), nullable=True)
tx_type = db.Column(db.String(50), default='') # Invoice, CreditMemo, JournalEntry, Payment
tx_date = db.Column(db.DateTime, nullable=True)
amount = db.Column(db.Float, nullable=True)
account_name = db.Column(db.String(255), default='')
account_id = db.Column(db.String(100), default='')
memo = db.Column(db.Text, default='')
class_name = db.Column(db.String(255), default='')
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref=db.backref('quickbooks_transactions', cascade='all, delete-orphan'))
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'qb_doc_id': self.qb_doc_id,
'qb_realm_id': self.qb_realm_id,
'tx_type': self.tx_type,
'tx_date': self.tx_date.isoformat() if self.tx_date else None,
'amount': self.amount,
'account_name': self.account_name,
'account_id': self.account_id,
'memo': self.memo,
'class_name': self.class_name,
'metadata': self.metadata_json,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<QuickbooksTransaction {self.tx_type} {self.qb_doc_id}>'
class QuickbooksCustomer(db.Model):
"""Customers synced from QuickBooks Online."""
__tablename__ = 'quickbooks_customers'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
qb_doc_id = db.Column(db.String(100), nullable=False, index=True)
qb_realm_id = db.Column(db.String(50), nullable=True)
display_name = db.Column(db.String(255), default='')
email = db.Column(db.String(255), default='')
phone = db.Column(db.String(50), default='')
balance = db.Column(db.Float, default=0.0)
billing_address_json = db.Column(db.JSON, default=dict)
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref=db.backref('quickbooks_customers', cascade='all, delete-orphan'))
__table_args__ = (
db.UniqueConstraint('company_id', 'qb_doc_id'),
)
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'qb_doc_id': self.qb_doc_id,
'qb_realm_id': self.qb_realm_id,
'display_name': self.display_name,
'email': self.email,
'phone': self.phone,
'balance': self.balance,
'billing_address': self.billing_address_json,
'metadata': self.metadata_json,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<QuickbooksCustomer {self.display_name} {self.qb_doc_id}>'
class QuickbooksExpense(db.Model):
"""Expenses synced from QuickBooks Online."""
__tablename__ = 'quickbooks_expenses'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
qb_doc_id = db.Column(db.String(100), nullable=False, index=True)
qb_realm_id = db.Column(db.String(50), nullable=True)
account_name = db.Column(db.String(255), default='')
amount = db.Column(db.Float, nullable=True)
vendor_name = db.Column(db.String(255), default='')
tx_date = db.Column(db.DateTime, nullable=True)
memo = db.Column(db.Text, default='')
category = db.Column(db.String(100), default='')
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref=db.backref('quickbooks_expenses', cascade='all, delete-orphan'))
__table_args__ = (
db.UniqueConstraint('company_id', 'qb_doc_id'),
)
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'qb_doc_id': self.qb_doc_id,
'qb_realm_id': self.qb_realm_id,
'account_name': self.account_name,
'amount': self.amount,
'vendor_name': self.vendor_name,
'tx_date': self.tx_date.isoformat() if self.tx_date else None,
'memo': self.memo,
'category': self.category,
'metadata': self.metadata_json,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self):
return f'<QuickbooksExpense {self.qb_doc_id}>'
# =============================================================================
# Advertising models β populated by Google Ads, Facebook Ads, etc.
# =============================================================================
class AdCampaign(db.Model):
"""Ad campaigns synced from Google Ads, Facebook Ads, etc."""
__tablename__ = 'ad_campaigns'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
external_id = db.Column(db.String(128), nullable=False, index=True)
source_service = db.Column(db.String(50), nullable=False) # google_ads, facebook_ads
name = db.Column(db.String(255), default='')
status = db.Column(db.String(50), default='') # ENABLED, PAUSED, REMOVED, etc.
budget = db.Column(db.Float, nullable=True) # daily or lifetime budget
budget_type = db.Column(db.String(50), default='') # daily, lifetime
start_date = db.Column(db.DateTime, nullable=True)
end_date = db.Column(db.DateTime, nullable=True)
channel_type = db.Column(db.String(50), default='') # SEARCH, DISPLAY, etc.
parent_campaign_id = db.Column(db.String(128), nullable=True)
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref='ad_campaigns')
__table_args__ = (
db.UniqueConstraint('company_id', 'external_id', name='uq_ad_campaign_company_external'),
)
def __repr__(self):
return f'<AdCampaign {self.source_service} {self.name}>'
class AdMetric(db.Model):
"""Daily ad performance metrics synced from advertising platforms."""
__tablename__ = 'ad_metrics'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
external_campaign_id = db.Column(db.String(128), nullable=True, index=True)
source_service = db.Column(db.String(50), nullable=False) # google_ads, facebook_ads
metric_date = db.Column(db.Date, nullable=False, index=True)
spend = db.Column(db.Float, default=0.0)
impressions = db.Column(db.BigInteger, default=0)
clicks = db.Column(db.Integer, default=0)
conversions = db.Column(db.Float, default=0.0)
ctr = db.Column(db.Float, nullable=True) # click-through rate
cpc = db.Column(db.Float, nullable=True) # cost per click
cpv = db.Column(db.Float, nullable=True) # cost per view
roas = db.Column(db.Float, nullable=True) # return on ad spend
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref='ad_metrics')
def __repr__(self):
return f'<AdMetric {self.source_service} {self.metric_date}>'
# =============================================================================
# Lead Generation Engine β campaign templates, creatives, keywords, attribution
# =============================================================================
class AdTemplate(db.Model):
"""Reusable campaign templates organized by vertical/service type.
Templates define the structure: campaign settings, ad groups, keywords,
and ad copy. Users fill in budget, geo radius, and city β system
generates actual campaigns on Google/Facebook Ads.
"""
__tablename__ = 'ad_templates'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
name = db.Column(db.String(255), nullable=False) # e.g. "Emergency Roof Repair"
slug = db.Column(db.String(100), nullable=False, unique=True, index=True) # "roofing-emergency-repair"
vertical = db.Column(db.String(50), nullable=False, index=True) # roofing, hvac, plumbing
description = db.Column(db.Text, default='')
budget_recommendation = db.Column(db.Float, nullable=True) # monthly minimum
target_cpa = db.Column(db.Float, nullable=True) # recommended cost-per-acquire
bidding_strategy = db.Column(db.String(50), default='target_cpa') # target_cpa, maximize_clicks, manual_cpc
# Template structure (stored as JSON β campaigns, ad groups, keywords, ad copy)
structure_json = db.Column(db.JSON, default=dict)
# Status
is_active = db.Column(db.Boolean, default=True, nullable=False)
# Metadata
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
def __repr__(self):
return f'<AdTemplate {self.vertical}/{self.slug}>'
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'slug': self.slug,
'vertical': self.vertical,
'description': self.description,
'budget_recommendation': self.budget_recommendation,
'target_cpa': self.target_cpa,
'bidding_strategy': self.bidding_strategy,
'structure': self.structure_json,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
class AdCreative(db.Model):
"""Ad copy and creative variants organized by vertical.
Used for A/B testing and populating campaigns from templates.
Each creative tracks which variants won/lost.
"""
__tablename__ = 'ad_creatives'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=True, index=True) # nullable = system-wide templates
vertical = db.Column(db.String(50), nullable=False, index=True)
service_type = db.Column(db.String(100), nullable=False) # emergency-repair, new-install, seasonal-maintenance
ad_type = db.Column(db.String(50), nullable=False) # responsive_search, expanded_text, image, video
# Content
headlines = db.Column(db.JSON, default=list) # array of headline variants (max 15 for RSA)
descriptions = db.Column(db.JSON, default=list) # array of description variants (max 5 for RSA)
display_url = db.Column(db.String(500), default='')
final_url = db.Column(db.String(500), default='')
image_url = db.Column(db.String(500), default='') # for image ads
call_to_action = db.Column(db.String(100), default='')
# Performance tracking
total_impressions = db.Column(db.BigInteger, default=0)
total_clicks = db.Column(db.Integer, default=0)
total_conversions = db.Column(db.Float, default=0.0)
avg_ctr = db.Column(db.Float, nullable=True)
# Status
is_active = db.Column(db.Boolean, default=True, nullable=False)
is_winner = db.Column(db.Boolean, default=False) # auto-set after A/B test
# Metadata
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref=db.backref('ad_creatives', cascade='all, delete-orphan'))
def __repr__(self):
return f'<AdCreative {self.vertical}/{self.service_type} {self.id[:8]}>'
class AdKeyword(db.Model):
"""Keywords tracked per campaign/ad group with performance data.
Stores the keyword list used in search campaigns plus match type
and performance metrics for optimization decisions.
"""
__tablename__ = 'ad_keywords'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
source_service = db.Column(db.String(50), nullable=False) # google_ads, facebook_ads
external_campaign_id = db.Column(db.String(128), nullable=False, index=True)
external_ad_group_id = db.Column(db.String(128), nullable=True)
# Keyword
text = db.Column(db.String(255), nullable=False) # the keyword
match_type = db.Column(db.String(20), default='phrase') # broad, phrase, exact
# Bidding
max_cpc = db.Column(db.Float, nullable=True) # max cost-per-click bid
target_cpa = db.Column(db.Float, nullable=True)
# Status
status = db.Column(db.String(20), default='enabled') # enabled, paused, removed
# Performance (updated via sync or attribution)
total_impressions = db.Column(db.BigInteger, default=0)
total_clicks = db.Column(db.Integer, default=0)
total_spend = db.Column(db.Float, default=0.0)
total_conversions = db.Column(db.Float, default=0.0)
avg_position = db.Column(db.Float, nullable=True)
avg_ctr = db.Column(db.Float, nullable=True)
# Research data (populated from Keyword Planner or similar source)
search_volume = db.Column(db.BigInteger, nullable=True) # monthly average
competition_level = db.Column(db.String(20), nullable=True) # low, medium, high
cpc_suggestion = db.Column(db.Float, nullable=True) # suggested bid in cents
trend_data = db.Column(db.JSON, nullable=True) # monthly search volume array
# Metadata
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref=db.backref('ad_keywords', cascade='all, delete-orphan'))
__table_args__ = (
db.UniqueConstraint('company_id', 'source_service', 'external_ad_group_id', 'text', 'match_type',
name='uq_ad_keyword_company_service_group_text_match'),
)
def __repr__(self):
return f'<AdKeyword "{self.text}" {self.match_type}>'
class LeadAttribution(db.Model):
"""Maps ad clicks to leads and deals β closes the attribution loop.
When a form is submitted, UTM params from the landing page are captured
and linked to the resulting lead. When a deal closes, we calculate
ROAS: deal_amount / ad_spend.
"""
__tablename__ = 'lead_attribution'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
# Lead reference
external_lead_id = db.Column(db.String(200), nullable=True, index=True) # CRM/Angi lead ID
lead_source = db.Column(db.String(50), default='') # web_form, phone_call, email, angi, hubspot
# UTM tracking params
utm_source = db.Column(db.String(100), default='') # google_ads, facebook_ads, organic
utm_campaign = db.Column(db.String(255), default='', index=True)
utm_ad_group = db.Column(db.String(255), default='')
utm_ad = db.Column(db.String(255), default='')
utm_content = db.Column(db.String(255), default='')
utm_medium = db.Column(db.String(50), default='') # cpc, organic, email
utm_term = db.Column(db.String(255), default='') # keyword
# Timing
click_date = db.Column(db.DateTime, nullable=True)
lead_date = db.Column(db.DateTime, nullable=True)
# Cost
click_cost = db.Column(db.Float, nullable=True) # estimated cost of that click
total_ad_spend = db.Column(db.Float, nullable=True) # total spend on campaign at time of conversion
# Revenue
external_deal_id = db.Column(db.String(200), nullable=True) # CRM deal ID when converted
deal_amount = db.Column(db.Float, nullable=True)
deal_closed_date = db.Column(db.DateTime, nullable=True)
roas = db.Column(db.Float, nullable=True) # deal_amount / total_ad_spend
# Status
attribution_status = db.Column(db.String(20), default='click') # click, lead, deal
attribution_window_days = db.Column(db.Integer, default=30)
# Metadata
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref=db.backref('lead_attributions', cascade='all, delete-orphan'))
def __repr__(self):
return f'<LeadAttribution {self.utm_source} {self.utm_campaign[:30] if self.utm_campaign else "unknown"}>'
class OptimizationRule(db.Model):
"""Auto-optimization rules β kill, scale, pause, reallocate.
Configurable thresholds that the optimization engine evaluates daily.
"""
__tablename__ = 'optimization_rules'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
name = db.Column(db.String(255), nullable=False)
rule_type = db.Column(db.String(50), nullable=False, index=True) # kill_high_cpa, scale_low_cpa, pause_zero_conv, kill_low_ctr, budget_reallocation
target_level = db.Column(db.String(20), default='campaign') # campaign, ad_group, ad, keyword
# Rule parameters (JSON β thresholds, conditions, time windows)
params_json = db.Column(db.JSON, default=dict)
# e.g. {"cpa_multiplier": 2.0, "min_spend": 100, "lookback_days": 14, "action": "pause"}
# Status
is_active = db.Column(db.Boolean, default=True, nullable=False)
# Execution
last_run_at = db.Column(db.DateTime, nullable=True)
total_actions = db.Column(db.Integer, default=0)
# Metadata
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref=db.backref('optimization_rules', cascade='all, delete-orphan'))
def __repr__(self):
return f'<OptimizationRule {self.rule_type} {self.name}>'
class OptimizationLog(db.Model):
"""Audit log of auto-optimization actions taken on campaigns/ads/keywords.
Every change is logged with before/after state for rollback capability.
"""
__tablename__ = 'optimization_logs'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
rule_id = db.Column(db.String(36), db.ForeignKey('optimization_rules.id', ondelete='CASCADE'), nullable=True)
# What was changed
source_service = db.Column(db.String(50), nullable=False) # google_ads, facebook_ads
target_type = db.Column(db.String(20), nullable=False) # campaign, ad_group, ad, keyword, budget
target_id = db.Column(db.String(128), nullable=False) # external ID of the entity
target_name = db.Column(db.String(255), default='')
# Action taken
action = db.Column(db.String(50), nullable=False) # paused, resumed, deleted, scaled, rebudgeted, bid_adjusted
# Before/after state (for rollback)
before_json = db.Column(db.JSON, default=dict)
after_json = db.Column(db.JSON, default=dict)
# Reason
reason = db.Column(db.Text, default='') # e.g. "CPA $147 > $50 target Γ 2.0 multiplier over 14d, spend $340"
# Result
status = db.Column(db.String(20), default='success') # success, failed, skipped
error_message = db.Column(db.Text, default='')
# Metadata
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref=db.backref('optimization_logs', cascade='all, delete-orphan'))
rule = db.relationship('OptimizationRule', backref=db.backref('optimization_logs', cascade='all, delete-orphan'))
def __repr__(self):
return f'<OptimizationLog {self.action} {self.target_name[:40]}>'
# =============================================================================
# Revenue Records β actual payment data from POS/accounting connectors
# =============================================================================
class RevenueRecord(db.Model):
"""Ground-truth revenue records synced from POS (Square), accounting (QuickBooks), or CRM invoice payments.
Contrasts with CrmDeal (forecasted/pipeline) β these represent money that actually moved.
The Revenue Leaks engine compares forecasted deal amounts vs actual revenue records.
"""
__tablename__ = 'revenue_records'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
# Source reference
source_service = db.Column(db.String(50), nullable=False, index=True)
external_id = db.Column(db.String(200), nullable=False, index=True)
# Transaction data
transaction_date = db.Column(db.DateTime, nullable=False, index=True)
amount = db.Column(db.Float, nullable=False)
currency = db.Column(db.String(3), default='USD')
status = db.Column(db.String(50)) # completed, pending, refunded, failed
# Location tracking (multi-location companies)
location_id = db.Column(db.String(36), nullable=True, index=True) # FK to locations.id (nullable for backward compat)
location_name = db.Column(db.String(255), nullable=True) # denormalized location name
# Customer info
customer_id = db.Column(db.String(200))
customer_name = db.Column(db.String(500))
customer_email = db.Column(db.String(500))
# Location / branch info (multi-location POS)
location_id = db.Column(db.String(100))
location_name = db.Column(db.String(500))
# Type: payment, invoice, deposit
record_type = db.Column(db.String(50), default='payment')
# Raw metadata for debugging
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(
db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
db.UniqueConstraint('company_id', 'source_service', 'external_id',
name='uq_revenue_record_company_source_external'),
)
company = db.relationship('Company', back_populates='revenue_records')
def __repr__(self):
return f'<RevenueRecord {self.source_service} {self.external_id}>'
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'source_service': self.source_service,
'external_id': self.external_id,
'transaction_date': self.transaction_date.isoformat() if self.transaction_date else None,
'amount': self.amount,
'currency': self.currency,
'status': self.status,
'customer_id': self.customer_id,
'customer_name': self.customer_name,
'customer_email': self.customer_email,
'location_id': self.location_id,
'location_name': self.location_name,
'record_type': self.record_type,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
# =============================================================================
# Location models β multi-location company hierarchy
# =============================================================================
class Location(db.Model):
"""Physical locations/branches for multi-location companies.
Formalizes the location_id/location_name fields that already exist
on RevenueRecord and other denormalized models. Enables proper
rollup analytics and location-level reporting.
"""
__tablename__ = 'locations'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
# Location identity
name = db.Column(db.String(255), nullable=False) # e.g. 'Downtown Store', 'North Branch'
code = db.Column(db.String(50), default='') # Short code: 'DT', 'NB', etc.
# Address
address = db.Column(db.String(500), default='')
city = db.Column(db.String(100), default='')
state = db.Column(db.String(50), default='')
zip_code = db.Column(db.String(20), default='')
country = db.Column(db.String(50), default='US')
# Status
is_active = db.Column(db.Boolean, default=True, nullable=False)
# Metadata
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.UniqueConstraint('company_id', 'name', name='uq_locations_company_name'),
db.UniqueConstraint('company_id', 'code', name='uq_locations_company_code'),
)
company = db.relationship('Company', backref=db.backref('locations', cascade='all, delete-orphan'))
def __repr__(self):
return f'<Location {self.name} @ {self.city or "HQ"}>'
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'name': self.name,
'code': self.code,
'address': self.address,
'city': self.city,
'state': self.state,
'zip_code': self.zip_code,
'country': self.country,
'is_active': self.is_active,
'metadata': self.metadata_json,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
# =============================================================================
# Slack models β workspace and channel data
# =============================================================================
class SlackChannel(db.Model):
"""Slack channels synced from workspace."""
__tablename__ = 'slack_channels'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
external_id = db.Column(db.String(128), nullable=False, index=True) # Slack channel ID
name = db.Column(db.String(255), default='')
is_private = db.Column(db.Boolean, default=False)
is_archived = db.Column(db.Boolean, default=False)
purpose = db.Column(db.Text, default='')
topic = db.Column(db.Text, default='')
created_at_ts = db.Column(db.DateTime, nullable=True)
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref='slack_channels')
__table_args__ = (
db.UniqueConstraint('company_id', 'external_id', name='uq_slack_channel_company_external'),
)
def __repr__(self):
return f'<SlackChannel #{self.name}>'
# =============================================================================
# Generic external data β for REST API and other connectors
# =============================================================================
class ExternalSyncRecord(db.Model):
"""Generic records synced from external APIs via Generic REST connector or other integrations."""
__tablename__ = 'external_sync_records'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
external_id = db.Column(db.String(128), nullable=False, index=True)
source_service = db.Column(db.String(50), nullable=False)
entity_type = db.Column(db.String(100), default='') # e.g. 'contact', 'opportunity', 'lead'
name = db.Column(db.String(255), default='')
status = db.Column(db.String(100), default='')
properties_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref='external_sync_records')
__table_args__ = (
db.UniqueConstraint('company_id', 'external_id', name='uq_external_sync_company_external'),
)
def __repr__(self):
return f'<ExternalSyncRecord {self.source_service}/{self.entity_type} {self.external_id}>'
class DemoRequest(db.Model):
"""Landing page demo request leads"""
__tablename__ = 'demo_requests'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(255), nullable=False)
email = db.Column(db.String(255), nullable=False, index=True)
company = db.Column(db.String(255), nullable=True)
phone = db.Column(db.String(50), nullable=True)
message = db.Column(db.Text, nullable=True)
status = db.Column(db.String(20), default='new') # new, contacted, converted, lost
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
def to_dict(self):
return {
'id': str(self.id),
'name': self.name,
'email': self.email,
'company': self.company or '',
'phone': self.phone,
'message': self.message or '',
'status': self.status,
'source': 'landing_page',
'assignedTo': None,
'createdAt': self.created_at.isoformat() if self.created_at else '',
'notes': self.message or '',
}
def __repr__(self):
return f'<DemoRequest {self.name} ({self.email})>'
class SSOConfig(db.Model):
"""SAML SSO configuration per company"""
__tablename__ = 'sso_configs'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
provider_name = db.Column(db.String(50), default='custom') # okta, azure-ad, one-login, custom
sso_url = db.Column(db.String(500), default='') # IdP SSO URL
sso_binding = db.Column(db.String(10), default='post') # post, get
idp_cert = db.Column(db.Text, default='') # PEM certificate
entity_id = db.Column(db.String(500), default='') # IdP entity ID
acs_url = db.Column(db.String(500), default='') # Assertion Consumer Service URL
sp_metadata_url = db.Column(db.String(500), default='') # SP metadata URL
sp_cert = db.Column(db.Text, default='') # SP certificate (optional, for signed assertions)
enabled = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', backref='sso_config')
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'provider_name': self.provider_name,
'sso_url': self.sso_url,
'sso_binding': self.sso_binding,
'idp_cert': self.idp_cert,
'entity_id': self.entity_id,
'acs_url': self.acs_url,
'sp_metadata_url': self.sp_metadata_url,
'sp_cert': self.sp_cert,
'enabled': self.enabled,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<SSOConfig {self.provider_name} company={self.company_id}>'
class Subscription(db.Model):
"""Stripe subscription tracking per company"""
__tablename__ = 'subscriptions'
# βββ Plan ID β Stripe Price ID mapping ββββββββββββββββββββββββββββββββββ
# Maps Stripe price IDs (from env) to plan slugs.
# Populated lazily on first access so env vars are guaranteed to be loaded.
# Fallback: any unrecognised price_id β 'launch' (free tier).
_PLAN_MAP: dict = {} # price_id β plan_id; populated in _build_plan_map()
@classmethod
def _build_plan_map(cls) -> None:
"""Build the price_id β plan_id lookup from environment variables."""
import os
cls._PLAN_MAP = {
os.environ.get('STRIPE_PRICE_LAUNCH', 'price_launch'): 'launch',
os.environ.get('STRIPE_PRICE_GROWTH', 'price_growth'): 'growth',
os.environ.get('STRIPE_PRICE_COMMAND', 'price_command'): 'command',
os.environ.get('STRIPE_PRICE_ENTERPRISE', 'price_enterprise'): 'enterprise',
}
# Remove empty-string keys (env var unset β falls through to default)
cls._PLAN_MAP = {k: v for k, v in cls._PLAN_MAP.items() if k}
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True, unique=True)
stripe_subscription_id = db.Column(db.String(255), default='', index=True)
stripe_price_id = db.Column(db.String(255), default='')
stripe_status = db.Column(db.String(50), default='none') # trialing, active, past_due, canceled, incomplete
current_period_start = db.Column(db.DateTime, nullable=True)
current_period_end = db.Column(db.DateTime, nullable=True)
cancel_at_period_end = db.Column(db.Boolean, default=False)
trial_end = db.Column(db.DateTime, nullable=True)
# Idempotency/ordering guard: Stripe `event.created` of the newest
# subscription-scoped webhook event applied to this row. Stale (out-of-order)
# webhook retries with an older timestamp are ignored.
last_event_created = db.Column(db.Integer, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"stripe_status IN ('trialing', 'active', 'past_due', 'canceled', 'incomplete', 'none')",
name='ck_subscriptions_stripe_status',
),
)
company = db.relationship('Company', backref='subscription')
@property
def is_active(self):
return self.stripe_status in ('active', 'trialing')
@property
def plan_id(self) -> str:
"""Map stripe_price_id β plan slug using _PLAN_MAP.
The map is built lazily from environment variables so Stripe price IDs
(STRIPE_PRICE_LAUNCH, STRIPE_PRICE_GROWTH, STRIPE_PRICE_COMMAND,
STRIPE_PRICE_ENTERPRISE) are resolved at runtime rather than import
time. Falls back to 'launch' for free/unrecognised price IDs.
"""
price_id = self.stripe_price_id or ''
if not price_id:
# No paid subscription recorded β treat as free Launch tier
return 'launch'
# Build map lazily (once per process)
if not self.__class__._PLAN_MAP:
self.__class__._build_plan_map()
return self.__class__._PLAN_MAP.get(price_id, 'launch')
def __repr__(self):
return f'<Subscription {self.stripe_status} company={self.company_id}>'
# =============================================================================
# Portfolio models β PE-backed portfolio management (grouping companies)
# =============================================================================
portfolio_companies = db.Table('portfolio_companies',
db.Column('id', db.String(36), primary_key=True, default=gen_uuid),
db.Column('portfolio_id', db.String(36), db.ForeignKey('portfolios.id', ondelete='CASCADE'), nullable=False, index=True),
db.Column('company_id', db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True),
db.Column('added_at', db.DateTime, default=lambda: datetime.now(timezone.utc)),
)
class Portfolio(db.Model):
"""Collections of companies for PE-backed portfolio management"""
__tablename__ = 'portfolios'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, default='')
created_by = db.Column(db.String(36), db.ForeignKey('profiles.id'), nullable=False, index=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
companies = db.relationship('Company', secondary=portfolio_companies, backref=db.backref('portfolios', lazy='dynamic'))
creator = db.relationship('User')
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'description': self.description or '',
'created_by': self.created_by,
'created_by_name': self.creator.full_name if self.creator else '',
'created_by_email': self.creator.email if self.creator else '',
'company_count': len(self.companies),
'companies': [{
'id': c.id,
'name': c.name,
'industry': c.industry,
'annual_revenue': c.annual_revenue,
} for c in self.companies],
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<Portfolio {self.name}>'
# =============================================================================
# Email Campaign models β outbound email delivery tracking
# =============================================================================
class EmailCampaign(db.Model):
"""Outbound email campaign tracking"""
__tablename__ = 'email_campaigns'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
name = db.Column(db.String(255), nullable=False)
status = db.Column(db.String(50), default='draft') # draft, scheduled, sending, sent, completed, paused, failed
subject = db.Column(db.String(500), default='')
body = db.Column(db.Text, default='')
created_by = db.Column(db.String(36), db.ForeignKey('profiles.id'), nullable=False, index=True)
sent_at = db.Column(db.DateTime, nullable=True)
total_sent = db.Column(db.Integer, default=0)
delivered = db.Column(db.Integer, default=0)
opened = db.Column(db.Integer, default=0)
bounced = db.Column(db.Integer, default=0)
failed = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
logs = db.relationship('EmailDeliveryLog', back_populates='campaign', cascade='all, delete-orphan')
creator = db.relationship('User')
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'status': self.status,
'subject': self.subject,
'body': self.body or '',
'created_by': self.created_by,
'created_by_name': self.creator.full_name if self.creator else '',
'created_by_email': self.creator.email if self.creator else '',
'sent_at': self.sent_at.isoformat() if self.sent_at else None,
'total_sent': self.total_sent,
'delivered': self.delivered,
'opened': self.opened,
'bounced': self.bounced,
'failed': self.failed,
'log_count': len(self.logs),
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<EmailCampaign {self.name}>'
class EmailDeliveryLog(db.Model):
"""Individual email delivery records for a campaign"""
__tablename__ = 'email_delivery_logs'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
campaign_id = db.Column(db.String(36), db.ForeignKey('email_campaigns.id'), nullable=False, index=True)
recipient = db.Column(db.String(255), nullable=False)
status = db.Column(db.String(50), default='pending') # pending, sent, delivered, opened, bounced, failed
sent_at = db.Column(db.DateTime, nullable=True)
delivered_at = db.Column(db.DateTime, nullable=True)
opened_at = db.Column(db.DateTime, nullable=True)
error_message = db.Column(db.Text, default='')
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
campaign = db.relationship('EmailCampaign', back_populates='logs')
def to_dict(self):
return {
'id': self.id,
'campaign_id': self.campaign_id,
'recipient': self.recipient,
'status': self.status,
'sent_at': self.sent_at.isoformat() if self.sent_at else None,
'delivered_at': self.delivered_at.isoformat() if self.delivered_at else None,
'opened_at': self.opened_at.isoformat() if self.opened_at else None,
'error_message': self.error_message,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self):
return f'<EmailDeliveryLog {self.recipient} {self.status}>'
class ApiKey(db.Model):
"""API keys for programmatic access"""
__tablename__ = 'api_keys'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
user_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False, index=True)
name = db.Column(db.String(255), nullable=False)
prefix = db.Column(db.String(10), default='csk_', nullable=False)
hashed_key = db.Column(db.String(255), nullable=False, unique=True)
last_used_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
expires_at = db.Column(db.DateTime, nullable=True)
permissions = db.Column(db.JSON, default=dict)
__table_args__ = ()
user = db.relationship('User', backref='api_keys')
def to_dict(self, include_key: bool = False):
result = {
'id': self.id,
'name': self.name,
'prefix': self.prefix,
'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'permissions': self.permissions or {},
}
if include_key:
result['key'] = self.hashed_key # Only returned once on creation
return result
def __repr__(self):
return f'<ApiKey {self.name}>'
# =============================================================================
# SMS + Call Connector models β telephony integration
# =============================================================================
class SmsMessage(db.Model):
"""SMS messages β inbound and outbound, with delivery tracking."""
__tablename__ = 'sms_messages'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
connector_id = db.Column(db.String(36), nullable=False, index=True) # FK to Connector
lead_id = db.Column(db.String(36), db.ForeignKey('angi_leads.id', ondelete='SET NULL'), nullable=True, index=True)
from_phone = db.Column(db.String(50), nullable=False) # Our business number
to_phone = db.Column(db.String(50), nullable=False) # Lead's phone
direction = db.Column(db.String(10), nullable=False) # 'outbound' | 'inbound'
body = db.Column(db.Text, nullable=False) # Message content
status = db.Column(db.String(20), default='queued') # queued, sent, delivered, failed, rejected
error_code = db.Column(db.String(50), default='') # Provider error code on failure
error_message = db.Column(db.Text, default='') # Human-readable error
provider_message_sid = db.Column(db.String(128), default='') # Twilio/Plivo message SID
keyword_flag = db.Column(db.String(20), default='') # STOP, YES, CALL_ME (for inbound)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', back_populates='sms_messages')
lead = db.relationship('AngiLead')
__table_args__ = (
db.CheckConstraint("direction IN ('outbound', 'inbound')", name='ck_sms_direction'),
db.CheckConstraint("status IN ('queued', 'sent', 'delivered', 'received', 'failed', 'rejected')", name='ck_sms_status'),
)
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'connector_id': self.connector_id,
'lead_id': self.lead_id,
'from_phone': self.from_phone,
'to_phone': self.to_phone,
'direction': self.direction,
'body': self.body,
'status': self.status,
'error_code': self.error_code,
'error_message': self.error_message,
'provider_message_sid': self.provider_message_sid,
'keyword_flag': self.keyword_flag,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<SmsMessage {self.direction} {self.status} to {self.to_phone}>'
class Call(db.Model):
"""Outbound/inbound calls with disposition tracking."""
__tablename__ = 'calls'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
connector_id = db.Column(db.String(36), nullable=False, index=True)
lead_id = db.Column(db.String(36), db.ForeignKey('angi_leads.id', ondelete='SET NULL'), nullable=True, index=True)
triggered_sms_id = db.Column(db.String(36), db.ForeignKey('sms_messages.id', ondelete='SET NULL'), nullable=True) # If triggered by CALL ME
from_phone = db.Column(db.String(50), nullable=False)
to_phone = db.Column(db.String(50), nullable=False)
direction = db.Column(db.String(10), nullable=False) # 'outbound'
disposition = db.Column(db.String(20), default='') # connected, voicemail, no_answer, busy, appointment_set, unknown
duration_seconds = db.Column(db.Integer, nullable=True)
recording_url = db.Column(db.String(500), default='') # Provider recording URL
provider_call_sid = db.Column(db.String(128), default='')
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
started_at = db.Column(db.DateTime, nullable=True)
ended_at = db.Column(db.DateTime, nullable=True)
company = db.relationship('Company', back_populates='calls')
lead = db.relationship('AngiLead')
__table_args__ = (
db.CheckConstraint("direction IN ('outbound', 'inbound')", name='ck_call_direction'),
db.CheckConstraint("disposition IN ('', 'connected', 'voicemail', 'no_answer', 'busy', 'appointment_set', 'unknown')", name='ck_call_disposition'),
)
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'connector_id': self.connector_id,
'lead_id': self.lead_id,
'triggered_sms_id': self.triggered_sms_id,
'from_phone': self.from_phone,
'to_phone': self.to_phone,
'direction': self.direction,
'disposition': self.disposition,
'duration_seconds': self.duration_seconds,
'recording_url': self.recording_url,
'provider_call_sid': self.provider_call_sid,
'created_at': self.created_at.isoformat() if self.created_at else None,
'started_at': self.started_at.isoformat() if self.started_at else None,
'ended_at': self.ended_at.isoformat() if self.ended_at else None,
}
def __repr__(self):
return f'<Call {self.direction} {self.disposition} to {self.to_phone}>'
class LeadTouchpoint(db.Model):
"""Unified timeline entry β every SMS, call, email, or status change creates one."""
__tablename__ = 'lead_touchpoints'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
lead_id = db.Column(db.String(36), db.ForeignKey('angi_leads.id', ondelete='CASCADE'), nullable=True, index=True)
touchpoint_type = db.Column(db.String(20), nullable=False) # sms_sent, sms_received, sms_delivered, sms_failed, call_outbound, call_connected, call_voicemail, call_no_answer, email_sent, status_change, note_added
direction = db.Column(db.String(10), default='outbound') # outbound | inbound | system
content = db.Column(db.Text, default='') # Summary or message snippet
reference_id = db.Column(db.String(36), default='') # FK to SmsMessage.id, Call.id, etc.
reference_type = db.Column(db.String(20), default='') # sms_message, call, angi_lead_action
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', back_populates='lead_touchpoints')
lead = db.relationship('AngiLead')
__table_args__ = (
db.CheckConstraint("touchpoint_type IN ('sms_sent', 'sms_received', 'sms_delivered', 'sms_failed', 'call_outbound', 'call_connected', 'call_voicemail', 'call_no_answer', 'email_sent', 'status_change', 'note_added')", name='ck_touchpoint_type'),
db.CheckConstraint("direction IN ('outbound', 'inbound', 'system')", name='ck_touchpoint_direction'),
)
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'lead_id': self.lead_id,
'touchpoint_type': self.touchpoint_type,
'direction': self.direction,
'content': self.content,
'reference_id': self.reference_id,
'reference_type': self.reference_type,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self):
return f'<LeadTouchpoint {self.touchpoint_type} {self.direction}>'
class OptOut(db.Model):
"""Opt-out suppression list β TCPA compliance."""
__tablename__ = 'opt_outs'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
phone = db.Column(db.String(50), nullable=False, index=True)
reason = db.Column(db.String(50), default='stop') # stop, manual, bounced, complaint
source_message_id = db.Column(db.String(36), db.ForeignKey('sms_messages.id', ondelete='SET NULL'), nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
company = db.relationship('Company', back_populates='opt_outs')
__table_args__ = (
db.UniqueConstraint('company_id', 'phone', name='uq_optout_company_phone'),
db.CheckConstraint("reason IN ('stop', 'manual', 'bounced', 'complaint')", name='ck_optout_reason'),
)
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'phone': self.phone,
'reason': self.reason,
'source_message_id': self.source_message_id,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self):
return f'<OptOut {self.phone} ({self.reason})>'
class StripeWebhookEvent(db.Model):
"""Processed Stripe webhook event IDs β idempotency guard."""
__tablename__ = 'stripe_webhook_events'
id = db.Column(db.String(255), primary_key=True) # Stripe event.id (evt_...)
event_type = db.Column(db.String(100), default='')
processed_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
def __repr__(self):
return f'<StripeWebhookEvent {self.id} ({self.event_type})>'
class PartnerCommission(db.Model):
"""Commission agreement between a partner and a client company.
One row per partnerβcompany relationship with a revenue-share rate.
The revenue_source field indicates where the company's revenue data
comes from (self-reported, connector-synced, or forecast-based).
"""
__tablename__ = 'partner_commissions'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
partner_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False, index=True)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
commission_rate = db.Column(db.Float, nullable=False) # e.g. 0.15 for 15%
revenue_source = db.Column(db.String(20), default='self_reported') # self_reported, connector, forecast
effective_date = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
terminated_date = db.Column(db.DateTime, nullable=True)
status = db.Column(db.String(20), default='active') # active, terminated, paused
metadata_json = db.Column(db.JSON, default=dict)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.UniqueConstraint('partner_id', 'company_id', name='uq_partner_commissions_partner_company'),
db.CheckConstraint(
"revenue_source IN ('self_reported', 'connector', 'forecast')",
name='ck_partner_commissions_revenue_source',
),
db.CheckConstraint(
"status IN ('active', 'terminated', 'paused')",
name='ck_partner_commissions_status',
),
)
# Relationships
partner = db.relationship('User')
company = db.relationship('Company')
commission_records = db.relationship('CommissionRecord', back_populates='partner_commission', cascade='all, delete-orphan')
def __repr__(self):
return f'<PartnerCommission partner={self.partner_id} company={self.company_id} rate={self.commission_rate}>'
class CommissionRecord(db.Model):
"""Monthly commission calculation for a partnerβcompany agreement.
Revenue amount is sourced from whichever tracking method the company
uses β connector sync, self-reported KPI, or forecast estimate.
The revenue_confidence field tracks data quality.
"""
__tablename__ = 'commission_records'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
partner_commission_id = db.Column(db.String(36), db.ForeignKey('partner_commissions.id', ondelete='CASCADE'), nullable=False, index=True)
period = db.Column(db.String(7), nullable=False, index=True) # YYYY-MM format
period_start = db.Column(db.DateTime, nullable=False)
period_end = db.Column(db.DateTime, nullable=False)
revenue_amount = db.Column(db.Float, nullable=False, default=0.0)
revenue_confidence = db.Column(db.String(20), default='self_reported') # verified, self_reported, estimated
commission_rate = db.Column(db.Float, nullable=False) # Snapshot of rate at time of calculation
commission_amount = db.Column(db.Float, nullable=False, default=0.0)
status = db.Column(db.String(20), default='calculated') # calculated, approved, paid, disputed
payout_id = db.Column(db.String(36), db.ForeignKey('payouts.id', ondelete='SET NULL'), nullable=True, index=True)
notes = db.Column(db.Text, default='')
calculated_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.UniqueConstraint('partner_commission_id', 'period', name='uq_commission_records_partner_period'),
db.CheckConstraint(
"revenue_confidence IN ('verified', 'self_reported', 'estimated')",
name='ck_commission_records_revenue_confidence',
),
db.CheckConstraint(
"status IN ('calculated', 'approved', 'paid', 'disputed')",
name='ck_commission_records_status',
),
)
# Relationships
partner_commission = db.relationship('PartnerCommission', back_populates='commission_records')
payout = db.relationship('Payout')
def __repr__(self):
return f'<CommissionRecord {self.period} ${self.commission_amount:.2f}>'
class Payout(db.Model):
"""Actual payment to a partner, aggregating multiple commission records.
Payment method is provider-agnostic β Stripe Connect, bank transfer,
check, etc.
"""
__tablename__ = 'payouts'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
partner_id = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False, index=True)
amount = db.Column(db.Float, nullable=False, default=0.0)
currency = db.Column(db.String(3), default='USD')
status = db.Column(db.String(20), default='scheduled') # scheduled, processing, completed, failed
scheduled_date = db.Column(db.DateTime, nullable=False)
paid_date = db.Column(db.DateTime, nullable=True)
payment_method = db.Column(db.String(20), default='bank_transfer') # stripe_transfer, bank_transfer, check, crypto, other
transaction_id = db.Column(db.String(255), default='') # External reference (Stripe Transfer ID, etc.)
notes = db.Column(db.Text, default='')
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.CheckConstraint(
"status IN ('scheduled', 'processing', 'completed', 'failed')",
name='ck_payouts_status',
),
db.CheckConstraint(
"payment_method IN ('stripe_transfer', 'bank_transfer', 'check', 'crypto', 'other')",
name='ck_payouts_payment_method',
),
)
# Relationships
partner = db.relationship('User')
payout_records = db.relationship('PayoutRecord', back_populates='payout', cascade='all, delete-orphan')
def __repr__(self):
return f'<Payout partner={self.partner_id} ${self.amount:.2f} {self.status}>'
class PayoutRecord(db.Model):
"""Junction table linking commission records to payouts.
One commission record can only belong to one payout.
"""
__tablename__ = 'payout_records'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
payout_id = db.Column(db.String(36), db.ForeignKey('payouts.id', ondelete='CASCADE'), nullable=False, index=True)
commission_record_id = db.Column(db.String(36), db.ForeignKey('commission_records.id', ondelete='CASCADE'), nullable=False, index=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.UniqueConstraint('payout_id', 'commission_record_id', name='uq_payout_records_payout_commission'),
)
# Relationships
payout = db.relationship('Payout', back_populates='payout_records')
commission_record = db.relationship('CommissionRecord')
def __repr__(self):
return f'<PayoutRecord payout={self.payout_id} commission={self.commission_record_id}>'
# =============================================================================
# Jobber connector models β P2: remodeling sector gap plan
# =============================================================================
class JobberClient(db.Model):
"""Clients synced from Jobber."""
__tablename__ = 'jobber_clients'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
external_id = db.Column(db.String(128), nullable=False, index=True) # Jobber client ID
first_name = db.Column(db.String(255), default='')
last_name = db.Column(db.String(255), default='')
email = db.Column(db.String(255), default='')
phone = db.Column(db.String(50), default='')
company_name = db.Column(db.String(255), default='')
# Address
address_line1 = db.Column(db.String(500), default='')
address_line2 = db.Column(db.String(500), default='')
city = db.Column(db.String(100), default='')
state = db.Column(db.String(100), default='')
postal_code = db.Column(db.String(20), default='')
country = db.Column(db.String(100), default='US')
# Jobber-specific
jobber_timezone = db.Column(db.String(50), default='')
jobber_language = db.Column(db.String(10), default='en')
status = db.Column(db.String(20), default='active') # active, archived
properties_json = db.Column(db.JSON, default=dict)
# Timestamps
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
synced_at = db.Column(db.DateTime, nullable=True)
company = db.relationship('Company', backref='jobber_clients')
__table_args__ = (
db.UniqueConstraint('company_id', 'external_id', name='uq_jobber_client_company_external'),
)
@property
def full_name(self):
parts = [self.first_name, self.last_name]
return ' '.join(p for p in parts if p) or self.company_name
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'external_id': self.external_id,
'first_name': self.first_name,
'last_name': self.last_name,
'full_name': self.full_name,
'email': self.email,
'phone': self.phone,
'company_name': self.company_name,
'address_line1': self.address_line1,
'address_line2': self.address_line2,
'city': self.city,
'state': self.state,
'postal_code': self.postal_code,
'country': self.country,
'status': self.status,
'properties_json': self.properties_json,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'synced_at': self.synced_at.isoformat() if self.synced_at else None,
}
def __repr__(self):
return f'<JobberClient {self.full_name} ({self.external_id})>'
class JobberQuote(db.Model):
"""Quotes (estimates) synced from Jobber."""
__tablename__ = 'jobber_quotes'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
external_id = db.Column(db.String(128), nullable=False, index=True) # Jobber quote ID
client_id = db.Column(db.String(36), db.ForeignKey('jobber_clients.id', ondelete='SET NULL'), nullable=True, index=True)
# Quote details
name = db.Column(db.String(255), default='')
description = db.Column(db.Text, default='')
status = db.Column(db.String(50), default='draft') # draft, pending, accepted, rejected, converted
amount = db.Column(db.Float, nullable=True)
currency = db.Column(db.String(3), default='USD')
tax_amount = db.Column(db.Float, nullable=True)
discount_amount = db.Column(db.Float, nullable=True)
# Dates
sent_at = db.Column(db.DateTime, nullable=True)
accepted_at = db.Column(db.DateTime, nullable=True)
rejected_at = db.Column(db.DateTime, nullable=True)
expires_at = db.Column(db.DateTime, nullable=True)
# Linked to Estimate funnel
estimate_id = db.Column(db.String(36), db.ForeignKey('estimates.id', ondelete='SET NULL'), nullable=True)
# Raw data
line_items_json = db.Column(db.JSON, default=list)
properties_json = db.Column(db.JSON, default=dict)
# Timestamps
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
synced_at = db.Column(db.DateTime, nullable=True)
company = db.relationship('Company', backref='jobber_quotes')
client = db.relationship('JobberClient')
estimate = db.relationship('Estimate')
__table_args__ = (
db.UniqueConstraint('company_id', 'external_id', name='uq_jobber_quote_company_external'),
)
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'external_id': self.external_id,
'client_id': self.client_id,
'name': self.name,
'description': self.description,
'status': self.status,
'amount': self.amount,
'currency': self.currency,
'tax_amount': self.tax_amount,
'discount_amount': self.discount_amount,
'sent_at': self.sent_at.isoformat() if self.sent_at else None,
'accepted_at': self.accepted_at.isoformat() if self.accepted_at else None,
'rejected_at': self.rejected_at.isoformat() if self.rejected_at else None,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'estimate_id': self.estimate_id,
'line_items_json': self.line_items_json,
'properties_json': self.properties_json,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'synced_at': self.synced_at.isoformat() if self.synced_at else None,
}
def __repr__(self):
return f'<JobberQuote {self.name} ${self.amount} {self.status}>'
class JobberJob(db.Model):
"""Jobs (projects) synced from Jobber."""
__tablename__ = 'jobber_jobs'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
external_id = db.Column(db.String(128), nullable=False, index=True) # Jobber job ID
client_id = db.Column(db.String(36), db.ForeignKey('jobber_clients.id', ondelete='SET NULL'), nullable=True, index=True)
# Job details
name = db.Column(db.String(255), default='')
description = db.Column(db.Text, default='')
status = db.Column(db.String(50), default='draft') # draft, scheduled, in_progress, completed, cancelled
project_type = db.Column(db.String(100), default='')
# Financials
estimated_revenue = db.Column(db.Float, nullable=True)
actual_revenue = db.Column(db.Float, nullable=True)
estimated_cost = db.Column(db.Float, nullable=True)
actual_cost = db.Column(db.Float, nullable=True)
# Schedule
start_date = db.Column(db.DateTime, nullable=True)
end_date = db.Column(db.DateTime, nullable=True)
estimated_end_date = db.Column(db.DateTime, nullable=True)
# Address
address_line1 = db.Column(db.String(500), default='')
address_line2 = db.Column(db.String(500), default='')
city = db.Column(db.String(100), default='')
state = db.Column(db.String(100), default='')
postal_code = db.Column(db.String(20), default='')
# Linked to Estimate funnel
estimate_id = db.Column(db.String(36), db.ForeignKey('estimates.id', ondelete='SET NULL'), nullable=True)
quote_id = db.Column(db.String(36), db.ForeignKey('jobber_quotes.id', ondelete='SET NULL'), nullable=True)
# Raw data
properties_json = db.Column(db.JSON, default=dict)
# Timestamps
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
synced_at = db.Column(db.DateTime, nullable=True)
company = db.relationship('Company', backref='jobber_jobs')
client = db.relationship('JobberClient')
estimate = db.relationship('Estimate')
quote = db.relationship('JobberQuote')
__table_args__ = (
db.UniqueConstraint('company_id', 'external_id', name='uq_jobber_job_company_external'),
)
@property
def margin(self) -> Optional[float]:
if self.actual_revenue and self.actual_cost and self.actual_revenue > 0:
return ((self.actual_revenue - self.actual_cost) / self.actual_revenue) * 100
if self.estimated_revenue and self.estimated_cost and self.estimated_revenue > 0:
return ((self.estimated_revenue - self.estimated_cost) / self.estimated_revenue) * 100
return None
@property
def schedule_variance_days(self) -> Optional[int]:
"""Days between estimated end and actual end. Positive = late."""
if self.estimated_end_date and self.end_date:
return (self.end_date - self.estimated_end_date).days
return None
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'external_id': self.external_id,
'client_id': self.client_id,
'name': self.name,
'description': self.description,
'status': self.status,
'project_type': self.project_type,
'estimated_revenue': self.estimated_revenue,
'actual_revenue': self.actual_revenue,
'estimated_cost': self.estimated_cost,
'actual_cost': self.actual_cost,
'margin': self.margin,
'start_date': self.start_date.isoformat() if self.start_date else None,
'end_date': self.end_date.isoformat() if self.end_date else None,
'estimated_end_date': self.estimated_end_date.isoformat() if self.estimated_end_date else None,
'schedule_variance_days': self.schedule_variance_days,
'address_line1': self.address_line1,
'address_line2': self.address_line2,
'city': self.city,
'state': self.state,
'postal_code': self.postal_code,
'estimate_id': self.estimate_id,
'quote_id': self.quote_id,
'properties_json': self.properties_json,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'synced_at': self.synced_at.isoformat() if self.synced_at else None,
}
def __repr__(self):
return f'<JobberJob {self.name} {self.status}>'
class JobberInvoice(db.Model):
"""Invoices synced from Jobber."""
__tablename__ = 'jobber_invoices'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
external_id = db.Column(db.String(128), nullable=False, index=True) # Jobber invoice ID
client_id = db.Column(db.String(36), db.ForeignKey('jobber_clients.id', ondelete='SET NULL'), nullable=True, index=True)
job_id = db.Column(db.String(36), db.ForeignKey('jobber_jobs.id', ondelete='SET NULL'), nullable=True, index=True)
# Invoice details
invoice_number = db.Column(db.String(100), default='')
status = db.Column(db.String(50), default='draft') # draft, sent, paid, overdue, cancelled
amount = db.Column(db.Float, nullable=True)
currency = db.Column(db.String(3), default='USD')
tax_amount = db.Column(db.Float, nullable=True)
paid_amount = db.Column(db.Float, nullable=True, default=0.0)
# Dates
issued_at = db.Column(db.DateTime, nullable=True)
due_date = db.Column(db.DateTime, nullable=True)
paid_at = db.Column(db.DateTime, nullable=True)
# Raw data
line_items_json = db.Column(db.JSON, default=list)
properties_json = db.Column(db.JSON, default=dict)
# Timestamps
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
synced_at = db.Column(db.DateTime, nullable=True)
company = db.relationship('Company', backref='jobber_invoices')
client = db.relationship('JobberClient')
job = db.relationship('JobberJob')
__table_args__ = (
db.UniqueConstraint('company_id', 'external_id', name='uq_jobber_invoice_company_external'),
)
@property
def balance(self) -> float:
return (self.amount or 0) - (self.paid_amount or 0)
@property
def is_overdue(self) -> bool:
if self.due_date and self.status not in ('paid', 'cancelled'):
from datetime import datetime, timezone
return datetime.now(timezone.utc) > self.due_date
return False
def to_dict(self):
return {
'id': self.id,
'company_id': self.company_id,
'external_id': self.external_id,
'client_id': self.client_id,
'job_id': self.job_id,
'invoice_number': self.invoice_number,
'status': self.status,
'amount': self.amount,
'currency': self.currency,
'tax_amount': self.tax_amount,
'paid_amount': self.paid_amount,
'balance': self.balance,
'is_overdue': self.is_overdue,
'issued_at': self.issued_at.isoformat() if self.issued_at else None,
'due_date': self.due_date.isoformat() if self.due_date else None,
'paid_at': self.paid_at.isoformat() if self.paid_at else None,
'line_items_json': self.line_items_json,
'properties_json': self.properties_json,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'synced_at': self.synced_at.isoformat() if self.synced_at else None,
}
def __repr__(self):
return f'<JobberInvoice {self.invoice_number} ${self.amount} {self.status}>'
# =============================================================================
# Estimate models β estimate-to-close funnel tracking for remodeling sector
# =============================================================================
class Estimate(db.Model):
"""Estimate/quote records with funnel stage tracking.
Tracks an estimate from draft through delivery, acceptance/rejection,
scheduling, and final close. Supports multi-source origins (Angi leads,
ServiceTitan, Jobber, manual entry).
Funnel stages: draft β delivered β accepted | rejected | expired
Accepted estimates progress: accepted β scheduled β closed
"""
__tablename__ = 'estimates'
id = db.Column(db.String(36), primary_key=True, default=gen_uuid)
company_id = db.Column(db.String(36), db.ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
# Human-readable reference number (auto-generated if not provided)
estimate_number = db.Column(db.String(50), nullable=False, index=True)
# Linkage to existing entities (all nullable β estimates can be standalone)
project_id = db.Column(db.String(36), db.ForeignKey('projects.id', ondelete='SET NULL'), nullable=True, index=True)
customer_id = db.Column(db.String(36), db.ForeignKey('quickbooks_customers.id', ondelete='SET NULL'), nullable=True, index=True)
source_lead_id = db.Column(db.String(36), db.ForeignKey('angi_leads.id', ondelete='SET NULL'), nullable=True, index=True)
# Estimate details
customer_name = db.Column(db.String(255), default='')
customer_email = db.Column(db.String(255), default='')
customer_phone = db.Column(db.String(50), default='')
project_type = db.Column(db.String(100), default='') # roofing, hvac, kitchen, bath, etc.
description = db.Column(db.Text, default='')
address = db.Column(db.String(500), default='')
city = db.Column(db.String(100), default='')
state = db.Column(db.String(50), default='')
zip_code = db.Column(db.String(20), default='')
# Financials
total_value = db.Column(db.Float, nullable=False) # Total estimate amount
estimated_cost = db.Column(db.Float, nullable=True) # Estimated cost to complete
estimated_margin = db.Column(db.Float, nullable=True) # Pre-computed margin %
deposit = db.Column(db.Float, default=0.0)
tax_amount = db.Column(db.Float, default=0.0)
discount_amount = db.Column(db.Float, default=0.0)
line_items_json = db.Column(db.JSON, default=list) # [{description, quantity, unit_price, total}]
# Funnel tracking
stage = db.Column(db.String(50), nullable=False, default='draft', index=True)
# Stage transition audit trail: [{stage, timestamp, notes}]
stage_history = db.Column(db.JSON, default=list)
# Timeline
delivered_at = db.Column(db.DateTime, nullable=True)
accepted_at = db.Column(db.DateTime, nullable=True)
rejected_at = db.Column(db.DateTime, nullable=True)
scheduled_date = db.Column(db.DateTime, nullable=True)
closed_at = db.Column(db.DateTime, nullable=True)
expires_at = db.Column(db.DateTime, nullable=True)
# Outcome details
rejection_reason = db.Column(db.String(200), default='') # price, competitor, timing, etc.
competitor_name = db.Column(db.String(255), default='')
competitor_price = db.Column(db.Float, nullable=True)
notes = db.Column(db.Text, default='')
# Assignment
assigned_to = db.Column(db.String(36), db.ForeignKey('profiles.id', ondelete='SET NULL'), nullable=True)
# Source metadata
source = db.Column(db.String(50), default='manual') # manual, angi, servicetitan, jobber, hubspot, email
source_estimate_id = db.Column(db.String(128), default='') # External system estimate ID
# Timestamps
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
company = db.relationship('Company', backref=db.backref('estimates', cascade='all, delete-orphan'))
project = db.relationship('Project')
customer = db.relationship('QuickbooksCustomer')
source_lead = db.relationship('AngiLead')
__table_args__ = (
db.UniqueConstraint('company_id', 'estimate_number', name='uq_estimate_company_number'),
db.CheckConstraint(
"stage IN ('draft', 'delivered', 'accepted', 'rejected', 'expired', 'scheduled', 'closed')",
name='ck_estimates_stage',
),
db.CheckConstraint(
"source IN ('manual', 'angi', 'servicetitan', 'jobber', 'hubspot', 'email', 'referral', 'website')",
name='ck_estimates_source',
),
)
@staticmethod
def _parse_history(raw):
"""Defensively parse stage_history from SQLite JSON column."""
if isinstance(raw, str):
import json
try:
raw = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return []
if isinstance(raw, list):
return raw
return []
@property
def current_stage_timestamp(self):
"""Timestamp when the estimate entered its current stage (as datetime)."""
history = self._parse_history(self.stage_history)
if history:
ts = history[-1].get('timestamp')
if isinstance(ts, str):
try:
return datetime.fromisoformat(ts)
except (ValueError, TypeError):
return None
if isinstance(ts, datetime):
return ts
return None
@property
def days_in_stage(self):
"""Number of days in the current stage."""
ts = self.current_stage_timestamp
if ts and isinstance(ts, datetime):
now = datetime.now(timezone.utc)
if ts.tzinfo is not None:
ts = ts.replace(tzinfo=None)
if now.tzinfo is not None:
now = now.replace(tzinfo=None)
return (now - ts).days
return 0
@property
def time_to_accept_days(self):
"""Days from delivered to accepted (or None if not applicable)."""
if self.delivered_at and self.accepted_at:
return (self.accepted_at - self.delivered_at).days
return None
@property
def is_active(self):
"""Whether the estimate is in an active (non-terminal) stage."""
return self.stage in ('draft', 'delivered', 'accepted', 'scheduled')
@property
def is_closed(self):
"""Whether the estimate is in a terminal stage."""
return self.stage in ('rejected', 'expired', 'closed')
def transition_to(self, new_stage, notes=''):
"""Transition the estimate to a new stage with audit trail.
Returns True on success, False if the transition is invalid.
"""
valid_transitions = {
'draft': ['delivered'],
'delivered': ['accepted', 'rejected', 'expired'],
'accepted': ['scheduled', 'rejected'],
'scheduled': ['closed', 'rejected'],
'rejected': [],
'expired': [],
'closed': [],
}
if new_stage not in valid_transitions.get(self.stage, []):
return False
now = datetime.now(timezone.utc)
# Record transition
transition_record = {
'from_stage': self.stage,
'to_stage': new_stage,
'timestamp': now.isoformat(),
'notes': notes,
}
# Ensure stage_history is a list (SQLite may return JSON as string)
history = self._parse_history(self.stage_history)
history.append(transition_record)
self.stage_history = history
# Update stage and stage-specific timestamps
old_stage = self.stage
self.stage = new_stage
if new_stage == 'delivered' and not self.delivered_at:
self.delivered_at = now
elif new_stage == 'accepted' and not self.accepted_at:
self.accepted_at = now
elif new_stage == 'rejected':
self.rejected_at = now
elif new_stage == 'expired':
# Set expired_at via updated_at since we don't have a dedicated field
pass
elif new_stage == 'closed' and not self.closed_at:
self.closed_at = now
return True
def to_dict(self):
history = self._parse_history(self.stage_history)
return {
'id': self.id,
'company_id': self.company_id,
'estimate_number': self.estimate_number,
'project_id': self.project_id,
'customer_id': self.customer_id,
'source_lead_id': self.source_lead_id,
'customer_name': self.customer_name,
'customer_email': self.customer_email,
'customer_phone': self.customer_phone,
'project_type': self.project_type,
'description': self.description,
'address': self.address,
'city': self.city,
'state': self.state,
'zip_code': self.zip_code,
'total_value': self.total_value,
'deposit': self.deposit,
'tax_amount': self.tax_amount,
'discount_amount': self.discount_amount,
'line_items': self.line_items_json,
'stage': self.stage,
'stage_history': history,
'delivered_at': self.delivered_at.isoformat() if self.delivered_at else None,
'accepted_at': self.accepted_at.isoformat() if self.accepted_at else None,
'rejected_at': self.rejected_at.isoformat() if self.rejected_at else None,
'scheduled_date': self.scheduled_date.isoformat() if self.scheduled_date else None,
'closed_at': self.closed_at.isoformat() if self.closed_at else None,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'rejection_reason': self.rejection_reason,
'competitor_name': self.competitor_name,
'competitor_price': self.competitor_price,
'notes': self.notes,
'assigned_to': self.assigned_to,
'source': self.source,
'source_estimate_id': self.source_estimate_id,
'days_in_stage': self.days_in_stage,
'time_to_accept_days': self.time_to_accept_days,
'is_active': self.is_active,
'is_closed': self.is_closed,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<Estimate {self.estimate_number} ${self.total_value} {self.stage}>'