TECHNICAL SPECIFICATION v1.0

UnitCycle AI Platform
Developer Handoff Document

Complete engineering specification for 19 AI-powered features across 5 phases. Backend models, API contracts, Celery tasks, Angular components, algorithms, and wireframes.

Stack Django 5 + Angular 19 + Claude API
Database PostgreSQL 16 (4,353 leases)
Codebase unitcycle-demo/
Date 2026-03-27
Features 19 across 5 phases
12
Features Specified
38
New Django Models
67
API Endpoints
14
Celery Tasks
42
Angular Components
4,353
Existing Leases
Feature Completion 0%
Phase 1
0 / 0
Phase 2
0 / 0
Phase 3
0 / 0
Phase 4
0 / 0

CRITICAL: Modify Existing Screens vs New Screens

Many features integrate INTO existing UI. Do NOT rebuild these. Read this section before touching any code.

MODIFY EXISTING FILES (do NOT create new pages for these)

1.1 Lifecycle Badges View live screen →

The problem: Right now, every unit with a tenant shows the same green "Occupied" badge regardless of whether their lease is active, expired, or they've given notice to leave. A property manager looking at a unit has no idea if the lease ended 2 months ago or if the tenant is moving out next week — they all look the same. This change replaces the simple Occupied/Vacant badge with a lifecycle-aware status that tells the manager exactly where this tenancy stands and what action to take: Active (everything fine), Month-to-Month (lease expired, tenant stayed — opportunity to renew or increase rent), Notice (tenant leaving — start turnover planning), or Vacant (no tenant — needs make-ready and listing).

File: src/app/features/property-management/unit-detail/unit-detail.component.html (~800 lines) • Section: Header area, line ~65. Status badge next to unit number.
What exists now:
Shows "Occupied" / "Vacant" pill badge based on unit status field.
What it should become:
Show lifecycle-aware badge: Active (teal), Month-to-Month (amber), Notice (red), Vacant (gray). Badge comes from lease_status field on the lease, not the unit status.
1.1 Lease Progress Bar States View live screen →

The problem: The progress bar currently only works for active leases — it shows percentage elapsed from start to end date. But for month-to-month tenants there's no end date (so it shows 100% forever), for tenants who gave notice the countdown should be to their move-out date not lease end, and for vacant units a progress bar makes no sense at all. This change makes the progress bar context-aware: it behaves differently depending on which lifecycle state the unit is in, giving the property manager the right visual at a glance.

File: src/app/features/property-management/unit-detail/unit-detail.component.ts (~1100 lines) • Section: getLeaseProgress() method at line ~715, getLeaseTimeRemaining() at line ~735
What exists now:
Linear progress bar based on lease_begin_date to lease_end_date. Returns "Expired" if past end date. Colors: teal (>90d), amber (30-90d), orange (<30d).
What it should become:
Four modes: (1) Active: current countdown bar (no change). (2) Month-to-Month: rolling/infinite bar with "MTM since {holdover_start_date}" text, amber pulsing color. Shows days on holdover, not percentage. (3) Notice: countdown to notice_move_out_date with red bar. (4) Vacant: hide progress bar entirely, show "Vacant since {actual_move_out_date}".
1.1 Honeycomb Hex Colors View live screen →

The problem: The property overview honeycomb grid currently shows all occupied units as the same green color. A property manager can't visually distinguish between a healthy active lease and a unit where the lease expired 3 months ago. This change color-codes each hex by lifecycle state, so the manager can instantly see the health distribution at a glance — a sea of green with a few amber/red dots tells a very different story than a grid with 30% amber.

File: src/app/features/property-management/property-detail/property-detail.component.ts (~2600 lines) • Section: getUnitStatusClassWithInsurance(unit: UnitGridItem) at line ~2629, UnitGridItem interface at line ~56
What exists now:
Hex color is based on unit.status: occupied = green, vacant = gray, maintenance = orange. Insurance overlay adds yellow ring.
What it should become:
Add leaseStatus to UnitGridItem interface. Hex colors: Active = teal (#0D9488), MTM = amber (#D97706), Notice = red (#DC2626), Vacant = gray (#94A3B8). Populated via existing unit grid data loader at line ~2462.
1.1 Honeycomb Tooltip View live screen →

The problem: When hovering a hex unit, the tooltip shows generic info (tenant, rent, lease dates). It doesn't tell the manager what state the lease is in or flag any urgency. This change adds the lifecycle badge and a lease progress bar to the tooltip, plus adapts the lease date display per state — so a manager can hover over units and quickly spot which ones need attention without clicking into each one.

File: src/app/features/property-management/property-detail/property-detail.component.html (~3000 lines) • Section: Tooltip popup. Uses hoveredUnit signal and getTooltipLeaseProgress(unit).
What exists now:
Shows: unit number, tenant name, monthly rent, lease dates, lease progress bar, outstanding balance.
What it should become:
Add lifecycle badge. MTM: show "Month-to-Month since {date}" instead of lease end. Notice: show "Moving out {date}" in red. Add churn risk dot (green/amber/red) from feature 1.2 if available.
1.2 Churn Risk Badge View live screen →

The problem: Property managers have no way to know which tenants are likely to leave when their lease expires. They find out when the tenant gives notice — by then it's too late to offer a renewal or competitive rent. This change adds an AI-powered churn prediction score directly in the unit header. The manager can see at a glance "this tenant has 78% churn risk" and proactively reach out with a renewal offer before they decide to leave. This is the foundation for the Renewal Intelligence Agent.

File: src/app/features/property-management/unit-detail/unit-detail.component.htmlSection: Header stat row (line ~80-120), after Balance cell.
What exists now:
4 stat cells: Monthly Rent | Tenant | Lease Progress | Balance
What it should become:
Add 5th stat cell "Churn Risk" with score gauge (0-100) and risk badge (Low/Medium/High). Only shown for active/MTM leases with a RenewalRecommendation. Data from /api/v1/renewals/?lease={lease_id}.
1.3 Delinquent Indicator View live screen →

The problem: When a tenant has an overdue balance, the unit detail just shows a red number. There's no indication of whether a collections process has started, what step it's at, or if it needs manager attention. This change adds a small "Collections" badge next to the balance amount that shows the escalation level (1-5 dots) and links directly to the active collections case. The manager can see "this tenant is $2,450 overdue and we're at Step 4 (payment plan offered)" without leaving the unit detail page.

File: src/app/features/property-management/unit-detail/unit-detail.component.htmlSection: Balance stat cell (line ~110-119)
What exists now:
Shows balance amount in red if > 0, green if 0.
What it should become:
When balance > 0 AND CollectionCase exists: add "Collections" badge with escalation dots (1-5) and link to case. Data from /api/v1/collections/?tenant={tenant_id}&status=open.
2.2 Market Rent Comparison View live screen →

The problem: Property managers don't know if a unit's rent is above or below market rate. They might be leaving $75/month on the table for 200 units ($15,000/month in lost revenue) and have no visibility into it. This change adds a "Market Intelligence" card to the unit detail Overview tab that shows current rent vs. estimated market rent, the gap in dollars and percentage, and a 30-day rent trend. This gives the manager instant context when deciding renewal pricing or listing a vacant unit.

File: src/app/features/property-management/unit-detail/unit-detail.component.htmlSection: Overview tab, below unit details section.
What exists now:
Overview tab shows unit info cards, image gallery, and basic stats.
What it should become:
Add "Market Intelligence" card: current rent, market rent (from PricingSnapshot), gap %, 30-day sparkline. Data from /api/v1/pricing/units/{unit_id}/history/.
2.3 Maintenance Risk Assessment View live screen →

The problem: Maintenance is entirely reactive — something breaks, tenant calls, we fix it. There's no way to anticipate failures. A 12-year-old water heater in a building where 3 similar units already had failures is a ticking time bomb, but nobody can see that risk. This change adds an AI risk assessment card at the top of the Maintenance tab. It shows a risk score based on equipment age, maintenance history, seasonal patterns, and similar-unit failures. Managers can create preventive work orders before things break, reducing emergency calls and tenant frustration.

File: src/app/features/property-management/unit-detail/unit-detail.component.htmlSection: Maintenance tab, header area
What exists now:
Maintenance tab shows list of work orders with status filters.
What it should become:
Add "AI Risk Assessment" card at top: risk score gauge (0-100), risk badge, predicted next issue, "Create Preventive WO" button. Data from /api/v1/predictive-maintenance/risk-scores/?unit={unit_id}.
1.1 Property Lifecycle Summary View live screen →

The problem: The property header shows simple "Occupied/Vacant" counts, which hides the real story. A property with "94% occupied" sounds great, but if 72 of those units are on expired month-to-month leases and 28 have given notice, the actual situation is quite different. This change replaces the simple occupancy stats with a lifecycle breakdown bar showing exactly how many units are in each state, plus an "Expiring in 30d" warning badge so the manager can see upcoming risk at a glance.

File: src/app/features/property-management/property-detail/property-detail.component.htmlSection: Property header (occupancy stats bar)
What exists now:
Shows: Total Units, Occupied, Vacant, Occupancy Rate
What it should become:
Stacked bar: Active, MTM, Notice, Vacant counts. Keep occupancy rate. Add "Expiring in 30d: {N}" warning badge. Data from /api/v1/tenants/leases/lifecycle-stats/?property={property_id}.
1.2 Dashboard Renewals Widget View live screen →

The problem: When a manager opens the dashboard, there's no visibility into upcoming lease renewals. They have to manually check each property to find expiring leases. This change adds a "Renewals Due" stat card to the main dashboard showing the count of pending renewal recommendations, acceptance rate, and a direct link to the renewals page. It ensures renewal management is always visible as part of the daily workflow.

File: src/app/features/dashboard/dashboard.component.htmlSection: Dashboard cards area (add new card)
What exists now:
Dashboard has multiple stat cards and charts.
What it should become:
Add "Renewals Due" stat card: count of pending renewals, acceptance rate trend, click-through to /renewals page.

NEW SCREENS (create from scratch)

FeatureRouteSidebar Nav LocationComponent File to Create
1.1 Lifecycle Dashboard /lifecycle Operations → Lease Lifecycle src/app/features/lifecycle/lifecycle-dashboard.component.ts
1.2 Renewals Dashboard /renewals AI → Renewals src/app/features/renewals/renewal-dashboard.component.ts
1.2 Renewal Detail /renewals/:id (from renewals list) src/app/features/renewals/renewal-detail.component.ts
1.3 Collections Dashboard /collections AI → Collections src/app/features/collections/collections-dashboard.component.ts
1.3 Collection Case Detail /collections/:caseId (from collections list) src/app/features/collections/collection-detail.component.ts
2.1 Turnover Pipeline (Kanban) /turnover Operations → Turnovers src/app/features/turnover/turnover-pipeline.component.ts
2.1 Turnover Case Detail /turnover/:caseId (from pipeline) src/app/features/turnover/turnover-detail.component.ts
2.2 Revenue Intelligence Dashboard /pricing AI → Revenue src/app/features/pricing/pricing-dashboard.component.ts
2.3 Predictive Maintenance Dashboard /predictive-maintenance (existing route, new heatmap view) AI → Maintenance AI (existing sidebar item) Extend existing src/app/features/predictive-maintenance/predictive-maintenance-list.component.ts with risk heatmap tab
3.1 Move-Out Photo Inspection /inspections/:id (accessed from turnover case detail) src/app/features/inspections/inspection-review.component.ts
3.2 Invoice Upload & AI Review /invoices/processing/:id (existing route) (existing sidebar: Invoices) Extend existing src/app/features/invoices/invoice-processing/invoice-processing.component.ts with AI extraction side-panel
3.3 Portfolio Chat /chat AI → Portfolio Chat src/app/features/chat/portfolio-chat.component.ts
4.1 Virtual Staging /staging/:projectId (accessed from turnover or unit detail) src/app/features/staging/staging-project.component.ts
4.2 Application Review /screening/:id Operations → Screening src/app/features/screening/application-review.component.ts
4.3 Lease Extraction Review /abstractions/:id AI → Lease AI src/app/features/abstractions/abstraction-review.component.ts

Key Existing Files Reference

FileLinesWhat It Contains
src/app/features/property-management/unit-detail/unit-detail.component.html~800Unit detail page: header with status badge, stat row (rent/tenant/lease progress/balance), 5 tabs (Overview, Tenant, Financials, Maintenance, Compliance)
src/app/features/property-management/unit-detail/unit-detail.component.ts~1100Lifecycle methods: getLeaseProgress(), getLeaseTimeRemaining(), currentLease computed, signals for unit/charges/maintenance/leases
src/app/features/property-management/property-detail/property-detail.component.html~3000Property detail: header stats, honeycomb grid with hex units, tooltip with tenant/rent/lease info, building views
src/app/features/property-management/property-detail/property-detail.component.ts~2600UnitGridItem interface (line 56), buildingGridData loader (line 2462), honeycomb hover/tooltip logic (line 2558+), getUnitStatusClassWithInsurance (line 2629)
src/app/core/services/property.service.ts~300API service: getProperties, getUnit, getUnitCharges, getLeasesByUnit, getMaintenanceRequestsByUnit
django_api/tenants/models.py~289Tenants, TenantLease (PK: UUID, FKs: tenant/unit/property, fields: lease_begin_date, lease_end_date, monthly_rent_amount, status)
django_api/units/models.py~333UnitsUnit (PK: UUID, fields: unit_number, bedrooms, bathrooms, square_feet, market_rent, current_rent, status), UnitsMaintenance
django_api/invoices/models.py~189Invoices (with confidence_score, vendor/property/unit matching fields, line_items), InvoiceLineItems, InvoiceAttachments
django_api/predictive_maintenance/models.py~131Equipment, MaintenancePrediction (existing app to extend)
Phase 1 — Foundation
1.1 Lifecycle State Engine
Backend Frontend Celery
Why this feature matters:
Today, our system treats every occupied unit the same — it's either "occupied" or "vacant", end of story. But in reality, 1,146 out of 4,353 leases have already expired, and the system still calls them "active." A tenant whose lease ended 3 months ago looks identical to one with 8 months remaining. Property managers are flying blind — they can't tell which tenants are on expired holdover leases, which ones have given notice to leave, or which units are about to turn over. This feature introduces four distinct lifecycle states that reflect the real-world tenancy lifecycle: Active (lease is current and everything is fine), Month-to-Month (lease expired but tenant stayed — an opportunity to renew at market rate), Notice (tenant or landlord gave notice, move-out is scheduled — start the turnover process), and Vacant (no tenant, needs make-ready and re-listing). Every screen in the platform — the unit detail header, the honeycomb grid, the tooltips, the property summary — will reflect these states with different colors, badges, progress bars, and action buttons. The manager opens a property and instantly sees: 324 active, 72 month-to-month, 28 on notice, 14 vacant. No more guessing.

Classify every lease in the system into one of four lifecycle states: active, month_to_month, notice, or vacant. Add a lease_status field to TenantLease. A daily Celery task auto-transitions expired leases. The frontend shows status badges, progress bars, and action buttons per state.

There are currently 1,146 leases where lease_end_date < today and status = 'active'. The initial migration must reclassify these into month_to_month.
MODIFY EXISTING: This feature modifies 4 existing files. See the "Modify Existing vs New Screens" section above for exact file paths and line numbers. The unit detail header badge, lease progress bar, honeycomb hex colors, and honeycomb tooltip all need lifecycle-aware updates. Do NOT create a separate "lifecycle badge" page.

B Django Models

Extend the existing TenantLease model (table: tenant_lease). Add new fields via migration (not a new table).

# tenants/migrations/XXXX_add_lifecycle_fields.py
# Adds columns to existing tenant_lease table

from django.db import migrations, models

class Migration(migrations.Migration):
    dependencies = [('tenants', '0001_initial')]

    operations = [
        migrations.AddField(
            model_name='tenantlease',
            name='lease_status',
            field=models.CharField(
                max_length=20,
                default='active',
                choices=[
                    ('active', 'Active'),
                    ('month_to_month', 'Month-to-Month'),
                    ('notice', 'Notice Given'),
                    ('vacant', 'Vacant'),
                ],
                db_index=True,
            ),
        ),
        migrations.AddField(
            model_name='tenantlease',
            name='lease_status_changed_at',
            field=models.DateTimeField(null=True, blank=True),
        ),
        migrations.AddField(
            model_name='tenantlease',
            name='lease_status_changed_by',
            field=models.CharField(max_length=50, default='system'),
        ),
        migrations.AddField(
            model_name='tenantlease',
            name='holdover_start_date',
            field=models.DateField(null=True, blank=True),
        ),
        migrations.AddField(
            model_name='tenantlease',
            name='notice_date',
            field=models.DateField(null=True, blank=True),
        ),
        migrations.AddField(
            model_name='tenantlease',
            name='notice_move_out_date',
            field=models.DateField(null=True, blank=True),
        ),
        migrations.AddField(
            model_name='tenantlease',
            name='actual_move_out_date',
            field=models.DateField(null=True, blank=True),
        ),
    ]

Also create a new LeaseStatusLog model to track all transitions:

# New model in tenants/models.py

class LeaseStatusLog(models.Model):
    """Audit log for lease status transitions."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    lease = models.ForeignKey(
        TenantLease, on_delete=models.CASCADE,
        related_name='status_logs'
    )
    previous_status = models.CharField(max_length=20)
    new_status = models.CharField(max_length=20)
    reason = models.CharField(max_length=200, default='')
    changed_by = models.CharField(max_length=100, default='system')
    metadata = models.JSONField(default=dict, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'lease_status_log'
        ordering = ['-created_at']
        indexes = [
            models.Index(fields=['lease', '-created_at']),
        ]

Field Reference — New Columns on tenant_lease

FieldTypeDefaultPurpose
lease_statusCharField(20)'active'Current lifecycle state. Indexed.
lease_status_changed_atDateTimeFieldnullWhen the status last changed.
lease_status_changed_byCharField(50)'system'Who/what triggered the change.
holdover_start_dateDateFieldnullDate lease became month-to-month.
notice_dateDateFieldnullDate notice was given.
notice_move_out_dateDateFieldnullExpected move-out per notice.
actual_move_out_dateDateFieldnullActual move-out (set on vacancy).

B API Endpoints

MethodPathDescription
GET /api/v1/tenants/leases/ List leases. Filterable by lease_status, property, lease_end_date__lte, lease_end_date__gte.
GET /api/v1/tenants/leases/{id}/ Get single lease with all lifecycle fields.
POST /api/v1/tenants/leases/{id}/convert-mtm/ Convert active lease to month-to-month.
POST /api/v1/tenants/leases/{id}/give-notice/ Record notice given. Sets notice_date and notice_move_out_date.
POST /api/v1/tenants/leases/{id}/mark-vacant/ Mark lease as vacant after move-out.
GET /api/v1/tenants/leases/lifecycle-stats/ Aggregate counts per status.
GET /api/v1/tenants/leases/{id}/status-history/ Status transition log for a lease.

POST /api/v1/tenants/leases/{id}/convert-mtm/

// Request: (no body needed, or optional)
{}

// Response 200:
{
  "id": "uuid",
  "lease_status": "month_to_month",
  "holdover_start_date": "2026-03-28",
  "lease_status_changed_at": "2026-03-28T12:00:00Z",
  "lease_status_changed_by": "user:admin@unitcycle.com"
}

// Response 400:
{ "detail": "Lease is already month-to-month." }

// Response 404:
{ "detail": "Lease not found." }

POST /api/v1/tenants/leases/{id}/give-notice/

// Request:
{
  "notice_date": "2026-03-15",
  "notice_move_out_date": "2026-04-15",
  "reason": "Tenant relocating for work"
}

// Response 200:
{
  "id": "uuid",
  "lease_status": "notice",
  "notice_date": "2026-03-15",
  "notice_move_out_date": "2026-04-15",
  "days_until_move_out": 18
}

GET /api/v1/tenants/leases/lifecycle-stats/

// Response 200:
{
  "total_leases": 4353,
  "active": 2841,
  "month_to_month": 1146,
  "notice": 87,
  "vacant": 279,
  "expiring_30_days": 142,
  "expiring_60_days": 298,
  "expiring_90_days": 451
}

C Celery Tasks

Task: tenants.tasks.classify_lease_statuses

Schedule: Daily at 02:00 AM UTC

Algorithm:

  1. Query all leases where lease_status = 'active' AND lease_end_date < today().
  2. For each: set lease_status = 'month_to_month', holdover_start_date = lease_end_date + 1 day.
  3. Query all leases where lease_status = 'notice' AND notice_move_out_date < today() AND no actual_move_out_date.
  4. For each: set lease_status = 'vacant', actual_move_out_date = notice_move_out_date.
  5. Update associated UnitsUnit.status to 'vacant' for all newly vacated leases.
  6. Create LeaseStatusLog entries for every transition.
  7. Log summary: "Transitioned X to MTM, Y to vacant."
# tenants/tasks.py
from celery import shared_task
from django.utils import timezone

@shared_task(name='tenants.classify_lease_statuses')
def classify_lease_statuses():
    from tenants.models import TenantLease, LeaseStatusLog
    from units.models import UnitsUnit

    today = timezone.now().date()
    mtm_count = 0
    vacant_count = 0

    # Active -> Month-to-Month
    expired_active = TenantLease.objects.filter(
        lease_status='active',
        lease_end_date__lt=today
    )
    for lease in expired_active.iterator(chunk_size=500):
        lease.lease_status = 'month_to_month'
        lease.holdover_start_date = lease.lease_end_date + timedelta(days=1)
        lease.lease_status_changed_at = timezone.now()
        lease.lease_status_changed_by = 'system:daily_classify'
        lease.save(update_fields=[
            'lease_status', 'holdover_start_date',
            'lease_status_changed_at', 'lease_status_changed_by'
        ])
        LeaseStatusLog.objects.create(
            lease=lease,
            previous_status='active',
            new_status='month_to_month',
            reason='Lease end date passed without renewal',
            changed_by='system:daily_classify'
        )
        mtm_count += 1

    # Notice -> Vacant
    expired_notice = TenantLease.objects.filter(
        lease_status='notice',
        notice_move_out_date__lt=today,
        actual_move_out_date__isnull=True
    )
    for lease in expired_notice.iterator(chunk_size=500):
        lease.lease_status = 'vacant'
        lease.actual_move_out_date = lease.notice_move_out_date
        lease.lease_status_changed_at = timezone.now()
        lease.lease_status_changed_by = 'system:daily_classify'
        lease.save(update_fields=[
            'lease_status', 'actual_move_out_date',
            'lease_status_changed_at', 'lease_status_changed_by'
        ])
        UnitsUnit.objects.filter(id=lease.unit_id).update(status='vacant')
        LeaseStatusLog.objects.create(
            lease=lease,
            previous_status='notice',
            new_status='vacant',
            reason='Move-out date passed',
            changed_by='system:daily_classify'
        )
        vacant_count += 1

    return f"Transitioned {mtm_count} to MTM, {vacant_count} to vacant."

Task: tenants.tasks.initial_lease_reclassification

Schedule: One-time migration task (run via management command)

Purpose: Reclassify the 1,146 existing expired leases that still have status='active'.

B Serializers

# tenants/serializers.py

class LeaseLifecycleSerializer(serializers.ModelSerializer):
    tenant_name = serializers.SerializerMethodField()
    unit_number = serializers.CharField(source='unit.unit_number', read_only=True)
    property_name = serializers.CharField(source='property.name', read_only=True)
    days_until_expiry = serializers.SerializerMethodField()
    days_in_current_status = serializers.SerializerMethodField()
    holdover_days = serializers.SerializerMethodField()

    class Meta:
        model = TenantLease
        fields = [
            'id', 'lease_number', 'tenant', 'tenant_name', 'unit', 'unit_number',
            'property', 'property_name', 'lease_begin_date', 'lease_end_date',
            'monthly_rent_amount', 'status', 'lease_status',
            'lease_status_changed_at', 'holdover_start_date',
            'notice_date', 'notice_move_out_date', 'actual_move_out_date',
            'days_until_expiry', 'days_in_current_status', 'holdover_days',
        ]

    def get_tenant_name(self, obj):
        return f"{obj.tenant.first_name} {obj.tenant.last_name}"

    def get_days_until_expiry(self, obj):
        if obj.lease_end_date:
            return (obj.lease_end_date - date.today()).days
        return None

    def get_days_in_current_status(self, obj):
        if obj.lease_status_changed_at:
            return (timezone.now() - obj.lease_status_changed_at).days
        return None

    def get_holdover_days(self, obj):
        if obj.holdover_start_date:
            return (date.today() - obj.holdover_start_date).days
        return None


class LeaseStatusLogSerializer(serializers.ModelSerializer):
    class Meta:
        model = LeaseStatusLog
        fields = '__all__'


class LifecycleStatsSerializer(serializers.Serializer):
    total_leases = serializers.IntegerField()
    active = serializers.IntegerField()
    month_to_month = serializers.IntegerField()
    notice = serializers.IntegerField()
    vacant = serializers.IntegerField()
    expiring_30_days = serializers.IntegerField()
    expiring_60_days = serializers.IntegerField()
    expiring_90_days = serializers.IntegerField()

F Angular Routes

PathComponentDescription
/lifecycleLifecycleDashboardComponentMain lifecycle dashboard with hex grid + table
/lifecycle/:leaseIdLeaseLifecycleDetailComponentSingle lease lifecycle detail with timeline
// app.routes.ts — add to children array
{
  path: 'lifecycle',
  title: 'Lease Lifecycle',
  loadComponent: () =>
    import('./features/lifecycle/lifecycle-dashboard.component')
      .then(m => m.LifecycleDashboardComponent)
},
{
  path: 'lifecycle/:leaseId',
  title: 'Lease Lifecycle Detail',
  loadComponent: () =>
    import('./features/lifecycle/lease-lifecycle-detail.component')
      .then(m => m.LeaseLifecycleDetailComponent)
},

F Components

ComponentLocationInputs/OutputsService Calls
LifecycleDashboardComponent features/lifecycle/ None (top-level) lifecycleService.getStats(), .getLeases(filters)
LifecycleHexGridComponent features/lifecycle/components/ @Input() stats: LifecycleStats None (pure display)
LeaseStatusBadgeComponent shared/components/ @Input() status: LeaseStatus None (pure display)
LeaseLifecycleDetailComponent features/lifecycle/ Route param leaseId lifecycleService.getLease(id), .getStatusHistory(id)
LeaseActionButtonsComponent features/lifecycle/components/ @Input() lease: Lease, @Output() actionTaken lifecycleService.convertToMtm(id), .giveNotice(id, data), .markVacant(id)
ExpiringLeasesTableComponent features/lifecycle/components/ @Input() leases: Lease[] None (presentational)

F Service Methods

// core/services/lifecycle.service.ts

@Injectable({ providedIn: 'root' })
export class LifecycleService {
  private url = `${environment.apiUrl}/tenants/leases`;

  getStats(): Observable<LifecycleStats> {
    return this.http.get<LifecycleStats>(`${this.url}/lifecycle-stats/`);
  }

  getLeases(filters?: LeaseFilters): Observable<PaginatedResponse<LeaseLifecycle>> {
    const params = this.buildParams(filters);
    return this.http.get<PaginatedResponse<LeaseLifecycle>>(this.url + '/', { params });
  }

  getLease(id: string): Observable<LeaseLifecycle> {
    return this.http.get<LeaseLifecycle>(`${this.url}/${id}/`);
  }

  convertToMtm(id: string): Observable<LeaseLifecycle> {
    return this.http.post<LeaseLifecycle>(`${this.url}/${id}/convert-mtm/`, {});
  }

  giveNotice(id: string, data: GiveNoticeRequest): Observable<LeaseLifecycle> {
    return this.http.post<LeaseLifecycle>(`${this.url}/${id}/give-notice/`, data);
  }

  markVacant(id: string): Observable<LeaseLifecycle> {
    return this.http.post<LeaseLifecycle>(`${this.url}/${id}/mark-vacant/`, {});
  }

  getStatusHistory(id: string): Observable<LeaseStatusLog[]> {
    return this.http.get<LeaseStatusLog[]>(`${this.url}/${id}/status-history/`);
  }
}

F Screen Wireframes

Lifecycle Dashboard

┌─────────────────────────────────────────────────────────────────────────────┐ Lease Lifecycle [Filter ▾] [Export] ├─────────────────────────────────────────────────────────────────────────────┤ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ ACTIVE │ │ MTM │ │ NOTICE │ │ VACANT │ │ 2,841 │ │ 1,146 │ │ 87 │ │ 279 │ │ 65.3% │ │ 26.3% │ │ 2.0% │ │ 6.4% │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ ├─────────────────────────────────────────────────────────────────────────────┤ Expiring Soon 30 days: 142 | 60 days: 298 | 90 days: 451 ├─────────────────────────────────────────────────────────────────────────────┤ Unit Tenant Status Rent End Date Actions A-102 Maria Gonzalez Active $1,850 2026-06-30 [Renew] B-205 James Chen MTM $2,100 2025-12-31 [Convert|Notice] C-314 Sarah Williams Notice $1,650 2026-04-15 [Mark Vacant] A-401 (empty) Vacant $1,900 — [New Lease] └─────────────────────────────────────────────────────────────────────────────┘

Status Badge Colors

StatusBackgroundTextHex
Active#ECFDF5#065F46Teal/green honeycomb
Month-to-Month#FEF3C7#92400EAmber honeycomb
Notice#FEE2E2#991B1BRed honeycomb
Vacant#F1F5F9#64748BGray honeycomb

F State Management

// In LifecycleDashboardComponent
stats = signal<LifecycleStats | null>(null);
leases = signal<LeaseLifecycle[]>([]);
selectedStatus = signal<LeaseStatus | 'all'>('all');
loading = signal<boolean>(true);

// Computed
filteredLeases = computed(() => {
  const status = this.selectedStatus();
  if (status === 'all') return this.leases();
  return this.leases().filter(l => l.lease_status === status);
});

L Business Logic & Thresholds

State Transition Rules

FromToTriggerAutomation
ActiveMonth-to-Monthlease_end_date < today and no renewalDaily Celery task
ActiveNoticeManual: tenant gives noticeAPI call
Month-to-MonthNoticeManual: tenant gives noticeAPI call
Month-to-MonthActiveNew lease signed (renewal)Via renewal feature (1.2)
NoticeVacantnotice_move_out_date < todayDaily Celery task
VacantActiveNew lease createdVia new lease creation

Thresholds

  • Expiring soon warning: lease_end_date within 30 days (amber badge)
  • Expiring critical: lease_end_date within 7 days (red badge)
  • Holdover long: holdover_days > 90 (flag for management review)
  • Notice period minimum: 30 days (validate on give-notice endpoint)

☑ Acceptance Checklist — 1.1 Lifecycle State Engine

1.2 Renewal Intelligence Agent
Backend Frontend AI Celery
Why this feature matters:
Tenant turnover is the single most expensive event in property management. When a tenant leaves, it costs roughly $3,000–5,000 per unit: lost rent during vacancy (average 14 days = $700), turnover costs (cleaning, paint, repairs = $800–1,500), marketing and leasing costs ($500), plus the risk of getting a worse tenant. Multiply that across 200+ turnovers per year and it's a $600K–1M annual cost. The Renewal Intelligence Agent exists to prevent unnecessary turnover by predicting which tenants are likely to leave before they even know it themselves. Using payment history, maintenance request frequency, rent-to-market gap, tenure length, and communication patterns, the AI generates a churn risk score for every tenant with a lease expiring in the next 30–120 days. It then recommends the optimal renewal rent — the price point that maximizes the chance of acceptance while capturing fair market value — and auto-generates a personalized renewal letter. The property manager opens the Renewals Dashboard, sees 12 renewals due this month ranked by risk, previews each AI-drafted offer, adjusts if needed, and sends with one click. Instead of finding out a tenant is leaving when they give 30-day notice, the manager is proactively reaching out 90 days early with a competitive offer.

Predict churn risk per tenant using payment history, maintenance frequency, rent gap, and tenure. Nightly scan of leases expiring in 30-120 days. Generate renewal recommendations with AI-optimized rent. Auto-draft renewal letters.

MODIFY EXISTING + NEW SCREEN: Adds a "Churn Risk" stat cell to the existing unit detail header (unit-detail.component.html, after the Balance column). Also adds a "Renewals Due" card to the main dashboard (dashboard.component.html). The main Renewals Dashboard and Detail pages are NEW screens at /renewals.

B Django Models

# renewals/models.py
import uuid
from django.db import models

class RenewalRecommendation(models.Model):
    """AI-generated renewal recommendation for a lease."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    lease = models.ForeignKey(
        'tenants.TenantLease', on_delete=models.CASCADE,
        related_name='renewal_recommendations'
    )
    tenant = models.ForeignKey(
        'tenants.Tenants', on_delete=models.CASCADE,
        related_name='renewal_recommendations'
    )
    unit = models.ForeignKey(
        'units.UnitsUnit', on_delete=models.CASCADE,
        related_name='renewal_recommendations'
    )
    property = models.ForeignKey(
        'properties.Properties', on_delete=models.CASCADE,
        related_name='renewal_recommendations'
    )

    # Churn prediction
    churn_score = models.FloatField()           # 0.0 to 1.0
    churn_risk_level = models.CharField(        # low, medium, high, critical
        max_length=20,
        choices=[
            ('low', 'Low'),
            ('medium', 'Medium'),
            ('high', 'High'),
            ('critical', 'Critical'),
        ]
    )
    churn_factors = models.JSONField(default=dict)
    # Example: {"late_payments": 0.3, "maintenance_requests": 0.15, "rent_gap": 0.25, "tenure_short": 0.1}

    # Rent recommendation
    current_rent = models.DecimalField(max_digits=10, decimal_places=2)
    market_rent = models.DecimalField(max_digits=10, decimal_places=2)
    recommended_rent = models.DecimalField(max_digits=10, decimal_places=2)
    rent_increase_pct = models.FloatField()     # e.g. 3.5 = 3.5%
    rent_strategy = models.CharField(max_length=30)
    # 'hold', 'below_market_retain', 'market_rate', 'above_market_premium'
    rent_reasoning = models.TextField()

    # Lease terms
    recommended_term_months = models.IntegerField(default=12)
    recommended_lease_start = models.DateField()
    recommended_lease_end = models.DateField()

    # Letter
    renewal_letter_text = models.TextField(blank=True, default='')
    renewal_letter_generated_at = models.DateTimeField(null=True, blank=True)

    # Status
    status = models.CharField(
        max_length=20, default='pending',
        choices=[
            ('pending', 'Pending Review'),
            ('approved', 'Approved'),
            ('sent', 'Sent to Tenant'),
            ('accepted', 'Accepted by Tenant'),
            ('declined', 'Declined by Tenant'),
            ('expired', 'Expired'),
        ]
    )
    reviewed_by = models.CharField(max_length=100, blank=True, default='')
    reviewed_at = models.DateTimeField(null=True, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'renewal_recommendation'
        ordering = ['-created_at']
        indexes = [
            models.Index(fields=['lease', '-created_at']),
            models.Index(fields=['status', 'churn_risk_level']),
            models.Index(fields=['property', 'status']),
        ]

Field Reference

FieldTypePurpose
churn_scoreFloat 0.0-1.0Probability tenant will not renew. Higher = more likely to leave.
churn_risk_levelEnumlow (<0.3), medium (0.3-0.5), high (0.5-0.7), critical (>0.7)
churn_factorsJSONBreakdown of factors contributing to score. Keys: late_payments, maintenance_requests, rent_gap, tenure_short, market_trend
rent_strategyEnumAI-chosen strategy based on churn risk and market conditions
rent_increase_pctFloatPercentage increase from current rent to recommended

B API Endpoints

MethodPathDescription
GET /api/v1/renewals/ List all renewal recommendations. Filter by status, churn_risk_level, property.
GET /api/v1/renewals/{id}/ Single recommendation detail.
POST /api/v1/renewals/{id}/approve/ Approve recommendation (optionally with modified rent).
POST /api/v1/renewals/{id}/send/ Send renewal letter to tenant.
POST /api/v1/renewals/{id}/generate-letter/ Regenerate renewal letter via Claude API.
GET /api/v1/renewals/dashboard-stats/ Aggregate: pending, approved, sent, acceptance rate, revenue impact.
POST /api/v1/renewals/run-scan/ Manually trigger renewal scan (for testing).

GET /api/v1/renewals/dashboard-stats/

// Response 200:
{
  "total_pending": 142,
  "total_approved": 38,
  "total_sent": 67,
  "total_accepted": 51,
  "total_declined": 12,
  "acceptance_rate": 0.76,
  "avg_churn_score": 0.42,
  "projected_annual_revenue_increase": 284760.00,
  "avg_rent_increase_pct": 3.8,
  "by_risk_level": {
    "low": 82,
    "medium": 34,
    "high": 19,
    "critical": 7
  }
}

C Celery Tasks

Task: renewals.tasks.nightly_renewal_scan

Schedule: Daily at 03:00 AM UTC

Steps:

  1. Query leases where lease_status IN ('active', 'month_to_month') AND lease_end_date is within 30-120 days from today (or already MTM).
  2. Exclude leases that already have a RenewalRecommendation with status in ('pending', 'approved', 'sent').
  3. For each lease, compute churn score (see algorithm below).
  4. Determine rent strategy based on churn score and market rent.
  5. Calculate recommended rent.
  6. Call Claude API to generate renewal letter text.
  7. Create RenewalRecommendation record.

L Churn Scoring Algorithm

The churn score is a weighted sum of five factors, each normalized to 0.0-1.0:

def compute_churn_score(lease, tenant):
    """
    Returns float 0.0 to 1.0 (higher = more likely to churn).
    """
    factors = {}

    # Factor 1: Late Payment Rate (weight: 0.30)
    # Count ledger entries where trans_date > due_date in last 12 months
    total_charges = tenant.ledger_entries.filter(
        trans_type='Charge',
        trans_date__gte=twelve_months_ago
    ).count()
    late_payments = # count where payment arrived after due_date
    late_rate = late_payments / max(total_charges, 1)
    factors['late_payments'] = min(late_rate * 2, 1.0)  # scale: 50% late = 1.0

    # Factor 2: Maintenance Request Frequency (weight: 0.15)
    # High frequency = unhappy tenant
    wo_count = UnitsMaintenance.objects.filter(
        unit=lease.unit,
        requested_date__gte=twelve_months_ago
    ).count()
    factors['maintenance_requests'] = min(wo_count / 10, 1.0)  # 10+ requests = 1.0

    # Factor 3: Rent-to-Market Gap (weight: 0.25)
    # If tenant pays well below market, low churn risk (good deal)
    # If tenant pays at/above market, higher churn risk
    market = lease.unit.market_rent or lease.monthly_rent_amount
    current = lease.monthly_rent_amount
    if market > 0:
        ratio = float(current) / float(market)
        factors['rent_gap'] = max(0, min((ratio - 0.85) / 0.15, 1.0))
        # 85% of market = 0.0, 100%+ of market = 1.0
    else:
        factors['rent_gap'] = 0.5

    # Factor 4: Tenure Length (weight: 0.15)
    # Short tenure = higher churn
    tenure_years = (date.today() - lease.lease_begin_date).days / 365.25
    factors['tenure_short'] = max(0, 1.0 - (tenure_years / 3))
    # 0 years = 1.0, 3+ years = 0.0

    # Factor 5: Market Trend (weight: 0.15)
    # If local vacancy rate is low, tenant has more options
    # Use property-level vacancy as proxy
    property_units = lease.property.total_units
    vacant_units = UnitsUnit.objects.filter(
        property=lease.property, status='vacant'
    ).count()
    vacancy_rate = vacant_units / max(property_units, 1)
    factors['market_trend'] = max(0, 1.0 - (vacancy_rate * 10))
    # 0% vacancy = 1.0, 10%+ = 0.0

    # Weighted sum
    weights = {
        'late_payments': 0.30,
        'maintenance_requests': 0.15,
        'rent_gap': 0.25,
        'tenure_short': 0.15,
        'market_trend': 0.15,
    }
    score = sum(factors[k] * weights[k] for k in weights)
    return round(score, 3), factors

Rent Strategy Selection

Churn ScoreRent GapStrategyMax Increase
> 0.7 (critical)Anyhold0%
0.5-0.7 (high)Below marketbelow_market_retain2%
0.3-0.5 (medium)Below marketmarket_rate5% or to market
< 0.3 (low)Below marketmarket_rate8% or to market
< 0.3 (low)At/above marketabove_market_premium3% above market

Recommended Rent Formula

def calculate_recommended_rent(current_rent, market_rent, strategy, churn_score):
    if strategy == 'hold':
        return current_rent

    if strategy == 'below_market_retain':
        max_increase = current_rent * Decimal('0.02')
        target = min(current_rent + max_increase, market_rent * Decimal('0.95'))
        return max(current_rent, target)

    if strategy == 'market_rate':
        max_pct = Decimal('0.05') if churn_score >= 0.3 else Decimal('0.08')
        max_increase = current_rent * max_pct
        target = min(current_rent + max_increase, market_rent)
        return max(current_rent, target)

    if strategy == 'above_market_premium':
        return market_rent * Decimal('1.03')

    return current_rent

F Screen Wireframes

┌─────────────────────────────────────────────────────────────────────────────┐ Renewal Intelligence [Run Scan] [Export] ├──────────────┬──────────────┬──────────────┬───────────────────────────────────┤ Critical: 7 High: 19 Medium: 34 Low: 82 Acceptance: 76% ├──────────────┴──────────────┴──────────────┴───────────────────────────────────┤ Tenant Unit Churn Current Recommended Increase Status Maria Gonzalez A-102 0.82 $1,850 $1,850 0.0% Pending Strategy: HOLD — high churn risk, retain at current rent James Chen B-205 0.45 $2,100 $2,205 5.0% Approved Strategy: MARKET_RATE — moderate risk, move toward market Sarah Williams C-314 0.18 $1,650 $1,782 8.0% Sent Strategy: MARKET_RATE — low risk, healthy increase └──────────────────────────────────────────────────────────────────────────────┘

F Angular Routes & Components

PathComponent
/renewalsRenewalDashboardComponent
/renewals/:idRenewalDetailComponent
ComponentLocationPurpose
RenewalDashboardComponentfeatures/renewals/Main dashboard with stats cards and table
RenewalDetailComponentfeatures/renewals/Detail view with churn factors chart, letter preview
ChurnScoreGaugeComponentfeatures/renewals/components/Circular gauge showing churn probability
ChurnFactorsChartComponentfeatures/renewals/components/Horizontal bar chart of churn factors
RenewalLetterPreviewComponentfeatures/renewals/components/Letter text preview with edit capability

☑ Acceptance Checklist — 1.2 Renewal Intelligence

1.3 Smart Collections Agent
Backend Frontend AI Celery
Why this feature matters:
Chasing late rent is one of the most tedious and emotionally draining parts of property management. Today, when a tenant misses a payment, someone has to manually check, decide when to send a reminder, write the message, escalate if no response, calculate if a payment plan makes sense, and eventually loop in management. Different staff handle it differently — some are too aggressive, some too lenient, some just forget. The Smart Collections Agent automates this entire process with an adaptive dunning sequence that runs itself. When rent is 1 day late, it sends a friendly SMS. Day 3, a professional email with a payment link. Day 5, a formal notice referencing lease terms. Day 8, it auto-generates a payment plan offer based on the balance and the tenant's typical income. Day 12, it escalates to the manager with full history and a recommended next step. The key innovation is tone adaptation: a first-time-late tenant who's paid on time for 14 months gets a gentle "hey, everything okay?" while a repeat offender gets firmer language from the start. The manager only gets involved when human judgment is actually needed — the first 4 steps run entirely on autopilot.

Automated dunning sequences that adapt tone based on tenant history. Five-step escalation: Day 1 SMS, Day 3 email, Day 5 formal letter, Day 8 payment plan offer, Day 12 legal escalation.

MODIFY EXISTING + NEW SCREEN: Adds a delinquent indicator with escalation level to the existing Balance column in unit-detail.component.html (line ~110-119). The main Collections Dashboard and Case Detail are NEW screens at /collections.

B Django Models

# collections/models.py

class CollectionCase(models.Model):
    """A collection case for an overdue balance."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    tenant = models.ForeignKey('tenants.Tenants', on_delete=models.CASCADE, related_name='collection_cases')
    lease = models.ForeignKey('tenants.TenantLease', on_delete=models.CASCADE, related_name='collection_cases')
    unit = models.ForeignKey('units.UnitsUnit', on_delete=models.CASCADE, related_name='collection_cases')
    property = models.ForeignKey('properties.Properties', on_delete=models.CASCADE, related_name='collection_cases')

    original_amount_due = models.DecimalField(max_digits=10, decimal_places=2)
    current_balance = models.DecimalField(max_digits=10, decimal_places=2)
    late_fee_total = models.DecimalField(max_digits=10, decimal_places=2, default=0)
    days_overdue = models.IntegerField(default=0)
    due_date = models.DateField()

    escalation_level = models.IntegerField(default=0)
    # 0=new, 1=sms_sent, 2=email_sent, 3=formal_sent, 4=plan_offered, 5=escalated

    tenant_risk_profile = models.CharField(
        max_length=20, default='standard',
        choices=[
            ('good_history', 'Good History'),     # First-time late, usually pays on time
            ('standard', 'Standard'),
            ('repeat_offender', 'Repeat Offender'), # 3+ late in 12 months
            ('chronic', 'Chronic'),               # 6+ late in 12 months
        ]
    )
    tone = models.CharField(
        max_length=20, default='friendly',
        choices=[
            ('friendly', 'Friendly'),
            ('firm', 'Firm'),
            ('formal', 'Formal'),
            ('legal', 'Legal'),
        ]
    )

    status = models.CharField(
        max_length=20, default='open',
        choices=[
            ('open', 'Open'),
            ('payment_plan', 'Payment Plan'),
            ('paid', 'Paid'),
            ('escalated', 'Escalated to Legal'),
            ('written_off', 'Written Off'),
        ]
    )
    resolved_at = models.DateTimeField(null=True, blank=True)
    assigned_to = models.CharField(max_length=100, blank=True, default='')
    notes = models.TextField(blank=True, default='')

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'collection_case'
        ordering = ['-days_overdue']
        indexes = [
            models.Index(fields=['status', 'escalation_level']),
            models.Index(fields=['tenant', 'status']),
            models.Index(fields=['property', 'status']),
        ]


class CollectionAction(models.Model):
    """Individual action/communication in a collection case."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    case = models.ForeignKey(CollectionCase, on_delete=models.CASCADE, related_name='actions')

    action_type = models.CharField(
        max_length=20,
        choices=[
            ('sms', 'SMS Reminder'),
            ('email', 'Email Notice'),
            ('formal_letter', 'Formal Letter'),
            ('payment_plan', 'Payment Plan Offer'),
            ('escalation', 'Legal Escalation'),
            ('call', 'Phone Call'),
            ('payment_received', 'Payment Received'),
            ('note', 'Manual Note'),
        ]
    )
    escalation_level = models.IntegerField()
    channel = models.CharField(max_length=20, default='system')
    # 'sms', 'email', 'letter', 'phone', 'system'

    message_text = models.TextField()
    tone_used = models.CharField(max_length=20)
    ai_generated = models.BooleanField(default=True)

    sent_at = models.DateTimeField(null=True, blank=True)
    delivered = models.BooleanField(default=False)
    opened = models.BooleanField(default=False)
    response_received = models.BooleanField(default=False)

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'collection_action'
        ordering = ['created_at']


class PaymentPlan(models.Model):
    """Payment plan offered to tenant for overdue balance."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    case = models.OneToOneField(CollectionCase, on_delete=models.CASCADE, related_name='payment_plan')
    total_amount = models.DecimalField(max_digits=10, decimal_places=2)
    num_installments = models.IntegerField()
    installment_amount = models.DecimalField(max_digits=10, decimal_places=2)
    start_date = models.DateField()
    status = models.CharField(
        max_length=20, default='proposed',
        choices=[
            ('proposed', 'Proposed'),
            ('accepted', 'Accepted'),
            ('active', 'Active'),
            ('completed', 'Completed'),
            ('defaulted', 'Defaulted'),
        ]
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'collection_payment_plan'


class PaymentPlanInstallment(models.Model):
    """Individual installment in a payment plan."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    plan = models.ForeignKey(PaymentPlan, on_delete=models.CASCADE, related_name='installments')
    installment_number = models.IntegerField()
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    due_date = models.DateField()
    paid_date = models.DateField(null=True, blank=True)
    status = models.CharField(max_length=20, default='pending')
    # 'pending', 'paid', 'overdue', 'partial'

    class Meta:
        db_table = 'collection_plan_installment'
        ordering = ['installment_number']

B API Endpoints

MethodPathDescription
GET/api/v1/collections/List collection cases. Filter: status, property, escalation_level, days_overdue__gte
GET/api/v1/collections/{id}/Case detail with actions timeline
GET/api/v1/collections/{id}/actions/All actions for a case
POST/api/v1/collections/{id}/send-reminder/Manually trigger next escalation step
POST/api/v1/collections/{id}/offer-plan/Generate and offer payment plan
POST/api/v1/collections/{id}/record-payment/Record a payment against the case
POST/api/v1/collections/{id}/escalate/Escalate to legal
GET/api/v1/collections/dashboard-stats/Aggregate stats

C Celery Tasks

Task: collections.tasks.daily_collections_scan

Schedule: Daily at 08:00 AM UTC

  1. Query TenantLedgerEntry for open charges past due date.
  2. Group by tenant. Sum overdue balance per tenant.
  3. For each tenant with overdue balance and no open CollectionCase: create new case.
  4. Classify tenant_risk_profile based on payment history.
  5. Select initial tone based on risk profile.

Task: collections.tasks.escalation_engine

Schedule: Every 4 hours

  1. Query open CollectionCases.
  2. For each case, check if escalation is due based on dunning schedule (see thresholds).
  3. Generate message text via Claude API with appropriate tone.
  4. Create CollectionAction and send via channel (SMS/email).
  5. Increment escalation_level.

L Dunning Schedule & Thresholds

Days OverdueLevelActionChannelGood History ToneRepeat Offender Tone
11Friendly reminderSMSFriendlyFirm
32Detailed noticeEmailFriendlyFirm
53Formal noticeEmail + LetterFirmFormal
84Payment plan offerEmailFirmFormal
125Legal escalation warningCertified letterFormalLegal

Tone Selection Logic

def select_tone(risk_profile, escalation_level):
    tone_map = {
        'good_history':    ['friendly', 'friendly', 'firm',   'firm',   'formal'],
        'standard':        ['friendly', 'firm',     'firm',   'formal', 'formal'],
        'repeat_offender': ['firm',     'firm',     'formal', 'formal', 'legal'],
        'chronic':         ['firm',     'formal',   'formal', 'legal',  'legal'],
    }
    idx = min(escalation_level, 4)
    return tone_map.get(risk_profile, tone_map['standard'])[idx]

Risk Profile Classification

  • good_history: 0-1 late payments in last 12 months, balance usually $0
  • standard: 2 late payments in last 12 months
  • repeat_offender: 3-5 late payments in last 12 months
  • chronic: 6+ late payments in last 12 months

F Screen Wireframes

┌─────────────────────────────────────────────────────────────────────────────┐ Smart Collections [Scan Now] [Export] ├──────────────┬──────────────┬──────────────┬────────────────────────────────────┤ $47,280 23 Cases $12,400 8 Paid This Week Total Overdue Open On Plan ├──────────────┴──────────────┴──────────────┴────────────────────────────────────┤ Tenant Unit Balance Days Level Next Action Tone Robert Kim D-108 $3,200 14 5/5 Legal escalation Legal Timeline: SMS(1d) → Email(3d) → Formal(5d) → Plan(8d) → [Legal] Lisa Park A-205 $1,850 5 3/5 Formal letter Firm Timeline: SMS(1d) → Email(3d) → [Formal] → Plan(8d) → Escalation Ahmed Hassan B-312 $950 1 1/5 SMS sent Friendly Timeline: [SMS] → Email(3d) → Formal(5d) → Plan(8d) → Escalation └──────────────────────────────────────────────────────────────────────────────┘

Routes: /collections (dashboard), /collections/:caseId (detail with timeline view).

☑ Acceptance Checklist — 1.3 Smart Collections

1.4 Portfolio Command Center
Frontend Backend AI Celery
Why this feature matters:
Property managers spend hours doing the same repetitive bulk tasks: "send renewal offers to everyone with a lease expiring in 60 days," "issue 60-day non-renewal notices to all MTM tenants we want to convert," "schedule move-out inspections for all units with notice." Right now these are batched one-by-one. Worse, there's no AI-generated intelligence surfacing which actions would have the highest impact across the portfolio. The Portfolio Command Center solves both: a single screen that lists every unit in the portfolio, lets the manager filter by lifecycle state (and many other dimensions), shows AI-generated action recommendations per unit (pre-computed nightly), and lets the manager execute bulk actions — send letters, schedule inspections, update statuses — with one click. It turns the abstract "we need to do something about our 87 MTM tenants" into a concrete, actionable list in 30 seconds. This is the hub where AI recommendations meet bulk execution.

A new portfolio-wide unit list with multi-filtering, bulk selection, and AI action recommendations. Filters: lifecycle state, property, risk level, days-to-event, rent amount, building. AI pre-generates recommended actions nightly (per-unit confidence scores). PM reviews, selects units, and executes bulk actions in one pass.

NEW SCREEN: This feature creates an entirely new route /portfolio/command-center. It does NOT modify existing screens. The existing lifecycle badges on unit detail pages (F1.1) are unaffected. Depends on F1.1 (Lifecycle State Engine) for state classification and F1.3 (Smart Collections) for risk scoring data.

F UI Wireframe

┌─────────────────────────────────────────────────────────────────────────────┐
│  Portfolio Command Center                          [Export CSV] [Refresh]  │
├─────────────────────────────────────────────────────────────────────────────┤
│ FILTERS                                                                       │
│ Lifecycle: [Active ▾] [MTM ▾] [Notice ▾] [Vacant ▾]    [Clear Filters]    │
│ Property:  [All Properties ▾]   Rent: [$0]–[$3000]   Risk: [All ▾]        │
│ Sort by:   [Days to Event ▾]                                               │
├─────────────────────────────────────────────────────────────────────────────┤
│ Select all (412) │ Selected: 3 units                        [Bulk Actions ▾]│
│                                                                             │
│ ☐ │ Unit      │ Tenant          │ State      │ Rent    │ Days │ Risk  │ AI Action                         │
│───│───────────│─────────────────│─────────────│─────────│──────│───────│────────────────────────────────────│
│ ☐ │ 1407D    │ James Rivera    │ Notice     │ $1,250  │ 12d  │ Low   │ ⭐ Schedule inspection             │
│ ☐ │ 1624B    │ Maria Santos    │ MTM        │ $1,100  │ —    │ Med   │ ⭐ Offer renewal @ $1,150         │
│ ☐ │ 1543C    │ Uriel Trejo     │ MTM        │ $975    │ —    │ Low   │ ⭐ Rent increase notice (+$75)     │
│ ☐ │ 2301A    │ Chen Wei        │ Notice     │ $1,450  │ 23d  │ High  │ ⚠ Begin eviction prep             │
│ ☐ │ 1822F    │ Sarah Mitchell  │ Active     │ $1,325  │ 87d  │ Med   │ 💡 Offer early renewal             │
│...│ ...      │ ...             │ ...        │ ...     │ ...  │ ...   │ ...                                │
│                                                                             │
│ ─────────────────────────────────────────────────────────────────────────  │
│  Showing 412 units  │  3 units selected  │  Total rent selected: $4,575  │
└─────────────────────────────────────────────────────────────────────────────┘

BULK ACTION MODAL (appears after clicking "Bulk Actions ▾" → e.g., "Send Renewal Letters")
┌─────────────────────────────────────────────────────────────────────────────┐
│  ✉  Bulk Action: Send Renewal Letters (3 units)                            │
│ ────────────────────────────────────────────────────────────────────────── │
│  Units: 1624B (Maria Santos), 1543C (Uriel Trejo), 1822F (Sarah Mitchell)│
│  Template: [Renewal Offer Letter ▾]  [Preview First] [Edit Template]     │
│                                                                             │
│  ─── AI-GENERATED PREVIEW (3 of 3 shown) ────────────────────────────────  │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │ Unit 1624B — Maria Santos                                           │   │
│  │ Renewal at $1,150/mo (market: $1,175/mo). Term: 12 months.         │   │
│  │ "Dear Maria, as your lease approaches renewal..."                    │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│  Send via: [Email ▾]  [SMS ▾]  [Both ▾]   [Send Now] [Schedule ▾]        │
└─────────────────────────────────────────────────────────────────────────────┘

B Django Backend

New endpoints:

# portfolio/urls.py
path('command-center/', views.command_center, name='command-center'),
path('api/command-center/actions/', views.bulk_action_execute, name='bulk-action-execute'),
path('api/command-center/ai-queue/', views.ai_action_queue, name='ai-action-queue'),

New model:

# portfolio_command/models.py

class AIActionQueue(models.Model):
    """
    Nightly-generated AI action recommendation for a unit.
    One record per unit per action type, refreshed daily.
    """
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

    unit = models.ForeignKey('units.UnitsUnit', on_delete=models.CASCADE, related_name='ai_action_queue')
    lease = models.ForeignKey('tenants.TenantLease', on_delete=models.CASCADE,
                              null=True, blank=True, related_name='ai_action_queue')
    property = models.ForeignKey('properties.Properties', on_delete=models.CASCADE,
                                  related_name='ai_action_queue')

    action_type = models.CharField(max_length=50, db_index=True)
    # e.g. 'renewal_offer', 'rent_increase_notice', 'move_out_inspection',
    #      'listing_update', 'eviction_prep', 'early_renewal_nudge'

    description = models.TextField()          # Human-readable summary
    suggested_payload = models.JSONField()    # {template_slug, rent_amount, term_months, ...}
    generated_content = models.TextField(null=True, blank=True)  # Draft letter, etc.

    confidence_score = models.DecimalField(max_digits=3, decimal_places=2, default=0.50)
    # 0.00–1.00. Only show to PM if confidence >= 0.60 by default.

    priority = models.CharField(
        max_length=10, default='medium',
        choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('urgent', 'Urgent')]
    )

    lifecycle_state = models.CharField(max_length=20)  # Cached from lease at generation time

    status = models.CharField(
        max_length=20, default='pending',
        choices=[
            ('pending', 'Pending Review'),
            ('approved', 'Approved — Ready to Send'),
            ('executed', 'Executed'),
            ('dismissed', 'Dismissed'),
            ('superseded', 'Superseded (newer recommendation exists)'),
        ]
    )

    # Execution tracking
    executed_at = models.DateTimeField(null=True, blank=True)
    executed_by = models.CharField(max_length=100, blank=True, default='')
    execution_method = models.CharField(max_length=20, blank=True, default='')  # 'email', 'sms', 'manual'
    bulk_batch_id = models.UUIDField(null=True, blank=True)

    # Source context
    generation_reason = models.TextField(blank=True, default='')
    # e.g. "MTM tenant 90 days at below-market rent ($175 gap). High conversion probability."

    generated_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'ai_action_queue'
        ordering = ['-priority', '-confidence_score']
        indexes = [
            models.Index(fields=['status', 'priority', '-confidence_score']),
            models.Index(fields=['unit', 'action_type', '-generated_at']),
            models.Index(fields=['property', 'lifecycle_state', 'status']),
            models.Index(fields=['action_type', 'status']),
        ]

    def __str__(self):
        return f"{self.unit} — {self.action_type} ({self.confidence_score:.0%} conf)"


class BulkActionBatch(models.Model):
    """Audit trail for bulk action executions."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    action_type = models.CharField(max_length=50)
    unit_count = models.IntegerField()
    suggested_payload = models.JSONField()
    executed_by = models.CharField(max_length=100)
    execution_method = models.CharField(max_length=20, default='email')
    scheduled_for = models.DateTimeField(null=True, blank=True)
    status = models.CharField(
        max_length=20, default='pending',
        choices=[('pending', 'Pending'), ('sent', 'Sent'), ('failed', 'Failed'), ('cancelled', 'Cancelled')]
    )
    result_summary = models.TextField(blank=True, default='')
    created_at = models.DateTimeField(auto_now_add=True)

Command center endpoint:

# portfolio/views.py

@api_view(['GET'])
def command_center(request):
    """
    Returns paginated unit list for the command center with AI action recommendations.
    Filter params: lifecycle_state, property_id, risk_level, min_rent, max_rent,
                   days_to_event, sort_by, include_ai_actions (bool)
    """
    qs = UnitsUnit.objects.select_related(
        'lease', 'property', 'lease__tenant'
    ).annotate(
        lifecycle_state=F('lease__lease_status'),
        holdover_days=Exists(
            TenantLease.objects.filter(
                unit=OuterRef('pk'),
                lease_status='month_to_month'
            )
        )
    )

    # Filters
    state = request.GET.get('lifecycle_state')
    if state:
        qs = qs.filter(lease__lease_status=state)

    risk = request.GET.get('risk_level')
    if risk:
        qs = qs.filter(collection_cases__tenant_risk_profile=risk)

    min_rent = request.GET.get('min_rent')
    max_rent = request.GET.get('max_rent')
    if min_rent:
        qs = qs.filter(lease__market_rent__gte=min_rent)
    if max_rent:
        qs = qs.filter(lease__market_rent__lte=max_rent)

    # Only show units with active leases or in lifecycle states
    qs = qs.filter(
        Q(lease__lease_status__in=['active', 'month_to_month', 'notice']) |
        Q(status='vacant')
    ).distinct()

    page = int(request.GET.get('page', 1))
    per_page = 50
    paginator = Paginator(qs, per_page)
    units_page = paginator.page(page)

    # Attach top AI action per unit
    unit_ids = [u.id for u in units_page]
    ai_actions = {
        r['unit_id']: r for r in
        AIActionQueue.objects.filter(
            unit_id__in=unit_ids,
            status='pending'
        ).values(
            'unit_id', 'action_type', 'description', 'confidence_score',
            'priority', 'suggested_payload', 'generation_reason'
        ).order_by('-confidence_score')
    }

    data = [{
        'id': str(u.id),
        'unit_number': u.unit_number,
        'property_name': u.property.name if u.property else None,
        'lifecycle_state': u.lease.lease_status if u.lease else 'vacant',
        'tenant_name': f"{u.lease.tenant.first_name} {u.lease.tenant.last_name}" if u.lease and u.lease.tenant else None,
        'rent': str(u.lease.market_rent) if u.lease else None,
        'days_to_event': calculate_days_to_event(u),
        'ai_action': ai_actions.get(u.id),
    } for u in units_page]

    return Response({
        'units': data,
        'total': paginator.count,
        'page': page,
        'pages': paginator.num_pages,
        'summary': {
            'active_count': qs.filter(lease__lease_status='active').count(),
            'mtm_count': qs.filter(lease__lease_status='month_to_month').count(),
            'notice_count': qs.filter(lease__lease_status='notice').count(),
            'vacant_count': qs.filter(status='vacant').count(),
        }
    })


@api_view(['POST'])
def bulk_action_execute(request):
    """
    Execute a bulk action on selected units.
    Body: { action_type, unit_ids: [], method: 'email'|'sms'|'manual', scheduled_for? }
    """
    action_type = request.data.get('action_type')
    unit_ids = request.data.get('unit_ids', [])
    method = request.data.get('method', 'email')
    scheduled_for = request.data.get('scheduled_for')

    units = UnitsUnit.objects.filter(id__in=unit_ids).select_related('lease', 'property')
    batch = BulkActionBatch.objects.create(
        action_type=action_type,
        unit_count=len(unit_ids),
        suggested_payload=request.data.get('suggested_payload', {}),
        executed_by=str(request.user),
        execution_method=method,
        scheduled_for=parse_dt(scheduled_for) if scheduled_for else None,
    )

    # For most actions: generate content via Claude → create action record → queue send
    if action_type == 'renewal_offer':
        for unit in units:
            payload = AIActionQueue.objects.filter(
                unit=unit, action_type='renewal_offer', status='pending'
            ).first()
            if payload and payload.generated_content:
                create_renewal_letter(unit, payload.generated_content, method)
                payload.update(status='executed', executed_at=now(), executed_by=str(request.user))

    elif action_type == 'rent_increase_notice':
        for unit in units:
            payload = AIActionQueue.objects.filter(
                unit=unit, action_type='rent_increase_notice', status='pending'
            ).first()
            if payload:
                create_rent_increase_notice(unit, payload.suggested_payload, method)
                payload.update(status='executed', executed_at=now(), executed_by=str(request.user))

    elif action_type == 'move_out_inspection':
        for unit in units:
            schedule_moveout_inspection(unit)

    batch.update(status='sent')
    return Response({'batch_id': str(batch.id), 'units_processed': len(unit_ids)})

F Angular Frontend

// app-routing.module.ts
{
  path: 'portfolio/command-center',
  loadComponent: () => import('./features/portfolio/command-center/command-center.component')
    .then(m => m.CommandCenterComponent),
  data: { title: 'Portfolio Command Center' }
}

// src/app/features/portfolio/command-center/
//   command-center.component.ts       — Main component, handles filtering, selection, bulk actions
//   command-center.component.html     — Template with filter bar, unit table, bulk action modal
//   command-center.component.scss     — Styles
//   command-center.service.ts         — API calls (get units, execute bulk action)
//   unit-table.component.ts           — Reusable unit row with checkbox + action badge
//   bulk-action-modal.component.ts     — Modal for preview + confirm bulk actions
//   ai-action-badge.component.ts      — Colored badge showing AI action type + confidence dot
//   filter-bar.component.ts           — Lifecycle, property, rent, risk, sort filters

Key components:

  • CommandCenterComponent — Main page. Manages selected unit IDs, active filters, and the bulk action modal state. Calls GET /api/command-center/actions/ on load and on filter change.
  • UnitTableComponent — Virtualized table (CDK Table) for performance across 1,000+ units. Columns: checkbox, unit, tenant, state badge, rent, days-to-event, risk, AI action. Click row to expand inline details.
  • BulkActionModalComponent — Appears after selecting units + clicking bulk action. Shows AI-generated preview per unit (e.g., the draft renewal letter), lets PM edit template, choose send method (email/SMS/both), and confirm.
  • AIActionBadgeComponent — Color-coded pill: ⭐ Renewal offer (92%). Stars indicate AI confidence. Urgent items get a pulsing orange ring.
  • FilterBarComponent — Lifecycle state multi-select (checkboxes, not dropdown), property dropdown, rent range slider, risk filter, sort dropdown. Filters emit to parent, which re-fetches.

A AI Integration

Nightly action generation (Celery beat, 2 AM):

For each non-vacant unit, the AI agent evaluates the unit's current state and generates 0–2 action recommendations:

# portfolio/tasks.py

@app.task
def generate_ai_action_queue():
    """
    Nightly Celery task. For every unit with an active/MTM/notice lease,
    generate up to 2 AI action recommendations and upsert into AIActionQueue.
    """
    units = UnitsUnit.objects.filter(
        status__in=['occupied', 'partially_occupied']
    ).select_related('lease', 'lease__tenant', 'property')

    for unit in units:
        state = unit.lease.lease_status if unit.lease else None
        if not state:
            continue

        # Mark yesterday's pending items as superseded
        AIActionQueue.objects.filter(unit=unit, status='pending').exclude(
            generated_at__date=date.today()
        ).update(status='superseded')

        if state == 'active':
            days_to_expiry = (unit.lease.lease_end_date - date.today()).days
            if 60 <= days_to_expiry <= 90:
                prompt = build_renewal_prompt(unit, renewal_type='early_nudge')
                result = claude.complete(prompt)
                upsert_action(unit, 'early_renewal_nudge', result)

        elif state == 'month_to_month':
            gap = unit.lease.market_rent - unit.lease.current_rent
            if gap > 50:
                prompt = build_rent_increase_prompt(unit, gap_amount=gap)
                result = claude.complete(prompt)
                upsert_action(unit, 'rent_increase_notice', result)

            if (date.today() - unit.lease.lease_end_date).days > 30:
                prompt = build_renewal_prompt(unit, renewal_type='mtm_conversion')
                result = claude.complete(prompt)
                upsert_action(unit, 'renewal_offer', result)

        elif state == 'notice':
            prompt = build_inspection_prompt(unit)
            result = claude.complete(prompt)
            upsert_action(unit, 'move_out_inspection', result)

            if unit.lease.days_to_move_out and unit.lease.days_to_move_out <= 14:
                upsert_action(unit, 'listing_prep', {
                    'description': 'Begin pre-listing prep 14 days before move-out'
                })

        # Upsert helper
        def upsert_action(unit, action_type, result):
            score = float(result.get('confidence', 0.70))
            if score < 0.60:
                return  # Don't surface low-confidence recommendations
            AIActionQueue.objects.update_or_create(
                unit=unit,
                action_type=action_type,
                defaults={
                    'description': result.get('description', ''),
                    'suggested_payload': result.get('payload', {}),
                    'generated_content': result.get('draft_content', ''),
                    'confidence_score': score,
                    'priority': score > 0.85 and 'urgent' or 'medium',
                    'lifecycle_state': unit.lease.lease_status,
                    'generation_reason': result.get('reason', ''),
                    'status': 'pending',
                    'property': unit.property,
                    'lease': unit.lease,
                }
            )

Bulk content generation (on-demand):

When the PM selects units and triggers a bulk action (e.g., "Send renewal letters to 12 units"), the system calls Claude once per unit to generate the personalized content, then presents a preview. On confirm, it sends via email/SMS.

# portfolio/services.py — BulkActionService

class BulkActionService:
    ACTION_PROMPTS = {
        'renewal_offer': (
            "Draft a personalized renewal offer letter for tenant {tenant_name} "
            "at unit {unit_number}, {property_name}. Current rent: ${rent}/mo. "
            "Offer rent: ${offer_rent}/mo for {term} months. "
            "Format as a professional business letter. Keep under 300 words. "
            "Include: gratitude for being a tenant, proposed new rate, term options, response deadline."
        ),
        'rent_increase_notice': (
            "Draft a 30-day rent increase notice for tenant {tenant_name} "
            "at unit {unit_number}. Current rent: ${current_rent}/mo. "
            "New rent: ${new_rent}/mo (increase of ${delta}). "
            "Reference market conditions, express appreciation, provide deadline to respond."
        ),
        'move_out_inspection_notice': (
            "Draft a notice scheduling a move-out inspection for "
            "unit {unit_number} on {inspection_date}. "
            "Explain the purpose (move-in/move-out comparison, deposit assessment). "
            "Request tenant presence or advance notice if they cannot attend."
        ),
    }

    def generate_bulk_content(self, action_type, units) -> dict[str, str]:
        """Generate personalized content for each unit. Called when bulk action is triggered."""
        results = {}
        for unit in units:
            prompt = self.ACTION_PROMPTS[action_type].format(
                tenant_name=unit.tenant_name,
                unit_number=unit.unit_number,
                property_name=unit.property_name,
                rent=unit.current_rent,
                offer_rent=unit.suggested_rent,
                term=12,
                current_rent=unit.current_rent,
                new_rent=unit.suggested_rent,
                delta=unit.suggested_rent - unit.current_rent,
                inspection_date=(date.today() + timedelta(days=3)).strftime('%B %d, %Y'),
            )
            result = claude.complete(prompt)
            results[str(unit.id)] = result.get('content', '')
        return results

Dependencies & Interaction with Other Features

  • F1.1 Lifecycle State Engine (required) — The command center's primary filter dimension is lifecycle state. Without F1.1, units can't be filtered by Active/MTM/Notice. F1.1 must ship first.
  • F1.3 Smart Collections Agent (recommended) — Provides the risk scoring (tenant_risk_profile) used in the Risk filter. Without F1.3, risk shows as "Unknown."
  • F1.2 Renewal Intelligence (recommended) — Shares the renewal letter generation logic. The command center reuses the same Claude prompt template from F1.2.
  • F2.1 Turnover Orchestration — When "Schedule Move-Out Inspection" is executed from the command center, it creates a turnover case in F2.1's pipeline.

Size Estimate

XL
Very Large
3+ weeks
Backend
New models, endpoints, Celery task
Frontend
New route, 6 components, modal system
AI
Nightly queue gen + on-demand bulk gen
Phase 2 — Revenue
2.1 Turnover Orchestration
Backend Frontend Celery
Why this feature matters:
When a tenant gives notice, a cascade of tasks needs to happen in a specific order: schedule a move-out inspection, conduct the inspection, calculate deposit deductions, create turnover work orders (painting, cleaning, carpet, repairs), complete the work, photograph the ready unit, create a listing, schedule showings, screen applicants, and execute a new lease. Today, this is all manual coordination — sticky notes, spreadsheets, and things falling through cracks. Units sit vacant for 3 extra days because nobody scheduled the painter, or a deposit goes unprocessed for 2 weeks because it got buried in email. The Turnover Orchestration Agent manages this entire pipeline automatically. The moment a tenant gives notice, the system creates a turnover case and starts the clock: it auto-schedules an inspection work order 2 days before move-out, estimates turnover costs based on tenant tenure and unit history, and queues up the listing description. After the inspection, it generates turnover work orders for each repair needed. The manager sees the whole pipeline as a Kanban board — Notice → Inspect → Make Ready → Listed → Leased — and just moves cards along as work completes. No more missed steps, no more "how long has that unit been sitting empty?"

Pipeline: Notice → Inspect → Make Ready → Listed → Leased. Auto-create inspection work order when notice given. Deposit deduction calculator. Kanban board UI.

B Django Models

# turnover/models.py

class TurnoverCase(models.Model):
    """Tracks a unit through the turnover pipeline."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    lease = models.OneToOneField('tenants.TenantLease', on_delete=models.CASCADE, related_name='turnover_case')
    unit = models.ForeignKey('units.UnitsUnit', on_delete=models.CASCADE, related_name='turnover_cases')
    property = models.ForeignKey('properties.Properties', on_delete=models.CASCADE, related_name='turnover_cases')

    stage = models.CharField(
        max_length=20, default='notice',
        choices=[
            ('notice', 'Notice Given'),
            ('inspect', 'Pre-Inspection'),
            ('make_ready', 'Make Ready'),
            ('listed', 'Listed'),
            ('leased', 'Leased'),
        ],
        db_index=True,
    )

    notice_date = models.DateField()
    expected_move_out = models.DateField()
    actual_move_out = models.DateField(null=True, blank=True)
    inspection_date = models.DateField(null=True, blank=True)
    make_ready_start = models.DateField(null=True, blank=True)
    make_ready_complete = models.DateField(null=True, blank=True)
    listed_date = models.DateField(null=True, blank=True)
    leased_date = models.DateField(null=True, blank=True)
    new_lease = models.ForeignKey(
        'tenants.TenantLease', on_delete=models.SET_NULL,
        null=True, blank=True, related_name='turnover_new_lease'
    )

    inspection_work_order = models.ForeignKey(
        'units.UnitsMaintenance', on_delete=models.SET_NULL,
        null=True, blank=True, related_name='turnover_inspection'
    )

    # Deposit deduction
    security_deposit = models.DecimalField(max_digits=10, decimal_places=2, default=0)
    total_deductions = models.DecimalField(max_digits=10, decimal_places=2, default=0)
    refund_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
    deduction_letter_generated = models.BooleanField(default=False)

    # Cost tracking
    make_ready_estimated_cost = models.DecimalField(max_digits=10, decimal_places=2, default=0)
    make_ready_actual_cost = models.DecimalField(max_digits=10, decimal_places=2, default=0)

    # Days tracking
    days_vacant = models.IntegerField(default=0)
    target_days_to_lease = models.IntegerField(default=21)  # Target: 21 days

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'turnover_case'
        ordering = ['-created_at']
        indexes = [
            models.Index(fields=['stage', 'property']),
            models.Index(fields=['expected_move_out']),
        ]


class TurnoverDeduction(models.Model):
    """Individual deduction from security deposit."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    case = models.ForeignKey(TurnoverCase, on_delete=models.CASCADE, related_name='deductions')
    category = models.CharField(max_length=50)
    # 'cleaning', 'paint', 'carpet', 'appliance_repair', 'wall_damage', 'other'
    description = models.CharField(max_length=200)
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    photo_url = models.CharField(max_length=500, blank=True, default='')
    ai_detected = models.BooleanField(default=False)
    approved = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'turnover_deduction'


class TurnoverChecklist(models.Model):
    """Make-ready checklist items."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    case = models.ForeignKey(TurnoverCase, on_delete=models.CASCADE, related_name='checklist_items')
    category = models.CharField(max_length=50)
    item = models.CharField(max_length=200)
    completed = models.BooleanField(default=False)
    completed_by = models.CharField(max_length=100, blank=True, default='')
    completed_at = models.DateTimeField(null=True, blank=True)
    estimated_cost = models.DecimalField(max_digits=8, decimal_places=2, default=0)
    actual_cost = models.DecimalField(max_digits=8, decimal_places=2, default=0)
    vendor = models.ForeignKey(
        'vendors.VendorsVendor', on_delete=models.SET_NULL,
        null=True, blank=True
    )
    work_order = models.ForeignKey(
        'units.UnitsMaintenance', on_delete=models.SET_NULL,
        null=True, blank=True
    )

    class Meta:
        db_table = 'turnover_checklist'
        ordering = ['category', 'item']

B API Endpoints

MethodPathDescription
GET/api/v1/turnover/List cases. Filter: stage, property.
GET/api/v1/turnover/{id}/Case detail with deductions and checklist.
POST/api/v1/turnover/{id}/advance-stage/Move to next stage. Body: {"stage": "make_ready"}
POST/api/v1/turnover/{id}/deductions/Add deduction. Body: {"category":"paint","description":"...","amount":150}
POST/api/v1/turnover/{id}/generate-deduction-letter/AI-generate deposit deduction letter.
GET/api/v1/turnover/kanban/Cases grouped by stage for Kanban view.
GET/api/v1/turnover/dashboard-stats/Pipeline stats: count per stage, avg days, cost.

C Celery Tasks

turnover.tasks.auto_create_turnover_case

Trigger: Django signal when TenantLease.lease_status changes to 'notice'.

  1. Create TurnoverCase with stage='notice'.
  2. Auto-create inspection UnitsMaintenance work order.
  3. Generate default make-ready checklist (painting, carpet, cleaning, appliance check, HVAC filter, lock change).

F Screen Wireframes

┌────────────────────────────────────────────────────────────────────────────┐ Turnover Pipeline Avg 18 days | 12 active ├──────────────┬──────────────┬──────────────┬──────────────┬─────────────────┤ NOTICE (3) INSPECT (2) MAKE READY(4) LISTED (2) LEASED (1) ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ A-102 B-205 C-314 D-401 E-110 Apr 15 Mar 30 3/6 done $2,100/mo Signed Apr 1 $1,800 dep WO-3847 $2,400 est 14 days 21 days total └──────────┘ └──────────┘ └──────────┘ └──────────┘ └─────────────┘ └──────────────┴──────────────┴──────────────┴──────────────┴─────────────────┘

Routes: /turnover (Kanban board), /turnover/:caseId (case detail with checklist, deductions, timeline).

L Business Logic

Deposit Deduction Calculation

refund_amount = security_deposit - sum(approved_deductions)
# If refund_amount < 0, tenant owes the difference
# Deduction letter must be sent within 30 days of move-out (legal requirement)

Default Make-Ready Checklist

CategoryItemEst. Cost
CleaningDeep clean entire unit$250
PaintTouch-up / full repaint walls$400
FlooringCarpet clean or replacement$300
AppliancesTest and clean all appliances$100
HVACReplace filters, test system$75
SecurityRe-key locks$85
PlumbingCheck faucets, toilets, drains$50
ElectricalTest outlets, switches, smoke detectors$50

Target: 21 days from notice to leased. If > 30 days, flag as delayed.

☑ Acceptance Checklist — 2.1 Turnover Orchestration

2.2 Dynamic Pricing Engine
Backend Frontend Celery
Why this feature matters:
Most property managers set rent once and forget about it until renewal. They might check comps once a year or adjust based on gut feel. Meanwhile, the market is moving — seasonal demand shifts, competing properties adjust prices, and the portfolio slowly drifts below market rate. We found that across our 23 properties, the aggregate rent-to-market gap is over $114,000 per year in lost revenue. Some units are $75/month below market, and nobody knows because there's no systematic way to track it. The Revenue Optimization Engine fixes this by treating rent like a hotel treats room rates — dynamically, based on data. For vacant units, it adjusts the asking rent daily based on days on market, seasonal demand, and comparable listings. If a unit has been listed for 21 days above market average, it recommends a price drop and calculates the net impact ("reduce by $50, save 6 vacancy days, net gain $197"). For occupied units, it continuously tracks rent vs. market and surfaces opportunities: "87 month-to-month tenants are below market — potential recovery $9,570/month." The property manager opens the Revenue Intelligence Dashboard and sees exactly where money is being left on the table.

Daily pricing engine for vacant units. Market rent estimation from comparable units. Rent-to-market gap analysis across the full portfolio. Revenue opportunity dashboard.

MODIFY EXISTING + NEW SCREEN: Adds a "Market Intelligence" card to the existing unit detail Overview tab (unit-detail.component.html) showing current vs market rent with gap analysis. The Revenue Intelligence Dashboard is a NEW screen at /pricing.

B Django Models

# pricing/models.py

class PricingSnapshot(models.Model):
    """Daily pricing snapshot for a unit."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    unit = models.ForeignKey('units.UnitsUnit', on_delete=models.CASCADE, related_name='pricing_snapshots')
    property = models.ForeignKey('properties.Properties', on_delete=models.CASCADE, related_name='pricing_snapshots')
    snapshot_date = models.DateField(db_index=True)

    current_rent = models.DecimalField(max_digits=10, decimal_places=2, null=True)
    market_rent = models.DecimalField(max_digits=10, decimal_places=2)
    recommended_rent = models.DecimalField(max_digits=10, decimal_places=2)
    rent_per_sqft = models.DecimalField(max_digits=6, decimal_places=2, null=True)
    market_rent_per_sqft = models.DecimalField(max_digits=6, decimal_places=2, null=True)

    # Gap analysis
    rent_gap_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
    rent_gap_pct = models.FloatField(default=0)  # negative = below market

    # Factors
    days_vacant = models.IntegerField(default=0)
    seasonal_factor = models.FloatField(default=1.0)  # 0.95 to 1.05
    demand_score = models.FloatField(default=0.5)     # 0.0 to 1.0
    comp_count = models.IntegerField(default=0)       # number of comps used

    # Confidence
    confidence_score = models.FloatField(default=0.5)
    pricing_strategy = models.CharField(max_length=30, default='market')
    # 'aggressive', 'market', 'conservative', 'lease_up', 'premium'

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'pricing_snapshot'
        unique_together = [['unit', 'snapshot_date']]
        ordering = ['-snapshot_date']
        indexes = [
            models.Index(fields=['property', 'snapshot_date']),
        ]


class MarketComp(models.Model):
    """Comparable unit/listing used for pricing."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    snapshot = models.ForeignKey(PricingSnapshot, on_delete=models.CASCADE, related_name='comps')
    source = models.CharField(max_length=50)  # 'internal', 'zillow', 'apartments_com'
    address = models.CharField(max_length=300)
    bedrooms = models.IntegerField()
    bathrooms = models.DecimalField(max_digits=3, decimal_places=1)
    square_feet = models.IntegerField(null=True)
    rent = models.DecimalField(max_digits=10, decimal_places=2)
    distance_miles = models.FloatField(null=True)
    similarity_score = models.FloatField()  # 0.0 to 1.0

    class Meta:
        db_table = 'pricing_market_comp'

B API Endpoints

MethodPathDescription
GET/api/v1/pricing/snapshots/Latest snapshot per unit. Filter: property, bedrooms, is_vacant.
GET/api/v1/pricing/units/{unit_id}/history/Pricing history for a unit (last 90 days).
GET/api/v1/pricing/revenue-opportunity/Portfolio-wide gap analysis.
POST/api/v1/pricing/run-engine/Manually trigger pricing engine for a property.

GET /api/v1/pricing/revenue-opportunity/

// Response 200:
{
  "total_units_analyzed": 4074,
  "units_below_market": 2847,
  "units_at_market": 892,
  "units_above_market": 335,
  "total_monthly_gap": 127450.00,
  "annual_revenue_opportunity": 1529400.00,
  "avg_gap_pct": -6.2,
  "by_property": [
    {
      "property_id": "uuid",
      "property_name": "Westfield Gardens",
      "units_below": 312,
      "monthly_gap": 24800.00,
      "avg_gap_pct": -7.1
    }
  ]
}

C Celery Tasks

pricing.tasks.daily_pricing_engine

Schedule: Daily at 04:00 AM UTC

def compute_market_rent(unit):
    """
    Algorithm:
    1. Find comparable units (same property, same bed/bath, +/- 100 sqft)
    2. Find internal comps from occupied units with recent lease dates
    3. Calculate weighted average rent from comps
    4. Apply seasonal adjustment factor
    5. Apply vacancy duration discount
    """
    # Internal comps: same property, same bed/bath, occupied
    internal = UnitsUnit.objects.filter(
        property=unit.property,
        bedrooms=unit.bedrooms,
        bathrooms=unit.bathrooms,
        status='occupied',
        square_feet__gte=(unit.square_feet or 0) - 100,
        square_feet__lte=(unit.square_feet or 9999) + 100,
    ).exclude(id=unit.id)

    if internal.count() >= 3:
        avg_rent = internal.aggregate(avg=Avg('current_rent'))['avg']
        avg_sqft = internal.aggregate(avg=Avg('square_feet'))['avg']
        # Adjust for size difference
        if avg_sqft and unit.square_feet:
            size_adj = (unit.square_feet / avg_sqft)
            market_rent = avg_rent * Decimal(str(size_adj))
        else:
            market_rent = avg_rent
    else:
        market_rent = unit.market_rent or unit.current_rent or Decimal('0')

    # Seasonal adjustment (summer premium, winter discount)
    month = date.today().month
    seasonal = {1:0.97, 2:0.97, 3:0.99, 4:1.01, 5:1.03, 6:1.05,
                7:1.05, 8:1.04, 9:1.02, 10:1.00, 11:0.98, 12:0.96}
    market_rent *= Decimal(str(seasonal.get(month, 1.0)))

    # Vacancy duration discount
    # If vacant > 14 days, reduce by 0.5% per week (max 5%)
    days_vacant = compute_days_vacant(unit)
    if days_vacant > 14:
        weeks_over = (days_vacant - 14) / 7
        discount = min(weeks_over * 0.005, 0.05)
        market_rent *= Decimal(str(1 - discount))

    return round(market_rent, 2)

F Screen Wireframes

┌────────────────────────────────────────────────────────────────────────────┐ Dynamic Pricing Annual Opportunity: $1,529,400 ├────────────────────────────────────────────────────────────────────────────┤ Below Market At Market Above Market 2,847 units 892 units 335 units [-$127,450/mo] ████████████████████████████████████████████ ├────────────────────────────────────────────────────────────────────────────┤ Unit Bed/Bath SqFt Current Market Recommended Gap A-102 2/1 850 $1,850 $2,050 $2,050 -9.8% B-205 1/1 650 $1,400 $1,380 $1,400 +1.4% C-314 3/2 1100 $2,600 $2,750 $2,725 -5.5% └────────────────────────────────────────────────────────────────────────────┘

Routes: /pricing (portfolio dashboard), /pricing/:unitId (unit pricing history with chart).

☑ Acceptance Checklist — 2.2 Dynamic Pricing

2.3 Predictive Maintenance
Backend Frontend AI Celery
Why this feature matters:
Maintenance today is 100% reactive — something breaks, the tenant calls, we dispatch a vendor. Emergency repairs cost 3–5x more than scheduled maintenance, they create tenant frustration (especially when the AC dies in August), and they generate negative reviews. But the data to predict failures already exists in our system: we know how old the equipment is, how often each unit has had HVAC/plumbing/electrical issues, which season failures spike, and whether similar units in the same building have been failing (a water heater failure in 3 units of Building B01 means the remaining units probably have the same aging equipment). The Predictive Maintenance Agent analyzes all of this and generates risk scores for every unit. A score of 92% means "this HVAC system will very likely fail in the next 2 weeks based on age, history, and seasonal patterns." The manager sees these predictions on the unit's Maintenance tab and on a portfolio-wide heatmap dashboard. One click creates a preventive work order. Instead of getting a panicked call from a tenant on a 95-degree day, the manager scheduled the HVAC service 3 weeks ago.

Analyze maintenance history to predict failures. Risk scoring per unit. Auto-suggest preventive work orders. Risk heatmap dashboard. Note: The existing predictive_maintenance Django app with Equipment and MaintenancePrediction models already provides the foundation. This spec extends it.

MODIFY EXISTING + EXTEND: Adds an "AI Risk Assessment" card to the existing Maintenance tab in unit-detail.component.html (at the top of the tab content). Also extends the existing predictive-maintenance-list.component.ts with a new risk heatmap tab. Backend adds new models to the existing predictive_maintenance Django app.

B New Models (extend existing app)

# predictive_maintenance/models.py — ADD to existing file

class UnitRiskScore(models.Model):
    """Aggregated risk score per unit based on maintenance history."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    unit = models.ForeignKey('units.UnitsUnit', on_delete=models.CASCADE, related_name='risk_scores')
    property = models.ForeignKey('properties.Properties', on_delete=models.CASCADE, related_name='unit_risk_scores')
    score_date = models.DateField(db_index=True)

    risk_score = models.FloatField()  # 0-100
    risk_level = models.CharField(max_length=20)
    # 'low' (0-25), 'medium' (26-50), 'high' (51-75), 'critical' (76-100)

    # Factor breakdown
    wo_frequency_score = models.FloatField(default=0)    # Work order frequency
    wo_cost_score = models.FloatField(default=0)         # Cost trend
    equipment_age_score = models.FloatField(default=0)   # Equipment lifespan
    repeat_issue_score = models.FloatField(default=0)    # Same issue recurring
    seasonal_score = models.FloatField(default=0)        # Seasonal patterns

    # Predictions
    predicted_next_wo_date = models.DateField(null=True)
    predicted_next_wo_type = models.CharField(max_length=100, blank=True, default='')
    predicted_cost_30_days = models.DecimalField(max_digits=10, decimal_places=2, default=0)
    predicted_cost_90_days = models.DecimalField(max_digits=10, decimal_places=2, default=0)

    # Stats
    wo_count_12_months = models.IntegerField(default=0)
    total_cost_12_months = models.DecimalField(max_digits=10, decimal_places=2, default=0)
    avg_days_between_wo = models.IntegerField(default=0)

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'unit_risk_score'
        unique_together = [['unit', 'score_date']]
        indexes = [
            models.Index(fields=['property', 'score_date', 'risk_level']),
        ]


class PreventiveWorkOrderSuggestion(models.Model):
    """AI-generated suggestion for preventive maintenance."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    unit = models.ForeignKey('units.UnitsUnit', on_delete=models.CASCADE, related_name='preventive_suggestions')
    risk_score = models.ForeignKey(UnitRiskScore, on_delete=models.CASCADE, related_name='suggestions')

    title = models.CharField(max_length=200)
    description = models.TextField()
    priority = models.CharField(max_length=20)  # low, medium, high, urgent
    category = models.CharField(max_length=50)  # plumbing, electrical, hvac, etc.
    estimated_cost = models.DecimalField(max_digits=10, decimal_places=2)
    reasoning = models.TextField()
    suggested_date = models.DateField()

    status = models.CharField(max_length=20, default='suggested')
    # 'suggested', 'approved', 'work_order_created', 'dismissed'
    work_order = models.ForeignKey(
        'units.UnitsMaintenance', on_delete=models.SET_NULL,
        null=True, blank=True
    )
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'preventive_wo_suggestion'

B API Endpoints

MethodPathDescription
GET/api/v1/predictive-maintenance/risk-scores/Latest risk score per unit. Filter: property, risk_level.
GET/api/v1/predictive-maintenance/risk-heatmap/Property-level heatmap data (building → floor → unit grid).
GET/api/v1/predictive-maintenance/suggestions/Pending preventive WO suggestions.
POST/api/v1/predictive-maintenance/suggestions/{id}/approve/Approve and create actual work order.

L Risk Scoring Algorithm

def compute_unit_risk_score(unit):
    """Returns 0-100 risk score."""
    # Factor 1: Work Order Frequency (weight: 0.30)
    wo_12mo = UnitsMaintenance.objects.filter(unit=unit, requested_date__gte=year_ago).count()
    wo_freq = min(wo_12mo / 8, 1.0)  # 8+ work orders in 12mo = max

    # Factor 2: Cost Trend (weight: 0.20)
    cost_6mo = get_total_cost(unit, months=6)
    cost_prev_6mo = get_total_cost(unit, months=12) - cost_6mo
    if cost_prev_6mo > 0:
        cost_trend = min(float(cost_6mo / cost_prev_6mo) - 1, 1.0)
    else:
        cost_trend = 0
    cost_trend = max(0, cost_trend)

    # Factor 3: Equipment Age (weight: 0.20)
    equipment = Equipment.objects.filter(property_ref=unit.property)
    if equipment.exists():
        avg_lifespan_pct = equipment.aggregate(
            avg=Avg(F('age_years') * 100 / F('expected_lifespan_years'))
        )['avg'] or 0
        equip_age = min(avg_lifespan_pct / 100, 1.0)
    else:
        equip_age = 0.3  # default

    # Factor 4: Repeat Issues (weight: 0.20)
    recent_wos = UnitsMaintenance.objects.filter(unit=unit, requested_date__gte=year_ago)
    titles = [wo.title.lower() for wo in recent_wos]
    # Check for repeated keywords
    from collections import Counter
    words = [w for t in titles for w in t.split() if len(w) > 4]
    repeats = sum(1 for _, c in Counter(words).items() if c >= 3)
    repeat_score = min(repeats / 3, 1.0)

    # Factor 5: Seasonal (weight: 0.10)
    month = date.today().month
    # HVAC risk higher in summer/winter
    seasonal = {1:0.8, 2:0.7, 3:0.4, 4:0.3, 5:0.4, 6:0.7,
                7:0.9, 8:0.9, 9:0.5, 10:0.3, 11:0.5, 12:0.7}

    score = (
        wo_freq * 30 +
        cost_trend * 20 +
        equip_age * 20 +
        repeat_score * 20 +
        seasonal.get(month, 0.5) * 10
    )
    return round(min(score, 100), 1)

Risk Level Thresholds

  • Low (0-25): No action needed
  • Medium (26-50): Monitor, schedule routine inspection
  • High (51-75): Generate preventive WO suggestion
  • Critical (76-100): Urgent preventive WO, notify property manager

F Screen Wireframes

┌────────────────────────────────────────────────────────────────────────────┐ Predictive Maintenance ├──────────────┬──────────────┬──────────────┬───────────────────────────────────┤ Critical: 12 High: 47 Medium: 198 Low: 3,817 ├──────────────┴──────────────┴──────────────┴───────────────────────────────────┤ RISK HEATMAP — Westfield Gardens Floor 4 [401] [402] [403] [404] [405] [406] [407] [408] Floor 3 [301] [302] [303] [304] [305] [306] [307] [308] Floor 2 [201] [202] [203] [204] [205] [206] [207] [208] Floor 1 [101] [102] [103] [104] [105] [106] [107] [108] ■ Low ■ Medium ■ High/Critical └──────────────────────────────────────────────────────────────────────────────┘

Routes: /predictive-maintenance (existing, extend with heatmap), /predictive-maintenance/risk-heatmap.

☑ Acceptance Checklist — 2.3 Predictive Maintenance

Phase 3 — Intelligence
3.1 Move-Out Photo AI
Backend Frontend AI
Why this feature matters:
The move-out inspection is one of the most contentious moments in the tenant relationship. The maintenance tech walks through the unit, takes photos, and then someone has to look at those photos, decide what's normal wear vs. tenant damage, estimate repair costs, and write up a deposit deduction letter. This is subjective, inconsistent, and often leads to disputes. One manager might charge $340 for a carpet stain, another might call it normal wear. Tenants feel cheated, managers waste hours on back-and-forth. The Photo Inspection AI removes the guesswork. The tech uploads inspection photos and the AI (powered by Claude's vision capabilities) analyzes each one: "Carpet stain, 12\"×8\", bedroom — classified as tenant damage, estimated replacement cost $340. Wall damage, 2 nail holes, living room — estimated patch and paint $125. Kitchen counter scratches — classified as normal wear, no charge." The manager reviews each AI finding, adjusts if needed, and clicks "Generate Letter" to produce a professional, itemized deposit deduction letter with photo evidence attached. Fair, consistent, defensible, and takes 10 minutes instead of an hour.

Upload move-out inspection photos. Claude Vision analyzes each photo to detect damage vs. normal wear. Estimates repair costs. Generates deposit deduction letter. Photo grid with AI annotations.

B Django Models

# inspections/models.py

class MoveOutInspection(models.Model):
    """Move-out inspection session."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    turnover_case = models.OneToOneField(
        'turnover.TurnoverCase', on_delete=models.CASCADE,
        related_name='inspection', null=True, blank=True
    )
    unit = models.ForeignKey('units.UnitsUnit', on_delete=models.CASCADE, related_name='inspections')
    lease = models.ForeignKey('tenants.TenantLease', on_delete=models.CASCADE, related_name='inspections')
    inspector_name = models.CharField(max_length=100)
    inspection_date = models.DateField()

    status = models.CharField(max_length=20, default='in_progress')
    # 'in_progress', 'ai_processing', 'review', 'finalized'

    total_damage_cost = models.DecimalField(max_digits=10, decimal_places=2, default=0)
    total_wear_items = models.IntegerField(default=0)
    total_damage_items = models.IntegerField(default=0)

    deduction_letter = models.TextField(blank=True, default='')
    deduction_letter_generated_at = models.DateTimeField(null=True, blank=True)

    ai_processing_started_at = models.DateTimeField(null=True, blank=True)
    ai_processing_completed_at = models.DateTimeField(null=True, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'move_out_inspection'


class InspectionPhoto(models.Model):
    """Individual photo in a move-out inspection."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    inspection = models.ForeignKey(MoveOutInspection, on_delete=models.CASCADE, related_name='photos')

    room = models.CharField(max_length=50)
    # 'living_room', 'kitchen', 'bedroom_1', 'bedroom_2', 'bathroom_1', 'bathroom_2', 'hallway', 'exterior'
    photo_url = models.CharField(max_length=500)
    thumbnail_url = models.CharField(max_length=500, blank=True, default='')
    photo_order = models.IntegerField(default=0)

    # AI Analysis Results
    ai_analyzed = models.BooleanField(default=False)
    ai_classification = models.CharField(max_length=20, default='pending')
    # 'normal_wear', 'damage', 'clean', 'needs_review'
    ai_confidence = models.FloatField(null=True)
    ai_description = models.TextField(blank=True, default='')
    ai_damage_items = models.JSONField(default=list)
    # [{"item": "Wall hole near outlet", "severity": "moderate", "estimated_cost": 75.00, "category": "wall_damage"}]

    # Human review
    human_classification = models.CharField(max_length=20, blank=True, default='')
    human_override = models.BooleanField(default=False)
    reviewed_by = models.CharField(max_length=100, blank=True, default='')
    reviewed_at = models.DateTimeField(null=True, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'inspection_photo'
        ordering = ['room', 'photo_order']

B API Endpoints

MethodPathDescription
POST/api/v1/inspections/Create new inspection session.
GET/api/v1/inspections/{id}/Inspection detail with photos.
POST/api/v1/inspections/{id}/upload-photos/Upload photos (multipart). Returns photo IDs.
POST/api/v1/inspections/{id}/analyze/Trigger Claude Vision analysis on all photos.
PATCH/api/v1/inspections/photos/{photoId}/Override AI classification.
POST/api/v1/inspections/{id}/generate-letter/Generate deposit deduction letter from analysis results.
POST/api/v1/inspections/{id}/finalize/Lock inspection and apply deductions to turnover case.

A Claude Vision Integration

# inspections/services/photo_analyzer.py

import anthropic

SYSTEM_PROMPT = """You are a property inspection AI. Analyze this photo from a
move-out inspection. Classify each issue as:
- "normal_wear": Expected wear and tear for the tenancy duration
- "damage": Tenant-caused damage beyond normal wear

For each damage item found, provide:
1. Description of the damage
2. Severity: minor, moderate, severe
3. Estimated repair cost in USD
4. Category: wall_damage, floor_damage, fixture_damage, appliance_damage,
   cleaning_needed, other

Return JSON format:
{
  "classification": "damage" | "normal_wear" | "clean",
  "confidence": 0.0-1.0,
  "description": "Overall description of photo",
  "damage_items": [
    {
      "item": "Description",
      "severity": "minor|moderate|severe",
      "estimated_cost": 0.00,
      "category": "category_name"
    }
  ]
}"""

def analyze_photo(photo_url: str) -> dict:
    client = anthropic.Anthropic()
    message = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=1024,
        messages=[{
            "role": "user",
            "content": [
                {"type": "image", "source": {"type": "url", "url": photo_url}},
                {"type": "text", "text": "Analyze this move-out inspection photo."}
            ]
        }],
        system=SYSTEM_PROMPT,
    )
    return json.loads(message.content[0].text)

Cost Estimation Reference

CategoryMinorModerateSevere
Wall damage (holes, dents)$25-75$75-200$200-500
Floor damage (scratches, stains)$50-150$150-400$400-1200
Fixture damage (broken, missing)$30-100$100-300$300-800
Appliance damage$50-150$150-500$500-2000
Cleaning (beyond normal)$100-200$200-400$400-800

F Screen Wireframes

┌────────────────────────────────────────────────────────────────────────────┐ Move-Out Inspection — Unit A-102 Inspector: J. Smith 03/28/2026 ├────────────────────────────────────────────────────────────────────────────┤ Clean: 4 | Normal Wear: 6 | Damage: 3 | Total Damage: $475 ├────────────────────────────────────────────────────────────────────────────┤ Living Room ┌──────────┐ ┌──────────┐ ┌──────────┐ CLEAN │ │ DAMAGE │ │ WEAR │ [photo] │ │ [photo] │ │ [photo] │ │ 98% conf │ │ 91% conf │ │ 85% conf │ └──────────┘ └──────────┘ └──────────┘ Wall hole near outlet Moderate — $75 est. [Override ▾] ├────────────────────────────────────────────────────────────────────────────┤ [Upload More Photos] [Generate Deduction Letter] └────────────────────────────────────────────────────────────────────────────┘

Routes: /inspections/new (create), /inspections/:id (review grid), /inspections/:id/letter (deduction letter preview).

☑ Acceptance Checklist — 3.1 Move-Out Photo AI

3.2 Invoice Processing (AI)
Backend Frontend AI
Why this feature matters:
Property management generates hundreds of vendor invoices per month — plumbers, electricians, painters, landscapers, HVAC techs, cleaners. Each invoice arrives in a different format (PDF, email, photo of paper), needs to be matched to the right work order, coded to the correct expense category, checked for reasonable pricing, and approved. Today this is manual data entry: someone opens the PDF, types the vendor name, date, line items, and amounts into the system, looks up the matching work order, assigns a GL code, and clicks approve. It takes 5–10 minutes per invoice and errors are common. The Invoice Processing AI does all of this automatically. Upload a vendor invoice (PDF, photo, or email attachment) and within seconds the AI extracts every field: vendor name, invoice number, date, line items with descriptions and amounts, tax, total. It auto-matches the invoice to the most likely open work order based on vendor, unit, description, and date. It validates pricing against historical averages ("this plumber is charging 12% above your typical rate for similar work — flag for review?"). And it auto-assigns the GL expense code. The manager sees a side-by-side view — original document on the left, extracted data on the right — and just clicks Approve, Flag, or Reject.

Upload vendor invoice PDF. AI extracts all fields. Matches to work order. Validates pricing against historical data. Auto-assigns GL codes. Side-by-side review UI. Note: The existing Invoices model in invoices/models.py already handles most storage. This spec adds AI extraction and WO matching.

EXTEND EXISTING: Adds new InvoiceAIExtraction model to existing invoices/models.py. Frontend extends the existing invoice-processing.component.ts at route /invoices/processing/:id with AI extraction side-panel. Does NOT create a new invoice page.

B New Models (extend existing invoices app)

# invoices/models.py — ADD to existing

class InvoiceAIExtraction(models.Model):
    """AI extraction results for an invoice."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    invoice = models.OneToOneField(Invoices, on_delete=models.CASCADE, related_name='ai_extraction')

    # Extracted fields
    extracted_vendor_name = models.CharField(max_length=200, blank=True, default='')
    extracted_invoice_number = models.CharField(max_length=100, blank=True, default='')
    extracted_date = models.DateField(null=True, blank=True)
    extracted_due_date = models.DateField(null=True, blank=True)
    extracted_total = models.DecimalField(max_digits=10, decimal_places=2, null=True)
    extracted_tax = models.DecimalField(max_digits=10, decimal_places=2, null=True)
    extracted_subtotal = models.DecimalField(max_digits=10, decimal_places=2, null=True)
    extracted_line_items = models.JSONField(default=list)
    # [{"description": "...", "quantity": 1, "unit_price": 150.00, "total": 150.00}]

    # Work order matching
    matched_work_order = models.ForeignKey(
        'units.UnitsMaintenance', on_delete=models.SET_NULL,
        null=True, blank=True, related_name='matched_invoices'
    )
    wo_match_confidence = models.FloatField(null=True)
    wo_match_reasoning = models.TextField(blank=True, default='')

    # GL coding
    suggested_gl_code = models.CharField(max_length=50, blank=True, default='')
    gl_confidence = models.FloatField(null=True)

    # Price validation
    price_validated = models.BooleanField(default=False)
    price_anomaly_detected = models.BooleanField(default=False)
    price_anomaly_details = models.TextField(blank=True, default='')
    historical_avg_price = models.DecimalField(max_digits=10, decimal_places=2, null=True)

    # Overall
    extraction_confidence = models.FloatField(null=True)
    extraction_model = models.CharField(max_length=50, default='claude-sonnet-4-20250514')
    processing_time_ms = models.IntegerField(null=True)
    raw_response = models.JSONField(default=dict)

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'invoice_ai_extraction'

B API Endpoints

MethodPathDescription
POST/api/v1/invoices/upload/Upload invoice PDF. Returns invoice ID. Triggers async AI extraction.
GET/api/v1/invoices/{id}/extraction/Get AI extraction results. Poll until status='extracted'.
POST/api/v1/invoices/{id}/approve-extraction/Approve AI extraction (with optional field overrides).
POST/api/v1/invoices/{id}/match-work-order/Manually match to a work order.
GET/api/v1/invoices/processing-queue/All invoices in processing pipeline.

A Claude API — Invoice Extraction

INVOICE_EXTRACTION_PROMPT = """Extract all fields from this vendor invoice PDF.

Return JSON:
{
  "vendor_name": "string",
  "invoice_number": "string",
  "invoice_date": "YYYY-MM-DD",
  "due_date": "YYYY-MM-DD",
  "subtotal": 0.00,
  "tax": 0.00,
  "total": 0.00,
  "line_items": [
    {"description": "string", "quantity": 1, "unit_price": 0.00, "total": 0.00}
  ],
  "vendor_address": "string",
  "remit_to": "string",
  "po_number": "string or null",
  "work_order_reference": "string or null",
  "payment_terms": "string"
}

Be precise with amounts. If a field is not found, use null."""

Work Order Matching Logic

def match_invoice_to_wo(extraction):
    """Match extracted invoice to a work order."""
    candidates = []

    # Strategy 1: Explicit WO reference in invoice
    if extraction.get('work_order_reference'):
        wo = find_wo_by_ref(extraction['work_order_reference'])
        if wo:
            return wo, 0.95, "Direct WO reference found on invoice"

    # Strategy 2: Vendor + date range + amount proximity
    vendor = match_vendor(extraction['vendor_name'])
    if vendor:
        wos = UnitsMaintenance.objects.filter(
            assigned_vendor=vendor,
            status__in=['completed', 'in_progress'],
            requested_date__gte=extraction['invoice_date'] - timedelta(days=90),
        )
        for wo in wos:
            score = compute_wo_similarity(wo, extraction)
            candidates.append((wo, score))

    # Strategy 3: Line item description matching
    # ...

    if candidates:
        best = max(candidates, key=lambda x: x[1])
        if best[1] > 0.6:
            return best[0], best[1], f"Matched by vendor + date + amount"

    return None, 0.0, "No match found"

F Screen Wireframes

┌────────────────────────────────────────────────────────────────────────────┐ Invoice Review — INV-2026-0847 Confidence: 94% ├──────────────────────────────┬─────────────────────────────────────────────┤ [PDF Preview] Extracted Fields ┌────────────────────┐ Vendor: ABC Plumbing LLC ✓ Matched │ │ Invoice #: INV-2026-0847 │ PDF rendered │ Date: 2026-03-15 │ with highlights │ Due: 2026-04-15 │ on extracted │ Total: $2,847.50 │ fields │ │ │ Work Order Match │ │ WO-3847A4E2 — 87% match │ │ "Bathroom leak repair, Unit B-205" │ │ │ │ GL Code: 6200 — Repairs & Maintenance │ │ └────────────────────┘ Price Validation Historical avg: $2,340 +21.7% above avg [Reject] [Edit Fields] [Approve] └──────────────────────────────┴─────────────────────────────────────────────┘

Routes: /invoices (existing list, add processing queue tab), /invoices/processing/:id (side-by-side review).

☑ Acceptance Checklist — 3.2 Invoice Processing

3.3 Portfolio Intelligence Chat
Backend Frontend AI
Why this feature matters:
Property managers spend hours digging through dashboards, exporting spreadsheets, filtering tables, and cross-referencing data to answer basic questions: "Which properties have the most delinquent tenants?" "What's my projected vacancy rate for Q3?" "Show me all leases expiring next month at Ashford Farms with below-market rent." Today, answering any of these requires navigating to 3 different screens, applying filters, and mentally connecting dots. The Portfolio Intelligence Chat puts an AI analyst in the manager's pocket. Type a question in natural language, and the AI queries the entire portfolio database, formats the answer with inline tables and charts, and suggests actionable next steps. "Which properties have the most revenue leakage?" yields a ranked table with dollar amounts and a button to generate rent increase notices for all MTM tenants. The AI also generates a daily intelligence digest — a morning briefing that surfaces urgent items (3 tenants 5+ days late), revenue opportunities (12 renewals due, 87 MTM units below market), and portfolio health metrics. No more spreadsheet diving. Ask the question, get the answer.

Natural language queries against the entire portfolio via Claude with tool use. Streaming responses. Inline data tables and action buttons. Daily digest generation.

B Django Models

# chat/models.py

class ChatConversation(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    user_email = models.CharField(max_length=254)
    title = models.CharField(max_length=200, blank=True, default='')
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'chat_conversation'
        ordering = ['-updated_at']


class ChatMessage(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    conversation = models.ForeignKey(ChatConversation, on_delete=models.CASCADE, related_name='messages')
    role = models.CharField(max_length=20)  # 'user', 'assistant', 'system'
    content = models.TextField()
    tool_calls = models.JSONField(default=list, blank=True)
    tool_results = models.JSONField(default=list, blank=True)
    tokens_used = models.IntegerField(default=0)
    model = models.CharField(max_length=50, default='claude-sonnet-4-20250514')
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'chat_message'
        ordering = ['created_at']


class DailyDigest(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    digest_date = models.DateField(unique=True)
    content = models.TextField()
    highlights = models.JSONField(default=list)
    # [{"type": "alert|info|success", "text": "3 leases expiring this week"}]
    metrics = models.JSONField(default=dict)
    generated_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'daily_digest'

B API Endpoints

MethodPathDescription
POST/api/v1/chat/conversations/Create new conversation.
GET/api/v1/chat/conversations/List conversations.
POST/api/v1/chat/conversations/{id}/messages/Send message. Returns streaming SSE response.
GET/api/v1/chat/conversations/{id}/messages/Message history.
GET/api/v1/chat/daily-digest/Today's digest (or specific date).

A Claude Tool Use — Available Tools

TOOLS = [
    {
        "name": "query_leases",
        "description": "Query lease data. Can filter by status, property, date range.",
        "input_schema": {
            "type": "object",
            "properties": {
                "property_name": {"type": "string", "description": "Filter by property name"},
                "lease_status": {"type": "string", "enum": ["active","month_to_month","notice","vacant"]},
                "expiring_within_days": {"type": "integer"},
                "limit": {"type": "integer", "default": 20}
            }
        }
    },
    {
        "name": "query_financials",
        "description": "Query financial data: rent roll, collections, revenue.",
        "input_schema": {
            "type": "object",
            "properties": {
                "metric": {"type": "string", "enum": [
                    "total_rent_roll", "collection_rate", "delinquency",
                    "revenue_by_property", "rent_variance"
                ]},
                "property_name": {"type": "string"},
                "date_range": {"type": "string"}
            }
        }
    },
    {
        "name": "query_maintenance",
        "description": "Query maintenance/work order data.",
        "input_schema": {
            "type": "object",
            "properties": {
                "status": {"type": "string", "enum": ["open","in_progress","completed"]},
                "property_name": {"type": "string"},
                "priority": {"type": "string", "enum": ["low","medium","high","urgent"]},
                "limit": {"type": "integer", "default": 20}
            }
        }
    },
    {
        "name": "query_vacancy",
        "description": "Get vacancy information across portfolio.",
        "input_schema": {
            "type": "object",
            "properties": {
                "property_name": {"type": "string"},
                "include_pricing": {"type": "boolean", "default": False}
            }
        }
    },
    {
        "name": "create_action",
        "description": "Create an action item (work order, reminder, etc).",
        "input_schema": {
            "type": "object",
            "properties": {
                "action_type": {"type": "string", "enum": ["work_order","reminder","follow_up"]},
                "title": {"type": "string"},
                "description": {"type": "string"},
                "unit_id": {"type": "string"},
                "priority": {"type": "string"}
            },
            "required": ["action_type", "title"]
        }
    }
]

Example Queries

  • "How many leases are expiring in the next 30 days?"
  • "What's the current vacancy rate at Westfield Gardens?"
  • "Show me all overdue balances over $2,000"
  • "Which units have had the most maintenance requests this year?"
  • "Generate a summary of this week's collection activity"

F Screen Wireframes

┌────────────────────────────────────────────────────────────────────────────┐ Portfolio Intelligence ├───────────────────────┬────────────────────────────────────────────────────┤ Conversations You: How many leases expire next month? Today's Digest Vacancy Analysis AI: There are 47 leases expiring in April 2026: Collection Status ┌────────────────────────────────────────────┐ Property Count Avg Rent Risk Westfield Gardens 18 $1,840 Med Oakridge Apts 12 $2,100 Low Harbor View 17 $1,650 High └────────────────────────────────────────────┘ [View All Expiring] [Generate Renewal Batch] ┌────────────────────────────────────────────┐ Ask anything about your portfolio... └────────────────────────────────────────────┘ └───────────────────────┴────────────────────────────────────────────────────┘

Routes: /chat (main chat with sidebar), /chat/:conversationId (specific conversation).

☑ Acceptance Checklist — 3.3 Portfolio Intelligence Chat

Phase 4 — Delight
4.1 Virtual Staging
Backend Frontend AI
Why this feature matters:
Professional staging costs $500–2,000 per unit, takes days to coordinate, and needs to be done for every vacant unit. Most properties don't bother — they list with photos of empty rooms, which get 80% fewer inquiries than staged photos. The result: longer vacancy, lost revenue. AI Virtual Staging eliminates this entirely. Upload photos of the empty unit, select a style (Modern, Scandinavian, Traditional, Mid-Century), and the AI generates photorealistic staged versions in minutes — complete with furniture, decor, and lighting. The manager sees a before/after slider comparison and can publish directly. But it goes further than photos: the AI also auto-writes the listing description based on unit features, amenities, and neighborhood highlights. "Bright and spacious 1BR with updated kitchen, in-unit washer/dryer, and access to community pool. Minutes from I-465." Professional listings, zero staging cost, minimal effort.

Upload empty room photos. AI generates virtually staged versions. Before/after comparison slider. Auto-generate listing description from staged photos.

B Django Models

# staging/models.py

class StagingProject(models.Model):
    """Virtual staging project for a unit."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    unit = models.ForeignKey('units.UnitsUnit', on_delete=models.CASCADE, related_name='staging_projects')
    property = models.ForeignKey('properties.Properties', on_delete=models.CASCADE, related_name='staging_projects')

    style = models.CharField(max_length=30, default='modern')
    # 'modern', 'traditional', 'minimalist', 'industrial', 'scandinavian'

    status = models.CharField(max_length=20, default='draft')
    # 'draft', 'processing', 'completed', 'published'

    listing_description = models.TextField(blank=True, default='')
    listing_headline = models.CharField(max_length=200, blank=True, default='')

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'staging_project'


class StagingPhoto(models.Model):
    """Individual photo pair: original + staged."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    project = models.ForeignKey(StagingProject, on_delete=models.CASCADE, related_name='photos')

    room = models.CharField(max_length=50)
    original_url = models.CharField(max_length=500)
    staged_url = models.CharField(max_length=500, blank=True, default='')
    thumbnail_original = models.CharField(max_length=500, blank=True, default='')
    thumbnail_staged = models.CharField(max_length=500, blank=True, default='')

    processing_status = models.CharField(max_length=20, default='pending')
    # 'pending', 'processing', 'completed', 'failed'
    processing_error = models.TextField(blank=True, default='')

    ai_description = models.TextField(blank=True, default='')
    photo_order = models.IntegerField(default=0)

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'staging_photo'
        ordering = ['photo_order']

B API Endpoints

MethodPathDescription
POST/api/v1/staging/projects/Create project. Body: {"unit_id": "uuid", "style": "modern"}
GET/api/v1/staging/projects/{id}/Project detail with all photo pairs.
POST/api/v1/staging/projects/{id}/upload-photos/Upload empty room photos.
POST/api/v1/staging/projects/{id}/generate/Trigger AI staging generation.
POST/api/v1/staging/projects/{id}/generate-listing/AI-generate listing description.
POST/api/v1/staging/projects/{id}/publish/Publish staged photos to listing.

A AI Integration Notes

Virtual staging requires an image generation API (not Claude directly). Options: integrate with a third-party staging API (e.g., Virtual Staging AI, REimagineHome) or use a Stable Diffusion endpoint with ControlNet for room staging. Claude is used for listing description generation from the staged images.

The listing description generation uses Claude:

LISTING_PROMPT = """Based on these staged photos of a {bedrooms}BR/{bathrooms}BA
apartment ({sqft} sq ft) at {property_name}, write a compelling rental listing.

Include:
- Headline (max 80 chars)
- Description (150-200 words)
- Key features as bullet points
- Neighborhood highlights

Tone: Professional, inviting, aspirational.
Price point: ${rent}/month."""

F Screen Wireframes

┌────────────────────────────────────────────────────────────────────────────┐ Virtual Staging — Unit A-401 Style: Modern ├────────────────────────────────────────────────────────────────────────────┤ ┌────────────────────────────────────────────────────────────┐ │ │ Before | After (AI Staged) | │ [empty room] | [room with virtual furniture] │ | <==== drag slider ====> │ │ └────────────────────────────────────────────────────────────┘ [Living Room] [Kitchen] [Bedroom] [Bathroom] [Upload More] [Regenerate] [Generate Listing] [Publish] └────────────────────────────────────────────────────────────────────────────┘

Routes: /staging (project list), /staging/:projectId (before/after slider view).

☑ Acceptance Checklist — 4.1 Virtual Staging

4.2 Tenant Screening
Backend Frontend AI
Why this feature matters:
Screening rental applications is slow, inconsistent, and anxiety-inducing. Different managers weigh criteria differently — one might approve a tenant with a 2.5x income-to-rent ratio while another requires 3x. Some check references thoroughly, others rubber-stamp. Bad screening leads to evictions (average cost: $5,000–10,000 per case). The AI Tenant Screening system brings consistency and speed. When an application comes in, the AI evaluates income-to-rent ratio, employment stability, rental history, and credit indicators. It produces a score from 0–100 with a clear recommendation: Approve (green, score 80+), Approve with Conditions (amber, 60–79, e.g., "require additional deposit"), or Deny (red, below 60) — all with transparent reasoning. The manager sees the breakdown: "Income ratio: 3.2x (good), Employment: 4 years at same company (excellent), Rental history: no evictions (good), Credit: fair." One click to approve or deny. Critical note: the system NEVER considers protected characteristics — race, religion, national origin, familial status, sex, disability. Only objective financial and rental history criteria, fully auditable.

Application intake form. AI scores applicants on income ratio, employment stability, rental history. Approve/deny recommendation with reasoning. Fair housing compliance built in.

B Django Models

# screening/models.py

class ScreeningApplication(models.Model):
    """Tenant screening application."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    unit = models.ForeignKey('units.UnitsUnit', on_delete=models.CASCADE, related_name='applications')
    property = models.ForeignKey('properties.Properties', on_delete=models.CASCADE, related_name='applications')

    # Applicant info
    first_name = models.CharField(max_length=100)
    last_name = models.CharField(max_length=100)
    email = models.EmailField()
    phone = models.CharField(max_length=20)
    date_of_birth = models.DateField(null=True, blank=True)
    ssn_last_four = models.CharField(max_length=4, blank=True, default='')

    # Employment
    employer_name = models.CharField(max_length=200)
    employer_phone = models.CharField(max_length=20, blank=True, default='')
    job_title = models.CharField(max_length=100)
    monthly_income = models.DecimalField(max_digits=10, decimal_places=2)
    employment_start_date = models.DateField()
    income_verification_type = models.CharField(max_length=50, blank=True, default='')
    # 'pay_stubs', 'tax_return', 'bank_statements', 'employer_letter'

    # Rental history
    previous_address = models.TextField(blank=True, default='')
    previous_landlord_name = models.CharField(max_length=200, blank=True, default='')
    previous_landlord_phone = models.CharField(max_length=20, blank=True, default='')
    previous_rent = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
    previous_lease_duration_months = models.IntegerField(null=True, blank=True)
    reason_for_leaving = models.TextField(blank=True, default='')
    eviction_history = models.BooleanField(default=False)

    # Co-applicants
    num_occupants = models.IntegerField(default=1)
    has_pets = models.BooleanField(default=False)
    pet_details = models.TextField(blank=True, default='')

    # AI Scoring
    ai_score = models.FloatField(null=True)  # 0-100
    ai_recommendation = models.CharField(max_length=20, blank=True, default='')
    # 'approve', 'conditional_approve', 'deny', 'manual_review'
    ai_reasoning = models.TextField(blank=True, default='')
    ai_risk_factors = models.JSONField(default=list)
    # [{"factor": "Income ratio below 3x", "severity": "medium", "score_impact": -15}]

    # Scoring breakdown
    income_score = models.FloatField(null=True)
    employment_score = models.FloatField(null=True)
    rental_history_score = models.FloatField(null=True)
    credit_score_band = models.CharField(max_length=20, blank=True, default='')
    # 'excellent', 'good', 'fair', 'poor', 'not_available'

    # Status
    status = models.CharField(max_length=20, default='submitted')
    # 'submitted', 'screening', 'scored', 'approved', 'conditionally_approved',
    # 'denied', 'withdrawn'
    reviewed_by = models.CharField(max_length=100, blank=True, default='')
    reviewed_at = models.DateTimeField(null=True, blank=True)
    decision_notes = models.TextField(blank=True, default='')

    # Documents
    documents = models.JSONField(default=list)
    # [{"type": "pay_stub", "url": "...", "uploaded_at": "..."}]

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'screening_application'
        ordering = ['-created_at']
        indexes = [
            models.Index(fields=['unit', 'status']),
            models.Index(fields=['property', 'status']),
        ]

B API Endpoints

MethodPathDescription
POST/api/v1/screening/applications/Submit new application.
GET/api/v1/screening/applications/List all. Filter: status, property, unit.
GET/api/v1/screening/applications/{id}/Application detail with scoring.
POST/api/v1/screening/applications/{id}/score/Trigger AI scoring.
POST/api/v1/screening/applications/{id}/approve/Approve application.
POST/api/v1/screening/applications/{id}/deny/Deny with reason.
POST/api/v1/screening/applications/{id}/upload-document/Upload supporting docs (pay stubs, etc).

L Screening Scoring Algorithm

def score_application(app: ScreeningApplication) -> dict:
    """
    Returns score 0-100 and recommendation.
    """
    unit_rent = app.unit.market_rent or app.unit.current_rent or Decimal('0')
    score = 100  # Start at 100, deduct for risks

    risk_factors = []

    # 1. Income Ratio (max impact: -35 points)
    if unit_rent > 0 and app.monthly_income > 0:
        income_ratio = float(app.monthly_income / unit_rent)
        if income_ratio >= 3.0:
            income_score = 100
        elif income_ratio >= 2.5:
            income_score = 80
            score -= 10
            risk_factors.append({"factor": "Income ratio 2.5-3x", "severity": "low", "score_impact": -10})
        elif income_ratio >= 2.0:
            income_score = 50
            score -= 25
            risk_factors.append({"factor": "Income ratio 2.0-2.5x", "severity": "medium", "score_impact": -25})
        else:
            income_score = 20
            score -= 35
            risk_factors.append({"factor": f"Income ratio below 2x ({income_ratio:.1f}x)", "severity": "high", "score_impact": -35})
    else:
        income_score = 0
        score -= 35

    # 2. Employment Stability (max impact: -20 points)
    months_employed = (date.today() - app.employment_start_date).days / 30
    if months_employed >= 24:
        employment_score = 100
    elif months_employed >= 12:
        employment_score = 80
        score -= 5
    elif months_employed >= 6:
        employment_score = 50
        score -= 15
        risk_factors.append({"factor": f"Employment <12 months ({int(months_employed)}mo)", "severity": "medium", "score_impact": -15})
    else:
        employment_score = 20
        score -= 20
        risk_factors.append({"factor": f"Employment <6 months ({int(months_employed)}mo)", "severity": "high", "score_impact": -20})

    # 3. Rental History (max impact: -30 points)
    rental_score = 100
    if app.eviction_history:
        rental_score = 0
        score -= 30
        risk_factors.append({"factor": "Prior eviction on record", "severity": "critical", "score_impact": -30})
    elif not app.previous_landlord_name:
        rental_score = 60
        score -= 10
        risk_factors.append({"factor": "No previous landlord reference", "severity": "low", "score_impact": -10})
    elif app.previous_lease_duration_months and app.previous_lease_duration_months < 12:
        rental_score = 70
        score -= 10
        risk_factors.append({"factor": "Previous lease <12 months", "severity": "low", "score_impact": -10})

    # 4. Determine recommendation
    score = max(0, min(100, score))
    if score >= 75:
        recommendation = 'approve'
    elif score >= 55:
        recommendation = 'conditional_approve'
    elif score >= 40:
        recommendation = 'manual_review'
    else:
        recommendation = 'deny'

    return {
        'score': score,
        'recommendation': recommendation,
        'income_score': income_score,
        'employment_score': employment_score,
        'rental_history_score': rental_score,
        'risk_factors': risk_factors,
    }

Fair Housing Compliance

The scoring algorithm must NEVER consider: race, color, national origin, religion, sex, familial status, disability, or any protected class. All scoring factors must be objectively measurable financial criteria. Log all scoring decisions for audit trail.

Decision Thresholds

Score RangeRecommendationAction
75-100ApproveAuto-generate approval letter
55-74Conditional ApproveRequire additional deposit or guarantor
40-54Manual ReviewFlag for property manager review
0-39DenyGenerate adverse action notice

F Screen Wireframes

┌────────────────────────────────────────────────────────────────────────────┐ Tenant Screening — Application #4821 Score: 82/100 ├────────────────────────────────────────────────────────────────────────────┤ Applicant: Sarah Chen Unit: A-401 Rent: $1,900 ├────────────────────────────────────────────────────────────────────────────┤ Scoring Breakdown Income Ratio ████████████████████░░░░ 3.2x ($6,080/mo) 100 Employment ████████████████░░░░░░░░ 18 months 80 Rental History ████████████████████░░░░ 2yr prev lease 100 AI Recommendation: APPROVE "Strong application. Income exceeds 3x rent. Stable employment. Good rental history with no evictions." [Deny] [Request More Info] [Approve & Generate Lease] └────────────────────────────────────────────────────────────────────────────┘

Routes: /screening (application list), /screening/:id (application detail + scoring), /screening/new (application form).

☑ Acceptance Checklist — 4.2 Tenant Screening

4.3 Lease Abstraction
Backend Frontend AI
Why this feature matters:
Every new tenant means a 15–30 page lease document that someone has to manually read and key into the system: tenant name, start date, end date, monthly rent, security deposit, pet policy, early termination terms, renewal clauses, special conditions. This takes 20–30 minutes per lease and errors are common — a wrong date or missed clause can cause real problems down the line. The Lease Abstraction AI reads the entire PDF and extracts every term into structured data automatically. Upload a lease, and within seconds you see a side-by-side view: the PDF on the left (scrollable, with sections highlighted), and all 40+ extracted terms on the right. The AI also flags anything non-standard: "Early termination clause requires 60-day notice — your standard is 30 days" or "Pet deposit is $200 — below your market average of $350." The manager reviews, makes any corrections, and clicks "Apply to Lease" to populate the entire lease record. Zero manual data entry, zero missed clauses, 2 minutes instead of 30.

Upload lease PDF. Claude extracts 40+ lease terms. Flags non-standard clauses. Side-by-side review with original PDF. Apply extracted terms to lease record.

B Django Models

# abstractions/models.py

class LeaseAbstraction(models.Model):
    """AI-extracted terms from a lease document."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    lease = models.ForeignKey(
        'tenants.TenantLease', on_delete=models.CASCADE,
        related_name='abstractions', null=True, blank=True
    )
    unit = models.ForeignKey('units.UnitsUnit', on_delete=models.SET_NULL, null=True, blank=True)
    property = models.ForeignKey('properties.Properties', on_delete=models.SET_NULL, null=True, blank=True)

    # Document
    document_url = models.CharField(max_length=500)
    document_name = models.CharField(max_length=200)
    page_count = models.IntegerField(null=True)

    # Status
    status = models.CharField(max_length=20, default='uploaded')
    # 'uploaded', 'processing', 'extracted', 'reviewed', 'applied'

    # Extraction results
    extracted_terms = models.JSONField(default=dict)
    # See term list below

    # Flags
    non_standard_clauses = models.JSONField(default=list)
    # [{"clause": "...", "page": 5, "severity": "warning", "explanation": "..."}]
    missing_terms = models.JSONField(default=list)
    # ["renewal_option", "pet_policy"]

    # AI metadata
    extraction_confidence = models.FloatField(null=True)
    model_used = models.CharField(max_length=50, default='claude-sonnet-4-20250514')
    processing_time_ms = models.IntegerField(null=True)

    # Review
    reviewed_by = models.CharField(max_length=100, blank=True, default='')
    reviewed_at = models.DateTimeField(null=True, blank=True)
    applied_to_lease = models.BooleanField(default=False)
    applied_at = models.DateTimeField(null=True, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'lease_abstraction'
        ordering = ['-created_at']

Extracted Terms Schema (40+ fields)

EXTRACTED_TERMS_SCHEMA = {
    # Parties
    "landlord_name": "string",
    "landlord_address": "string",
    "tenant_name": "string",
    "tenant_names_all": ["string"],
    "guarantor_name": "string or null",

    # Property
    "property_address": "string",
    "unit_number": "string",
    "square_footage": "integer or null",
    "bedrooms": "integer",
    "bathrooms": "float",
    "parking_spaces": "integer",
    "storage_unit": "string or null",

    # Dates
    "lease_start_date": "YYYY-MM-DD",
    "lease_end_date": "YYYY-MM-DD",
    "lease_sign_date": "YYYY-MM-DD or null",
    "move_in_date": "YYYY-MM-DD",
    "possession_date": "YYYY-MM-DD or null",

    # Financial
    "monthly_rent": "decimal",
    "rent_due_day": "integer",
    "security_deposit": "decimal",
    "pet_deposit": "decimal or null",
    "last_month_rent_required": "boolean",
    "late_fee_amount": "decimal",
    "late_fee_grace_period_days": "integer",
    "nsf_fee": "decimal or null",
    "rent_escalation_pct": "float or null",
    "rent_escalation_date": "YYYY-MM-DD or null",

    # Terms
    "lease_type": "fixed|month_to_month|sublease",
    "renewal_option": "boolean",
    "renewal_terms": "string",
    "auto_renewal": "boolean",
    "notice_to_vacate_days": "integer",
    "early_termination_allowed": "boolean",
    "early_termination_fee": "decimal or null",

    # Policies
    "pets_allowed": "boolean",
    "pet_restrictions": "string",
    "pet_rent_monthly": "decimal or null",
    "smoking_allowed": "boolean",
    "subletting_allowed": "boolean",
    "max_occupants": "integer or null",
    "guest_policy": "string or null",

    # Maintenance
    "tenant_maintenance_responsibilities": "string",
    "landlord_maintenance_responsibilities": "string",
    "hvac_filter_responsibility": "tenant|landlord",
    "lawn_care_responsibility": "tenant|landlord|na",

    # Utilities
    "utilities_included": ["string"],
    "utilities_tenant_responsibility": ["string"],

    # Insurance
    "renters_insurance_required": "boolean",
    "minimum_liability_coverage": "decimal or null",

    # Other
    "governing_law_state": "string",
    "dispute_resolution": "mediation|arbitration|court",
    "lead_paint_disclosure": "boolean",
    "mold_disclosure": "boolean",
}

B API Endpoints

MethodPathDescription
POST/api/v1/abstractions/Upload lease PDF. Triggers async extraction.
GET/api/v1/abstractions/{id}/Get extraction results.
PATCH/api/v1/abstractions/{id}/terms/Update extracted terms (human corrections).
POST/api/v1/abstractions/{id}/apply/Apply extracted terms to TenantLease record.
GET/api/v1/abstractions/List all abstractions. Filter: status, property.

A Claude API — Lease Extraction

LEASE_EXTRACTION_PROMPT = """You are a legal document AI. Extract all terms from
this residential lease agreement.

Return a JSON object with EXACTLY these fields (use null for fields not found):
{extracted_terms_schema}

Also identify any NON-STANDARD clauses that differ from typical residential leases:
- Unusual restrictions
- Non-standard fees
- Unusual liability clauses
- Missing standard protections

Return these as:
"non_standard_clauses": [
  {"clause": "exact text", "page": N, "severity": "info|warning|critical",
   "explanation": "why this is non-standard"}
]

"missing_terms": ["list", "of", "expected", "terms", "not", "found"]
"""

# Send as: PDF -> base64 -> Claude messages API with document type

F Screen Wireframes

┌────────────────────────────────────────────────────────────────────────────┐ Lease Abstraction 42/44 terms extracted Conf: 96% ├────────────────────────────┬───────────────────────────────────────────────┤ [PDF Viewer] Extracted Terms Page 1 of 12 Parties ┌──────────────────┐ Landlord: ABC Properties LLC │ │ Tenant: Sarah Chen │ Lease text │ │ with highlights │ Financial │ on extracted │ Monthly Rent: $1,900.00 │ fields │ Security Deposit: $1,900.00 │ │ Late Fee: $75.00 after 5 days │ │ └──────────────────┘ Flags (2) ! Non-standard early termination clause ? Missing: pet_policy, mold_disclosure [Edit Terms] [Apply to Lease Record] └────────────────────────────┴───────────────────────────────────────────────┘

Routes: /abstractions (list), /abstractions/upload (upload new), /abstractions/:id (side-by-side review).

☑ Acceptance Checklist — 4.3 Lease Abstraction

4.4 Move-In Inspection AI
Backend Frontend AI
Why this feature matters:
Deposit disputes are the single most common source of landlord-tenant litigation in the US — and they’re almost always lost without documentation. 17 US states legally require move-in condition reports. In Washington state, no report means no deductions, period. Yet most property managers still use paper checklists, take blurry phone photos with no timestamps, and shove them in a filing cabinet where they’ll never be found again.

This feature creates an airtight, timestamped, AI-analyzed, digitally-signed condition record for every unit at move-in. Claude Vision examines each photo, identifies every item in the room, rates its condition, and documents pre-existing issues with severity, dimensions, and exact location. The real payoff comes at move-out: Feature 3.1 automatically pulls up the move-in photos for side-by-side comparison — “this scratch was here at move-in (no charge)” vs “this damage is new (tenant responsibility, estimated $200).” No arguments, no he-said-she-said — just photographic evidence with AI analysis.

The guided room-by-room workflow ensures nothing gets missed. The system knows what rooms each unit has, tracks which have been photographed, and won’t let the inspection finalize until every room has adequate coverage. GPS coordinates and timestamps on every photo create a chain of evidence that holds up in court.

Guided room-by-room inspection with Claude Vision analysis, digital signatures, PDF report generation, and side-by-side comparison with move-out.

B Django Models — inspections/models.py

class MoveInInspection(models.Model):
    id              = models.UUIDField(primary_key=True, default=uuid4)
    unit            = models.ForeignKey('units.UnitsUnit', on_delete=CASCADE, related_name='move_in_inspections')
    lease           = models.ForeignKey('tenants.TenantLease', on_delete=CASCADE, related_name='move_in_inspections')
    property        = models.ForeignKey('properties.Properties', on_delete=CASCADE)
    inspector_name  = models.CharField(max_length=150)
    inspection_date = models.DateField()
    status          = models.CharField(max_length=20, choices=[
                        ('in_progress','In Progress'), ('ai_processing','AI Processing'),
                        ('review','Under Review'), ('pending_signatures','Pending Signatures'),
                        ('finalized','Finalized')], default='in_progress')
    rooms_required  = models.JSONField(default=list)      # ["living_room","kitchen","bedroom_1",...]
    rooms_completed = models.JSONField(default=list)
    completeness_pct        = models.IntegerField(default=0)
    total_items_documented  = models.IntegerField(default=0)
    items_excellent = models.IntegerField(default=0)
    items_good      = models.IntegerField(default=0)
    items_fair      = models.IntegerField(default=0)
    items_poor      = models.IntegerField(default=0)
    items_damaged   = models.IntegerField(default=0)
    overall_condition_score = models.FloatField(null=True)  # 0-100
    tenant_signature_url    = models.URLField(blank=True)
    tenant_signed_at        = models.DateTimeField(null=True)
    manager_signature_url   = models.URLField(blank=True)
    manager_signed_at       = models.DateTimeField(null=True)
    condition_report_url    = models.URLField(blank=True)
    location_lat    = models.FloatField(null=True)
    location_lng    = models.FloatField(null=True)
    location_verified = models.BooleanField(default=False)
    created_at      = models.DateTimeField(auto_now_add=True)
    updated_at      = models.DateTimeField(auto_now=True)
    class Meta: db_table = 'move_in_inspection'

class MoveInRoom(models.Model):
    id          = models.UUIDField(primary_key=True, default=uuid4)
    inspection  = models.ForeignKey(MoveInInspection, on_delete=CASCADE, related_name='rooms')
    room_type   = models.CharField(max_length=30)   # living_room, kitchen, bedroom_1, bathroom_1, ...
    room_name   = models.CharField(max_length=100)   # "Primary Bedroom", "Kitchen", ...
    overall_condition = models.CharField(max_length=20, choices=[
                        ('excellent','Excellent'), ('good','Good'), ('fair','Fair'),
                        ('poor','Poor'), ('damaged','Damaged'), ('not_inspected','Not Inspected')],
                        default='not_inspected')
    notes       = models.TextField(blank=True)
    photo_count = models.IntegerField(default=0)
    is_complete = models.BooleanField(default=False)
    class Meta: db_table = 'move_in_room'

class MoveInItem(models.Model):
    id              = models.UUIDField(primary_key=True, default=uuid4)
    room            = models.ForeignKey(MoveInRoom, on_delete=CASCADE, related_name='items')
    item_type       = models.CharField(max_length=50)  # walls, ceiling, floor_carpet, floor_tile,
                      # windows, countertops, sink, appliance_stove, appliance_refrigerator,
                      # hvac_vents, smoke_detector, light_fixtures, doors, cabinets, closet, ...
    item_label      = models.CharField(max_length=150)
    condition_rating = models.CharField(max_length=20)  # excellent/good/fair/poor/damaged
    ai_description  = models.TextField(blank=True)
    ai_pre_existing_issues = models.JSONField(default=list)  # [{severity, description, dimensions, location}]
    ai_confidence   = models.FloatField(null=True)
    photo           = models.ForeignKey('MoveInPhoto', on_delete=SET_NULL, null=True)
    human_override  = models.CharField(max_length=20, blank=True)
    human_notes     = models.TextField(blank=True)
    class Meta: db_table = 'move_in_item'

class MoveInPhoto(models.Model):
    id           = models.UUIDField(primary_key=True, default=uuid4)
    inspection   = models.ForeignKey(MoveInInspection, on_delete=CASCADE, related_name='photos')
    room         = models.ForeignKey(MoveInRoom, on_delete=CASCADE, related_name='photos')
    photo_url    = models.URLField()
    photo_order  = models.IntegerField(default=0)
    captured_at  = models.DateTimeField(null=True)
    gps_lat      = models.FloatField(null=True)
    gps_lng      = models.FloatField(null=True)
    ai_quality_score    = models.FloatField(null=True)  # 0.0 - 1.0
    ai_quality_issues   = models.JSONField(default=list)  # ["too_dark","blurry","obstructed"]
    is_acceptable       = models.BooleanField(default=True)
    ai_analyzed         = models.BooleanField(default=False)
    ai_condition_summary = models.TextField(blank=True)
    ai_items_found      = models.JSONField(default=list)  # [{item_type, condition, issues}]
    class Meta: db_table = 'move_in_photo'

B API Endpoints

MethodPathDescription
POST/api/v1/move-in-inspections/Create new inspection. Auto-generates room list from unit floorplan.
GET/api/v1/move-in-inspections/List all inspections. Filter: ?unit=, ?property=, ?status=.
GET/api/v1/move-in-inspections/{id}/Full detail with nested rooms, items, and photos.
POST/api/v1/move-in-inspections/{id}/upload-photos/Upload photos (multipart). Requires room_id. Returns quality check.
POST/api/v1/move-in-inspections/{id}/analyze/Trigger Claude Vision analysis on all unanalyzed photos. Celery task.
GET/api/v1/move-in-inspections/{id}/completeness/Coverage check: rooms without photos, items without ratings.
PATCH/api/v1/move-in-inspections/{id}/items/{itemId}/Override AI condition rating with human assessment.
POST/api/v1/move-in-inspections/{id}/sign/Submit digital signature (base64 image). Records IP + timestamp.
POST/api/v1/move-in-inspections/{id}/generate-report/Generate PDF condition report with all photos, ratings, signatures.
POST/api/v1/move-in-inspections/{id}/finalize/Lock inspection. Requires both signatures. No further edits allowed.
GET/api/v1/move-in-inspections/{id}/compare/{moveOutId}/Side-by-side comparison with move-out inspection. AI diff analysis.

A Claude Vision — Inspection Analysis

# ── QUALITY CHECK PROMPT ────────────────────────────────────────
QUALITY_CHECK_PROMPT = """
Assess this photo for use in a legal move-in condition report.

Return JSON:
{
  "quality_score": 0.0-1.0,
  "is_acceptable": true/false,
  "issues": ["too_dark", "blurry", "obstructed", "too_far", "reflection"],
  "suggestion": "Move closer to the wall damage and retake with flash."
}

Reject if: score < 0.4, or image is blurry, <50% of subject visible,
or lighting makes condition assessment impossible.
"""

# ── CONDITION ANALYSIS PROMPT ───────────────────────────────────
CONDITION_ANALYSIS_PROMPT = """
You are inspecting a {room_name} for a legal move-in condition report.
Analyze this photo and identify every visible item and its condition.

For EACH item found, return:
{
  "items": [{
    "item_type": "walls|ceiling|floor_carpet|floor_tile|windows|...",
    "item_label": "North wall near window",
    "condition_rating": "excellent|good|fair|poor|damaged",
    "description": "Detailed description of current condition",
    "pre_existing_issues": [{
      "severity": "cosmetic|minor|moderate|major",
      "description": "3-inch scratch on drywall",
      "dimensions": "3 inches long, surface-level",
      "location": "Upper-left quadrant, 5 feet from floor"
    }],
    "confidence": 0.0-1.0
  }],
  "room_summary": "Overall room assessment in 1-2 sentences",
  "overall_condition": "excellent|good|fair|poor|damaged"
}

Be thorough. In a legal dispute, anything NOT documented here is
assumed to be the tenant's responsibility at move-out.
"""

# ── COMPARISON PROMPT (used with Feature 3.1) ──────────────────
COMPARISON_PROMPT = """
Compare these two photos of the same {item_type} in {room_name}.
Photo 1: Move-in ({move_in_date})
Photo 2: Move-out ({move_out_date})

For each difference found, classify as:
- "pre_existing": Visible in move-in photo → NO charge to tenant
- "new_damage": NOT in move-in photo → Tenant responsibility
- "normal_wear": Expected deterioration for {lease_duration} tenancy
- "improved": Better condition than move-in

Return:
{
  "differences": [{
    "classification": "pre_existing|new_damage|normal_wear|improved",
    "description": "New 6-inch crack in tile near dishwasher",
    "estimated_repair_cost": 150.00,
    "charge_to_tenant": true/false,
    "reasoning": "Not visible in move-in photo. Consistent with impact damage."
  }],
  "summary": "1 pre-existing issue (no charge), 2 new damages ($350 total)"
}
"""

F Screen Wireframes

┌──────────────────────────────────────────────────────────────────────────┐ Move-In Inspection — Unit 4B • 742 Evergreen Terrace Status: In Progress Completeness: ████████░░ 78% ├──────────────────────┬───────────────────────────────────────────────────┤ ROOMS Kitchen — 6 photos • AI Analyzed ✓ Living Room (4) ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ▸ Kitchen (6) │ ░░░░░░░ │ │ ░░░░░░░ │ │ ░░░░░░░ │ │ ░░░░░░░ │ ✓ Bedroom 1 (3) │ ░Photo░ │ │ ░Photo░ │ │ ░Photo░ │ │ ░Photo░ │ ✓ Bedroom 2 (3) │ ░░░░░░░ │ │ ░░░░░░░ │ │ ░░░░░░░ │ │ ░░░░░░░ │ ✓ Bathroom 1 (4) Excellent│ │ Good │ │ Fair │ │ Damaged ✗ Bathroom 2 (0) └─────────┘ └─────────┘ └─────────┘ └─────────┘ ✗ Balcony (0) Pre-Existing Conditions Found: ┌───────────────────────────────────────────────┐ ⚠ Countertop — 4in scratch near sink ││ Severity: Cosmetic • Confidence: 94% ││ ⚠ Floor Tile — Cracked tile under fridge ││ Severity: Moderate • Confidence: 91% ││ ✓ Walls — Clean, no issues found ││ └───────────────────────────────────────────────┘ [+ Upload Photos] [Generate Report] [Request Signatures] [Finalize Inspection] └──────────────────────┴───────────────────────────────────────────────────┘

Routes: /inspections/move-in (list), /inspections/move-in/:id (detail), /inspections/move-in/:id/compare/:moveOutId (side-by-side).

☑ Acceptance Checklist — 4.4 Move-In Inspection AI

4.5 AI Lease Generation & E-Signing
Backend Frontend AI
Why this feature matters:
Feature 4.3 reads existing leases. This one writes them. Today, creating a lease means opening a 2-year-old Word template, manually filling in 30+ fields, guessing which state-required clauses to include, and hoping nothing has changed legally since the last time someone updated the template. One missed disclosure clause in the wrong jurisdiction and you’re looking at a void lease or a regulatory fine.

The AI assembles a complete, jurisdiction-compliant lease document from a clause library. It auto-includes clauses required by the unit’s state and city, scores each clause for litigation risk based on historical dispute data, generates addenda for pets, parking, storage, and military service, and handles the full e-signature flow — from sending to tracking page views to recording the final signature with IP and timestamp. No more DocuSign fees for a feature that should be built in.

When renewal time comes (Feature 1.2), the system pulls current lease terms, adjusts rent based on the AI recommendation, flags clauses that need updating, and regenerates the document — closing the loop from screening (4.3) through lease creation through renewal. The clause library is versioned, so you always know which version of a clause was in which lease, and translations maintain legal precision with binding-language disclaimers.

AI-powered lease generation from a clause library with jurisdiction compliance, per-clause risk scoring, e-signature workflow, renewal generation, and translation.

B Django Models — leases/models.py

class LeaseTemplate(models.Model):
    id              = models.UUIDField(primary_key=True, default=uuid4)
    name            = models.CharField(max_length=200)
    property_type   = models.CharField(max_length=30)     # residential, commercial, mixed
    jurisdiction_state = models.CharField(max_length=2)    # "WA", "CA", "NY", ...
    jurisdiction_city  = models.CharField(max_length=100, blank=True)
    base_clause_ids = models.JSONField(default=list)       # [uuid, uuid, ...]
    version         = models.IntegerField(default=1)
    is_active       = models.BooleanField(default=True)
    property        = models.ForeignKey('properties.Properties', on_delete=SET_NULL, null=True, blank=True)
    created_at      = models.DateTimeField(auto_now_add=True)
    class Meta: db_table = 'lease_template'

class LeaseClause(models.Model):
    id              = models.UUIDField(primary_key=True, default=uuid4)
    category        = models.CharField(max_length=50)      # parties, premises, term, rent,
                      # security_deposit, late_fees, utilities, maintenance, pets, parking,
                      # noise, subletting, insurance, lead_paint, mold, military, ...
    title           = models.CharField(max_length=200)
    text            = models.TextField()                   # Contains {{variable}} placeholders
    jurisdiction_required  = models.JSONField(default=list)   # [{"state":"WA"}, {"state":"CA","city":"SF"}]
    jurisdiction_prohibited = models.JSONField(default=list)
    risk_score      = models.IntegerField(default=0)       # 0-100, higher = more litigated
    dispute_count   = models.IntegerField(default=0)
    required_variables = models.JSONField(default=list)    # ["tenant_name","rent_amount",...]
    is_standard     = models.BooleanField(default=True)
    version         = models.IntegerField(default=1)
    effective_date  = models.DateField()
    class Meta: db_table = 'lease_clause'

class GeneratedLease(models.Model):
    id              = models.UUIDField(primary_key=True, default=uuid4)
    template        = models.ForeignKey(LeaseTemplate, on_delete=SET_NULL, null=True)
    unit            = models.ForeignKey('units.UnitsUnit', on_delete=CASCADE, related_name='generated_leases')
    property        = models.ForeignKey('properties.Properties', on_delete=CASCADE)
    tenant_lease    = models.ForeignKey('tenants.TenantLease', on_delete=SET_NULL, null=True, blank=True)
    screening_application = models.ForeignKey('screening.ScreeningApplication',
                            on_delete=SET_NULL, null=True, blank=True)
    lease_terms     = models.JSONField(default=dict)       # {rent, deposit, start_date, end_date, ...}
    clause_ids      = models.JSONField(default=list)       # Ordered list of clause UUIDs used
    ai_risk_assessment = models.JSONField(default=dict)    # {overall_score, clause_risks[], warnings[]}
    jurisdiction_warnings = models.JSONField(default=list) # [{clause_id, warning, severity}]
    document_url    = models.URLField(blank=True)
    document_html   = models.TextField(blank=True)
    status          = models.CharField(max_length=20, choices=[
                        ('draft','Draft'), ('review','Under Review'),
                        ('sent_for_signing','Sent for Signing'),
                        ('partially_signed','Partially Signed'),
                        ('fully_signed','Fully Signed'), ('voided','Voided')],
                        default='draft')
    is_renewal      = models.BooleanField(default=False)
    previous_lease  = models.ForeignKey('self', on_delete=SET_NULL, null=True, blank=True)
    renewal_changes = models.JSONField(default=list)       # [{field, old_value, new_value, reason}]
    created_at      = models.DateTimeField(auto_now_add=True)
    class Meta: db_table = 'generated_lease'

class LeaseSignature(models.Model):
    id              = models.UUIDField(primary_key=True, default=uuid4)
    lease           = models.ForeignKey(GeneratedLease, on_delete=CASCADE, related_name='signatures')
    signer_role     = models.CharField(max_length=20, choices=[
                        ('tenant','Tenant'), ('co_tenant','Co-Tenant'),
                        ('guarantor','Guarantor'), ('property_manager','Property Manager')])
    signer_name     = models.CharField(max_length=200)
    signer_email    = models.EmailField()
    signing_token   = models.UUIDField(default=uuid4, unique=True)
    signed_at       = models.DateTimeField(null=True)
    ip_address      = models.GenericIPAddressField(null=True)
    user_agent      = models.TextField(blank=True)
    pages_viewed    = models.JSONField(default=list)       # [1, 2, 3, ...] tracks which pages were viewed
    time_spent_seconds = models.IntegerField(default=0)
    status          = models.CharField(max_length=20, choices=[
                        ('pending','Pending'), ('viewed','Viewed'),
                        ('signed','Signed'), ('declined','Declined')],
                        default='pending')
    reminder_count  = models.IntegerField(default=0)
    expires_at      = models.DateTimeField()
    class Meta: db_table = 'lease_signature'

class LeaseAddendum(models.Model):
    id              = models.UUIDField(primary_key=True, default=uuid4)
    lease           = models.ForeignKey(GeneratedLease, on_delete=CASCADE, related_name='addenda')
    addendum_type   = models.CharField(max_length=20, choices=[
                        ('pet','Pet'), ('parking','Parking'), ('storage','Storage'),
                        ('military','Military'), ('lead_paint','Lead Paint'), ('custom','Custom')])
    title           = models.CharField(max_length=200)
    terms           = models.JSONField(default=dict)       # {pet_type, pet_weight, pet_deposit, ...}
    document_html   = models.TextField(blank=True)
    requires_signature = models.BooleanField(default=True)
    signed          = models.BooleanField(default=False)
    class Meta: db_table = 'lease_addendum'

class JurisdictionRule(models.Model):
    id              = models.UUIDField(primary_key=True, default=uuid4)
    state           = models.CharField(max_length=2)
    city            = models.CharField(max_length=100, blank=True)
    rule_type       = models.CharField(max_length=30, choices=[
                        ('security_deposit_limit','Security Deposit Limit'),
                        ('late_fee_limit','Late Fee Limit'),
                        ('notice_period','Notice Period'),
                        ('required_disclosure','Required Disclosure'),
                        ('rent_control','Rent Control')])
    rule_details    = models.JSONField(default=dict)       # {max_months: 2, max_pct: 5, ...}
    statute_reference = models.CharField(max_length=200)
    effective_date  = models.DateField()
    class Meta: db_table = 'jurisdiction_rule'

B API Endpoints

MethodPathDescription
GET/api/v1/lease-templates/List all active templates. Filter: ?state=, ?property_type=.
POST/api/v1/lease-templates/Create a new template with base clause selections.
GET/api/v1/lease-clauses/Browse the full clause library. Filter: ?category=, ?is_standard=.
GET/api/v1/lease-clauses/required/?state={st}Get all clauses required by jurisdiction. Optional &city=.
POST/api/v1/generated-leases/Generate a new lease. Triggers AI clause assembly + risk scoring. Celery task.
GET/api/v1/generated-leases/{id}/Full detail with clauses, signatures, addenda, risk assessment.
GET/api/v1/generated-leases/{id}/preview/Rendered HTML preview of the lease document.
POST/api/v1/generated-leases/{id}/risk-assessment/Re-run AI risk assessment on current clause set.
POST/api/v1/generated-leases/{id}/send-for-signing/Create signature requests and send email links to all signers.
GET/api/v1/lease-signatures/{token}/Public signing page. No auth required. Token-based access.
POST/api/v1/lease-signatures/{token}/sign/Submit signature. Records IP, user agent, time spent, pages viewed.
POST/api/v1/generated-leases/{id}/create-renewal/Generate renewal lease from existing. Pre-fills terms, applies rent escalation.
POST/api/v1/generated-leases/{id}/translate/Translate lease to specified language. Adds binding-language disclaimer.
GET/api/v1/jurisdiction-rules/?state={st}Get all rules for a jurisdiction. Optional &city=, &rule_type=.
POST/api/v1/jurisdiction-rules/check-compliance/Check a lease’s clauses against jurisdiction rules. Returns violations.

A Claude API — Lease Assembly & Analysis

# ── LEASE ASSEMBLY PROMPT ───────────────────────────────────────
LEASE_ASSEMBLY_PROMPT = """
You are a real estate attorney AI assembling a residential lease for:
- State: {state}, City: {city}
- Property type: {property_type}
- Unit: {unit_number} at {property_name}
- Tenant: {tenant_name}
- Lease terms: {lease_terms_json}

Clause library provided: {clauses_json}

Tasks:
1. ORDER clauses logically (parties → premises → term → rent → ...)
2. SUBSTITUTE all {{variables}} with actual values from lease_terms
3. CHECK jurisdiction compliance:
   - Include ALL required clauses for {state}/{city}
   - Remove any prohibited clauses
   - Validate security deposit ≤ {deposit_limit}
   - Validate late fee ≤ {late_fee_limit}
4. ASSESS risk per clause: score 0-100, flag high-risk (>70) with reason
5. GENERATE the complete HTML document with proper formatting

Return:
{
  "document_html": "...",
  "clause_order": ["uuid1", "uuid2", ...],
  "risk_assessment": {
    "overall_score": 23,
    "high_risk_clauses": [{"clause_id": "...", "score": 78, "reason": "..."}],
    "missing_required": [],
    "prohibited_included": []
  },
  "jurisdiction_warnings": [{"message": "...", "severity": "info|warning|error"}],
  "suggested_addenda": ["pet", "lead_paint"]
}
"""

# ── TRANSLATION PROMPT ──────────────────────────────────────────
TRANSLATION_PROMPT = """
Translate this residential lease from English to {target_language}.
Maintain exact legal meaning — do not simplify or paraphrase legal terms.
Use the formal register appropriate for legal documents in {target_language}.

CRITICAL: Add the following disclaimer at the top of the translated document:
"This translation is provided for informational purposes only. In the event of
any conflict between this translation and the original English-language lease,
the English version shall prevail and be considered the binding agreement."

Translate the disclaimer itself into {target_language} as well, then include
the English version immediately below it.

Source document:
{document_html}
"""

# ── RENEWAL ANALYSIS PROMPT ─────────────────────────────────────
RENEWAL_ANALYSIS_PROMPT = """
Analyze this lease for renewal. Current terms:
{current_terms_json}

Market data:
- Comparable rents in area: {comp_rents}
- Average rent increase YoY: {avg_increase_pct}%
- Vacancy rate: {vacancy_rate}%
- Tenant payment history: {payment_summary}
- Tenant tenure: {tenure_months} months

Recommend:
{
  "recommended_rent": 2150.00,
  "rent_change_pct": 3.5,
  "reasoning": "Below market by 4%. Reliable tenant, 0 late payments...",
  "clause_updates": [
    {"clause_id": "...", "reason": "State updated late fee statute in 2026",
     "old_text": "...", "new_text": "..."}
  ],
  "retention_risk": "low|medium|high",
  "retention_strategy": "Offer 2-year option at 2.8% increase for stability"
}
"""

F Screen Wireframes

┌──────────────────────────────────────────────────────────────────────────┐ Generate Lease — Unit 4B • Sarah Chen [Draft] ├──────────────────────────────┬───────────────────────────────────────────┤ LEASE TERMS CLAUSE PREVIEW Risk: Low 23 Tenant: Sarah Chen 1. Parties ■ 8 Unit: 4B • 2BR/1BA 2. Premises Description ■ 5 Start: 01 Apr 2026 3. Lease Term ■ 12 End: 31 Mar 2027 4. Rent & Payment ■ 15 Rent: $2,150.00/mo 5. Security Deposit ■ 18 Deposit: $2,150.00 6. Late Fees ■ 45 Late Fee: $50 after 5 days 7. Utilities ■ 10 8. Maintenance ■ 22 ──────────────────────── 9. Pet Policy ▸ ■ 72 Options: ⚠ High dispute rate. Consider ☑ Pets allowed adding weight/breed specifics. ☑ Parking (1 spot) 10. Parking ■ 8 ☐ Storage unit 11. Noise & Conduct ■ 14 ☐ Military clause ┌─────────────────────────────────────┐ Jurisdiction: WA • Seattle │ ⓘ WA requires: Lead paint disc., │ Template: Standard Res v3 │ mold disclosure, deposit receipt │ │ within 14 days. ✓ All included. │ └─────────────────────────────────────┘ [Generate Lease] [Preview HTML] [Send for Signing] [Customize Clauses] [Translate] [Create Renewal] └──────────────────────────────┴───────────────────────────────────────────┘

Routes: /leases/generate (builder), /leases/generated/:id (detail/preview), /sign/:token (public signing page).

☑ Acceptance Checklist — 4.5 AI Lease Generation & E-Signing

4.6 Tenant Onboarding Automation
Backend Frontend AI Celery
Why this feature matters:
38% of tenant satisfaction complaints in the first 60 days don’t stem from the unit itself — they stem from the experience. Nobody sends the welcome email. Nobody follows up to collect the renter’s insurance certificate. Nobody tells the tenant how to submit a maintenance request or where the recycling goes. The result: frustrated tenants, missed compliance tasks, and a disorganized first impression that sets the tone for the entire lease.

This system triggers automatically when a lease is signed. It generates a context-aware checklist of 15-20 tasks based on the specific unit, property, and tenant situation — a tenant with a pet gets pet registration and deposit tasks; a tenant with parking gets parking pass tasks; every tenant in a pre-1978 building gets lead paint acknowledgment. Welcome messages go out on schedule, documents are collected and AI-verified (is this actually a renter’s insurance certificate? does it cover the right dates?), keys are tracked with return status, and a first-week communication sequence checks in to catch issues early.

For tenants, there’s an AI chatbot backed by a property-specific knowledge base that can answer “where do I pick up packages?” or “what’s the guest parking policy?” without anyone on your team lifting a finger. For managers, stall detection alerts them if a checklist goes inactive for 48 hours — because the worst onboarding is the one that just quietly stops happening.

Automated onboarding from lease signing through first 30 days with context-aware task generation, AI document verification, key handoff tracking, communication sequences, and FAQ chatbot.

B Django Models — onboarding/models.py

class OnboardingChecklist(models.Model):
    id              = models.UUIDField(primary_key=True, default=uuid4)
    tenant_lease    = models.OneToOneField('tenants.TenantLease', on_delete=CASCADE,
                        related_name='onboarding')
    unit            = models.ForeignKey('units.UnitsUnit', on_delete=CASCADE)
    property        = models.ForeignKey('properties.Properties', on_delete=CASCADE)
    tenant_name     = models.CharField(max_length=200)
    tenant_email    = models.EmailField()
    move_in_date    = models.DateField()
    status          = models.CharField(max_length=20, choices=[
                        ('pending','Pending'), ('in_progress','In Progress'),
                        ('move_in_day','Move-In Day'), ('first_week','First Week'),
                        ('completed','Completed'), ('stalled','Stalled')],
                        default='pending')
    total_tasks     = models.IntegerField(default=0)
    completed_tasks = models.IntegerField(default=0)
    completion_pct  = models.IntegerField(default=0)
    last_activity_at = models.DateTimeField(null=True)
    stalled_since   = models.DateTimeField(null=True)
    stall_alert_sent = models.BooleanField(default=False)
    created_at      = models.DateTimeField(auto_now_add=True)
    class Meta: db_table = 'onboarding_checklist'

class OnboardingTask(models.Model):
    id              = models.UUIDField(primary_key=True, default=uuid4)
    checklist       = models.ForeignKey(OnboardingChecklist, on_delete=CASCADE, related_name='tasks')
    task_type       = models.CharField(max_length=50)      # welcome_email, portal_setup,
                      # insurance_collection, utility_setup_guide, parking_registration,
                      # pet_registration, key_handoff, move_in_inspection, autopay_enrollment,
                      # emergency_contacts, maintenance_walkthrough, community_info,
                      # satisfaction_checkin, document_collection, custom
    title           = models.CharField(max_length=200)
    description     = models.TextField(blank=True)
    trigger         = models.CharField(max_length=30, choices=[
                        ('on_lease_sign','On Lease Sign'), ('pre_move_in_7d','7 Days Before'),
                        ('pre_move_in_3d','3 Days Before'), ('on_move_in','Move-In Day'),
                        ('post_move_in_1d','1 Day After'), ('post_move_in_3d','3 Days After'),
                        ('post_move_in_7d','7 Days After'), ('post_move_in_30d','30 Days After')])
    assigned_to     = models.CharField(max_length=20, choices=[
                        ('system','System'), ('tenant','Tenant'), ('manager','Manager')])
    status          = models.CharField(max_length=20, choices=[
                        ('pending','Pending'), ('scheduled','Scheduled'),
                        ('sent','Sent'), ('completed','Completed'), ('skipped','Skipped')],
                        default='pending')
    document_type   = models.CharField(max_length=50, blank=True)   # renters_insurance, id_copy, ...
    document_url    = models.URLField(blank=True)
    document_verified = models.BooleanField(default=False)
    is_required     = models.BooleanField(default=True)
    is_blocking     = models.BooleanField(default=False)   # Blocks move-in if incomplete
    sort_order      = models.IntegerField(default=0)
    class Meta:
        db_table = 'onboarding_task'
        ordering = ['sort_order']

class OnboardingMessage(models.Model):
    id              = models.UUIDField(primary_key=True, default=uuid4)
    checklist       = models.ForeignKey(OnboardingChecklist, on_delete=CASCADE, related_name='messages')
    message_type    = models.CharField(max_length=50)      # welcome, reminder, checkin, ...
    channel         = models.CharField(max_length=10, choices=[
                        ('email','Email'), ('sms','SMS'), ('push','Push'), ('in_app','In-App')])
    subject         = models.CharField(max_length=200, blank=True)
    content_html    = models.TextField()
    sent_at         = models.DateTimeField(null=True)
    opened_at       = models.DateTimeField(null=True)
    status          = models.CharField(max_length=20, choices=[
                        ('queued','Queued'), ('sent','Sent'), ('delivered','Delivered'),
                        ('opened','Opened'), ('bounced','Bounced')],
                        default='queued')
    class Meta: db_table = 'onboarding_message'

class KeyHandoff(models.Model):
    id              = models.UUIDField(primary_key=True, default=uuid4)
    checklist       = models.ForeignKey(OnboardingChecklist, on_delete=SET_NULL,
                        null=True, blank=True, related_name='keys')
    unit            = models.ForeignKey('units.UnitsUnit', on_delete=CASCADE, related_name='key_handoffs')
    tenant_name     = models.CharField(max_length=200)
    key_type        = models.CharField(max_length=20, choices=[
                        ('physical_unit_key','Unit Key'), ('mailbox_key','Mailbox Key'),
                        ('fob','Key Fob'), ('garage_remote','Garage Remote'),
                        ('digital_smart_lock','Smart Lock'), ('gate_code','Gate Code')])
    key_identifier  = models.CharField(max_length=50, blank=True)   # Key #, fob serial, etc.
    access_code     = models.CharField(max_length=20, blank=True)   # For smart locks / gates
    quantity        = models.IntegerField(default=1)
    issued_at       = models.DateTimeField(null=True)
    returned_at     = models.DateTimeField(null=True)
    returned_condition = models.CharField(max_length=20, blank=True)  # good, damaged, lost
    tenant_acknowledged = models.BooleanField(default=False)
    status          = models.CharField(max_length=20, choices=[
                        ('pending','Pending'), ('issued','Issued'), ('active','Active'),
                        ('returned','Returned'), ('lost','Lost')],
                        default='pending')
    class Meta: db_table = 'key_handoff'

class PropertyKnowledgeBase(models.Model):
    id              = models.UUIDField(primary_key=True, default=uuid4)
    property        = models.ForeignKey('properties.Properties', on_delete=CASCADE,
                        related_name='knowledge_base')
    category        = models.CharField(max_length=30, choices=[
                        ('parking','Parking'), ('amenities','Amenities'),
                        ('maintenance','Maintenance'), ('policies','Policies'),
                        ('utilities','Utilities'), ('neighborhood','Neighborhood'),
                        ('safety','Safety'), ('emergency','Emergency'), ('faq','FAQ')])
    question        = models.CharField(max_length=500)
    answer          = models.TextField()
    keywords        = models.JSONField(default=list)       # ["guest parking","visitor","overnight"]
    is_active       = models.BooleanField(default=True)
    class Meta: db_table = 'property_knowledge_base'

B API Endpoints

MethodPathDescription
POST/api/v1/onboarding/Create checklist for a lease. Auto-generates context-aware tasks based on unit/property/tenant.
GET/api/v1/onboarding/{id}/Full checklist with nested tasks, messages, and key handoffs.
PATCH/api/v1/onboarding/{id}/tasks/{taskId}/Update task status (complete, skip). Recalculates completion_pct.
POST/api/v1/onboarding/{id}/tasks/{taskId}/upload-document/Upload document for a task (e.g., renter’s insurance PDF).
POST/api/v1/onboarding/{id}/tasks/{taskId}/verify-document/AI-verify uploaded document. Checks type, extracts key fields, flags issues.
POST/api/v1/onboarding/{id}/messages/send/Send a custom message to the tenant via specified channel.
POST/api/v1/key-handoffs/Record key issuance with type, identifier, and quantity.
PATCH/api/v1/key-handoffs/{id}/Update key status (returned, lost). Record condition.
POST/api/v1/onboarding/chat/Tenant FAQ chatbot. Answers from property knowledge base.
GET/api/v1/properties/{id}/knowledge-base/List all KB entries for a property. Filter: ?category=, ?search=.
POST/api/v1/properties/{id}/knowledge-base/bulk-import/Import KB entries from a property handbook PDF. AI extracts Q&A pairs.
GET/api/v1/onboarding/{id}/utility-guide/AI-generated personalized utility setup guide with local providers.

A Claude API — Onboarding Intelligence

# ── WELCOME EMAIL PROMPT ────────────────────────────────────────
WELCOME_EMAIL_PROMPT = """
Write a warm, professional welcome email for a new tenant:
- Tenant name: {tenant_name}
- Property: {property_name}
- Unit: {unit_number}
- Move-in date: {move_in_date}
- Property manager: {manager_name}

Include:
1. Warm greeting and excitement about their new home
2. Confirmed move-in date and time ({move_in_time})
3. 3-4 pre-move-in tasks they need to complete:
   - Upload renter's insurance (deadline: {insurance_deadline})
   - Set up utilities ({utility_providers})
   - Complete emergency contact form
   - Schedule move-in inspection
4. Link to tenant portal: {portal_url}
5. Emergency maintenance number: {emergency_phone}
6. Property manager contact info

Tone: Professional but warm. This is their new home, not a transaction.
Keep under 400 words. Use HTML formatting.
"""

# ── DOCUMENT VERIFICATION PROMPT ────────────────────────────────
DOCUMENT_VERIFICATION_PROMPT = """
Verify this uploaded document for a tenant onboarding task.
Expected document type: {expected_type}

For renter's insurance, verify:
{
  "is_correct_type": true/false,
  "policy_number": "...",
  "carrier": "...",
  "coverage_amount": 100000,
  "liability_amount": 300000,
  "effective_date": "2026-04-01",
  "expiration_date": "2027-04-01",
  "named_insured": "Sarah Chen",
  "property_address_matches": true/false,
  "meets_requirements": true/false,
  "issues": ["Liability coverage below $300,000 minimum requirement"],
  "suggestion": "Policy needs at least $300,000 liability. Current: $100,000."
}

For ID documents, verify: name matches lease, not expired, legible.
For other documents: verify type, extract key fields, flag obvious issues.

Return "is_correct_type": false if the uploaded file doesn't match
the expected document type (e.g., uploaded a bank statement instead
of insurance certificate).
"""

# ── TENANT FAQ PROMPT ───────────────────────────────────────────
TENANT_FAQ_PROMPT = """
You are an AI assistant for tenants at {property_name}.
Answer the tenant's question using ONLY the knowledge base provided below.
Do NOT make up information. If the answer is not in the knowledge base,
say: "I don't have that information. Please contact the office at
{office_phone} or {office_email} for help with this."

Knowledge base:
{knowledge_base_entries}

Tenant's question: {question}

Rules:
- Be friendly and helpful
- Keep answers concise (2-4 sentences)
- Include specific details (hours, locations, phone numbers) when available
- If the question is about an emergency, always include the emergency number
- Never give legal advice — redirect to the lease or office
"""

# ── UTILITY GUIDE PROMPT ────────────────────────────────────────
UTILITY_GUIDE_PROMPT = """
Generate a personalized utility setup guide for a new tenant:
- Address: {unit_address}
- City/State: {city}, {state}
- Move-in date: {move_in_date}
- Utilities tenant is responsible for: {tenant_utilities}
- Utilities included in rent: {included_utilities}

For each tenant-responsible utility, provide:
1. Provider name and phone number
2. Website for new account setup
3. Estimated monthly cost for a {bedrooms}BR unit
4. Setup deadline (should be active by move-in date)
5. Any tips (e.g., "Budget billing available", "Autopay discount")

Format as a clear, actionable checklist. Include a note about which
utilities are already included in rent so they don't accidentally
set up duplicate service.
"""

F Screen Wireframes

┌──────────────────────────────────────────────────────────────────────────┐ Tenant Onboarding — Sarah Chen • Unit 4B In Progress 65% Move-in: 01 Apr 2026 (5 days away) ████████████░░░░░░░ ├──────────────────────────────────────────┬───────────────────────────────┤ TIMELINE BLOCKING ITEMS (2) ● Lease Signed — 15 Mar 2026 ⚠ Renter's Insurance ✓ Welcome email sent Due: 28 Mar • Not uploaded ✓ Portal account created [Send Reminder] ✓ Emergency contacts collected ⚠ Utility Transfer ● Pre Move-In — 7 days before Due: 01 Apr • Not confirmed ✓ Utility setup guide sent [View Guide] ✗ Renter's insurance BLOCKING ◌ Parking pass registration ├───────────────────────────────┤ ◌ Pet registration + deposit KEY HANDOFF ○ Move-In Day — 01 Apr 2026 ┌─────────────────────────────┐ ◌ Key handoff (3 keys) ◌ Unit Key #4B-001 ×2 ││ ◌ Move-in inspection (4.4) ◌ Mailbox Key #MB-4B ×1 ││ ◌ Walkthrough with tenant ◌ Fob #F-2847 ×1 ││ ◌ Gate Code: **** ││ ○ First Week — 01-07 Apr └─────────────────────────────┘ ◌ Day 1 check-in message ◌ Maintenance request tutorial [Issue Keys] [Print Handoff] ◌ Day 3 satisfaction check-in ○ Post Move-In — 30 days ◌ Autopay enrollment reminder ◌ 30-day satisfaction survey ◌ Community event invitation └──────────────────────────────────────────┴───────────────────────────────┘

Routes: /onboarding (list all active), /onboarding/:id (detail), /onboarding/:id/chat (tenant FAQ).

☑ Acceptance Checklist — 4.6 Tenant Onboarding Automation

Phase 5 — Operations
5.1 AI Listing Syndication & Marketing Engine
Backend Frontend AI Celery
Why this feature matters:
After 4.1 creates beautiful staged photos, there is nothing to actually publish them. Right now, a property manager with a vacant unit has to manually log into Zillow, Apartments.com, Zumper, Facebook Marketplace, Craigslist, and half a dozen other sites, copy-paste descriptions (re-formatting for each platform's character limits and layout quirks), upload photos one by one, and set pricing. When the unit finally leases, they have to remember to go back and delist on every platform individually. They forget. Stale listings generate ghost inquiries, waste time, and damage credibility.

82% of renters search on 3 or more platforms. If your listing is only on one, you are invisible to most of your market. This feature publishes to 20+ platforms simultaneously with AI-optimized content per platform — Zillow gets a different headline than Craigslist because the audiences and SEO rules differ. Every inquiry from every platform lands in a single unified lead inbox with AI scoring: "A" leads (high income, urgent move-in, strong message) float to the top while tire-kickers get a polite auto-response. Analytics track which platforms produce actual applications vs just clicks. When a listing goes stale (14+ days, below-average views), the AI suggests pricing adjustments backed by real-time competitor analysis. And when the unit is leased, every listing auto-delists instantly. One click to publish everywhere, one inbox for all leads, zero stale listings.

Create listings from staged photos or unit data. Publish to multiple platforms with AI-optimized content. Unified lead inbox with AI scoring. Performance analytics with pricing recommendations. Auto-delist on lease execution.

B Django Models

# syndication/models.py

class Listing(models.Model):
    """A rental listing published to one or more platforms."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    unit = models.ForeignKey('units.UnitsUnit', on_delete=models.CASCADE, related_name='listings')
    property = models.ForeignKey('properties.Properties', on_delete=models.CASCADE, related_name='listings')
    turnover_case = models.ForeignKey(
        'turnover.TurnoverCase', on_delete=models.SET_NULL,
        null=True, blank=True, related_name='listings'
    )
    staging_project = models.ForeignKey(
        'staging.StagingProject', on_delete=models.SET_NULL,
        null=True, blank=True, related_name='listings'
    )

    status = models.CharField(max_length=20, default='draft', choices=[
        ('draft', 'Draft'),
        ('active', 'Active'),
        ('paused', 'Paused'),
        ('delisted', 'Delisted'),
    ], db_index=True)

    asking_rent = models.DecimalField(max_digits=10, decimal_places=2)
    title = models.CharField(max_length=200)
    description = models.TextField()
    photos = models.JSONField(default=list)
    # [{"url": "...", "caption": "...", "order": 0}]
    amenities = models.JSONField(default=list)
    # ["in_unit_laundry", "dishwasher", "pool", "gym", "parking"]
    pet_policy = models.CharField(max_length=100, blank=True, default='')
    parking = models.CharField(max_length=100, blank=True, default='')

    published_at = models.DateTimeField(null=True, blank=True)
    delisted_at = models.DateTimeField(null=True, blank=True)
    total_views = models.IntegerField(default=0)
    total_inquiries = models.IntegerField(default=0)
    days_on_market = models.IntegerField(default=0)

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'listing'
        ordering = ['-created_at']
        indexes = [
            models.Index(fields=['status', '-created_at']),
            models.Index(fields=['property', 'status']),
        ]


class ListingPlatform(models.Model):
    """Platform-specific syndication record for a listing."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    listing = models.ForeignKey(Listing, on_delete=models.CASCADE, related_name='platforms')

    platform_name = models.CharField(max_length=30, choices=[
        ('zillow', 'Zillow'),
        ('apartments_com', 'Apartments.com'),
        ('zumper', 'Zumper'),
        ('facebook', 'Facebook Marketplace'),
        ('craigslist', 'Craigslist'),
        ('realtor_com', 'Realtor.com'),
        ('hotpads', 'HotPads'),
        ('trulia', 'Trulia'),
        ('rent_com', 'Rent.com'),
    ])
    platform_listing_url = models.CharField(max_length=500, blank=True, default='')
    syndication_status = models.CharField(max_length=20, default='pending', choices=[
        ('pending', 'Pending'),
        ('active', 'Active'),
        ('failed', 'Failed'),
        ('delisted', 'Delisted'),
    ])
    platform_specific_content = models.JSONField(default=dict)
    # {"title": "...", "description": "...", "char_limit": 500}
    last_synced_at = models.DateTimeField(null=True, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'listing_platform'
        unique_together = ['listing', 'platform_name']


class ListingLead(models.Model):
    """Inbound lead from any platform."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    listing = models.ForeignKey(Listing, on_delete=models.CASCADE, related_name='leads')
    source_platform = models.CharField(max_length=30)

    prospect_name = models.CharField(max_length=200)
    email = models.EmailField(blank=True, default='')
    phone = models.CharField(max_length=20, blank=True, default='')
    message = models.TextField(blank=True, default='')
    move_in_date = models.DateField(null=True, blank=True)
    stated_income = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)

    lead_score = models.IntegerField(default=0)  # 0-100
    lead_grade = models.CharField(max_length=1, default='C', choices=[
        ('A', 'A — High Quality'),
        ('B', 'B — Good'),
        ('C', 'C — Average'),
        ('D', 'D — Low Quality'),
    ])
    qualification_factors = models.JSONField(default=dict)
    # {"income_signal": 0.8, "urgency": 0.9, "message_quality": 0.7}

    status = models.CharField(max_length=20, default='new', choices=[
        ('new', 'New'),
        ('contacted', 'Contacted'),
        ('touring', 'Touring'),
        ('applied', 'Applied'),
        ('closed', 'Closed'),
    ])
    ai_suggested_response = models.TextField(blank=True, default='')

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'listing_lead'
        ordering = ['-lead_score', '-created_at']
        indexes = [
            models.Index(fields=['listing', 'status']),
            models.Index(fields=['lead_grade', '-created_at']),
        ]


class ListingAnalytics(models.Model):
    """Daily analytics per listing per platform."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    listing = models.ForeignKey(Listing, on_delete=models.CASCADE, related_name='analytics')
    platform = models.ForeignKey(ListingPlatform, on_delete=models.CASCADE, related_name='analytics')
    date = models.DateField()

    views = models.IntegerField(default=0)
    inquiries = models.IntegerField(default=0)
    favorites = models.IntegerField(default=0)
    applications = models.IntegerField(default=0)
    conversion_rate = models.FloatField(default=0.0)

    class Meta:
        db_table = 'listing_analytics'
        unique_together = ['listing', 'platform', 'date']
        ordering = ['-date']


class CompetingListing(models.Model):
    """Nearby competing listing for pricing analysis."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    listing = models.ForeignKey(Listing, on_delete=models.CASCADE, related_name='competitors')

    competitor_address = models.CharField(max_length=300)
    competitor_rent = models.DecimalField(max_digits=10, decimal_places=2)
    competitor_bedrooms = models.IntegerField()
    competitor_sqft = models.IntegerField(null=True, blank=True)
    competitor_amenities = models.JSONField(default=list)
    price_diff_pct = models.FloatField(default=0.0)
    feature_advantages = models.JSONField(default=list)
    # ["in_unit_laundry", "newer_appliances", "covered_parking"]

    scraped_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'competing_listing'
        ordering = ['price_diff_pct']

B API Endpoints

MethodPathDescription
POST/api/v1/listings/Create listing from staging project or unit data.
GET/api/v1/listings/List all listings. Filter: status, property, days_on_market.
GET/api/v1/listings/{id}/Listing detail with platform statuses.
POST/api/v1/listings/{id}/publish/Publish to selected platforms. Body: {"platforms": ["zillow","apartments_com","zumper"]}
POST/api/v1/listings/{id}/delist/Delist from all platforms immediately.
POST/api/v1/listings/{id}/optimize/AI re-optimizes title + description per platform.
GET/api/v1/listings/{id}/analytics/Performance data by platform and date range.
GET/api/v1/listings/leads/Unified lead inbox. Filter: listing, grade, status.
POST/api/v1/listings/leads/{id}/respond/Send response to lead (email/SMS).
POST/api/v1/listings/leads/{id}/schedule-tour/Schedule showing from lead.
GET/api/v1/listings/{id}/competitors/Nearby comparable listings with price diff.
POST/api/v1/listings/{id}/price-check/AI pricing recommendation based on comps + performance.
GET/api/v1/listings/stats/Portfolio-level syndication dashboard stats.

POST /api/v1/listings/{id}/publish/

// Request:
{
  "platforms": ["zillow", "apartments_com", "zumper", "facebook"]
}

// Response 200:
{
  "id": "uuid",
  "status": "active",
  "published_at": "2026-03-28T14:00:00Z",
  "platforms": [
    {"platform_name": "zillow", "syndication_status": "active", "platform_listing_url": "https://zillow.com/..."},
    {"platform_name": "apartments_com", "syndication_status": "active", "platform_listing_url": "https://apartments.com/..."},
    {"platform_name": "zumper", "syndication_status": "pending", "platform_listing_url": null},
    {"platform_name": "facebook", "syndication_status": "active", "platform_listing_url": "https://facebook.com/marketplace/..."}
  ]
}

POST /api/v1/listings/{id}/price-check/

// Response 200:
{
  "current_rent": 1900.00,
  "recommended_rent": 1825.00,
  "confidence": 0.87,
  "reasoning": "Listing has been active for 18 days with below-average views. 4 comparable units within 0.5 miles are priced $50-100 lower. Recommend a 4% reduction to align with market.",
  "comparables_count": 6,
  "market_median": 1850.00,
  "days_on_market": 18,
  "view_percentile": 22
}

GET /api/v1/listings/stats/

// Response 200:
{
  "active_listings": 14,
  "total_leads_this_month": 87,
  "avg_days_on_market": 11.4,
  "overall_conversion_rate": 0.034,
  "leads_by_grade": {"A": 12, "B": 28, "C": 31, "D": 16},
  "top_platform": "apartments_com",
  "top_platform_leads": 34,
  "stale_listings": 3
}

C Celery Tasks

Task: syndication.tasks.sync_platform_analytics

Schedule: Every 6 hours

Purpose: Pull view/inquiry/favorite counts from each platform API and update ListingAnalytics.

Task: syndication.tasks.auto_delist_leased_units

Schedule: Every 30 minutes

Purpose: Query all active listings where the unit now has an active lease. Delist from all platforms. Update listing status.

Task: syndication.tasks.flag_stale_listings

Schedule: Daily at 08:00 AM UTC

Purpose: Flag listings with days_on_market > 14 and views below the 25th percentile. Trigger AI price-check recommendation.

A Claude API — Listing Optimization

LISTING_OPTIMIZATION_PROMPT = """Optimize this rental listing for {platform_name}.

Original listing:
Title: {title}
Description: {description}
Rent: ${rent}/month
Bedrooms: {bedrooms} | Bathrooms: {bathrooms} | Sqft: {sqft}
Amenities: {amenities}
Pet Policy: {pet_policy}

Platform constraints:
- Title max: {title_char_limit} characters
- Description max: {description_char_limit} characters
- Platform audience: {audience_profile}

Rules:
1. Front-load the most searched keywords for this platform:
   "pet-friendly", "in-unit laundry", "spacious", "updated kitchen",
   "near {neighborhood}", "move-in ready"
2. Use platform-appropriate tone (Zillow=professional, Craigslist=casual,
   Facebook=conversational)
3. Highlight differentiators vs typical listings in this price range
4. Include a clear call-to-action

Return JSON:
{
  "optimized_title": "string",
  "optimized_description": "string",
  "keywords_used": ["string"],
  "seo_score": 0-100
}"""
LEAD_QUALIFICATION_PROMPT = """Score this rental inquiry lead 0-100.

Lead message: "{message}"
Stated move-in date: {move_in_date}
Stated income: {stated_income}
Listing rent: ${rent}/month
Source platform: {platform}

Scoring criteria:
- Income signal (30%): mentions income, employment, or profession
- Urgency (25%): immediate need, specific move-in date, motivated language
- Message quality (20%): detailed, asks specific questions, professional tone
- Timeline fit (15%): move-in date within 30 days = high, 30-60 = medium
- Completeness (10%): provided phone, email, and full name

Return JSON:
{
  "score": 0-100,
  "grade": "A|B|C|D",
  "factors": {
    "income_signal": 0.0-1.0,
    "urgency": 0.0-1.0,
    "message_quality": 0.0-1.0,
    "timeline_fit": 0.0-1.0,
    "completeness": 0.0-1.0
  },
  "suggested_response": "string",
  "reasoning": "string"
}"""
COMPETITOR_ANALYSIS_PROMPT = """Analyze this listing vs nearby competitors.

Our listing:
- Rent: ${our_rent}/month
- {bedrooms}BR/{bathrooms}BA, {sqft} sqft
- Amenities: {our_amenities}
- Days on market: {dom}

Competitors:
{competitor_list}

Provide:
1. Price positioning (are we above, at, or below market?)
2. Feature advantages we should highlight
3. Feature gaps that competitors offer
4. Recommended price adjustment (if any) with reasoning
5. Marketing angle to differentiate

Return JSON:
{
  "price_position": "above_market|at_market|below_market",
  "price_diff_vs_median_pct": float,
  "advantages": ["string"],
  "gaps": ["string"],
  "recommended_rent": decimal,
  "recommended_adjustment_pct": float,
  "marketing_angle": "string",
  "reasoning": "string"
}"""

F Screen Wireframes

Syndication Dashboard

┌─────────────────────────────────────────────────────────────────────────────┐ Listing Syndication [+ New Listing] ├─────────────────────────────────────────────────────────────────────────────┤ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Active │ │ Leads │ │ Avg DOM │ │ Conv % │ │ Listings │ │ This Mo │ │ │ │ │ │ 14 │ │ 87 │ │ 11.4d │ │ 3.4% │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ ├───────────────────────────────────────────────┬─────────────────────────────┤ Listings [Active ▾] Lead Inbox 87 new ├───────────────────────────────────────────────┤─────────────────────────────┤ Unit A-401 — 1BR/1BA $1,900 A Sarah K. — Zillow Z A Zu FB 11 days 42 views 8 leads "Looking for a 1BR, move in April 1. Income $85k." Unit C-207 — 2BR/2BA $2,400 [Respond] [Schedule Tour] Z A Zu FB 3 days 18 views 2 leads B Mike R. — Apartments.com Unit B-112 — Studio $1,350 STALE 18d "Is this still available?" Z A 18 days 9 views 0 leads [Reprice] [Respond] [Schedule Tour] └───────────────────────────────────────────────┴─────────────────────────────┘

Routes: /syndication (dashboard + lead inbox), /syndication/:listingId (listing detail with analytics + competitors), /syndication/leads (full lead inbox view).

☑ Acceptance Checklist — 5.1 AI Listing Syndication

5.2 Smart Showing & Tour Management
Backend Frontend AI Celery
Why this feature matters:
Between listing and application, prospects need to physically see the unit. This is where most leasing funnels leak. The property manager plays phone tag scheduling tours — calling, leaving voicemails, texting back and forth to find a time that works. Then 20% of scheduled tours end in a no-show, wasting the manager's time and delaying the unit's lease-up. Meanwhile, the best prospects — the ones with jobs, busy schedules, and money — want to tour on their schedule, not during your office hours.

Self-guided tours with identity verification solve this. The prospect uploads an ID and selfie, picks a time slot, and receives a one-time access code that works for exactly 60 minutes. No staff required. The AI optimizes time slots for highest conversion (Saturday 10am–2pm converts 3x better than Tuesday 4pm), predicts no-shows based on engagement signals and history, and sends a smart reminder sequence at 24h, 2h, and 30 minutes. After the tour, the prospect gets a quick feedback form. The AI analyzes responses with NLP: "loved the kitchen" and "closets too small" become structured themes that inform future staging and marketing. Prospects who toured but haven't applied get automatic nurture messages: "3 other people toured Unit A-401 this week — schedule your application before it's gone." The entire showing-to-application funnel becomes instrumented, optimized, and largely automated.

Schedule guided and self-guided showings from leads. Identity verification for keyless access. No-show prediction with smart reminders. Post-tour feedback with NLP analysis. Conversion funnel tracking. Prospect nurturing automation.

B Django Models

# showings/models.py

class Prospect(models.Model):
    """A prospective tenant who has expressed interest."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    listing = models.ForeignKey(
        'syndication.Listing', on_delete=models.SET_NULL,
        null=True, blank=True, related_name='prospects'
    )

    name = models.CharField(max_length=200)
    email = models.EmailField()
    phone = models.CharField(max_length=20, blank=True, default='')

    pre_screening = models.JSONField(default=dict)
    # {"income_range": "75k-100k", "move_in_date": "2026-04-15",
    #  "pets": true, "occupants": 2, "employment": "full_time"}

    identity_verified = models.BooleanField(default=False)
    id_document_url = models.CharField(max_length=500, blank=True, default='')
    selfie_url = models.CharField(max_length=500, blank=True, default='')

    lead_score = models.IntegerField(default=0)
    nurture_stage = models.CharField(max_length=20, default='new', choices=[
        ('new', 'New'),
        ('engaged', 'Engaged'),
        ('touring', 'Touring'),
        ('applied', 'Applied'),
        ('lost', 'Lost'),
    ])
    converted_to_application = models.ForeignKey(
        'screening.ScreeningApplication', on_delete=models.SET_NULL,
        null=True, blank=True, related_name='source_prospect'
    )

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'prospect'
        ordering = ['-lead_score', '-created_at']


class Showing(models.Model):
    """A scheduled tour of a unit."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    listing = models.ForeignKey('syndication.Listing', on_delete=models.CASCADE, related_name='showings')
    prospect = models.ForeignKey(Prospect, on_delete=models.CASCADE, related_name='showings')

    showing_type = models.CharField(max_length=15, choices=[
        ('guided', 'Guided Tour'),
        ('self_guided', 'Self-Guided'),
        ('virtual', 'Virtual Tour'),
    ])

    scheduled_at = models.DateTimeField()
    duration_minutes = models.IntegerField(default=60)

    # Self-guided access
    access_code = models.CharField(max_length=8, blank=True, default='')
    access_code_expires_at = models.DateTimeField(null=True, blank=True)

    status = models.CharField(max_length=20, default='scheduled', choices=[
        ('scheduled', 'Scheduled'),
        ('confirmed', 'Confirmed'),
        ('in_progress', 'In Progress'),
        ('completed', 'Completed'),
        ('no_show', 'No Show'),
        ('cancelled', 'Cancelled'),
    ])

    no_show_risk_score = models.FloatField(default=0.0)  # 0.0-1.0

    # Reminders
    reminder_24h_sent = models.BooleanField(default=False)
    reminder_2h_sent = models.BooleanField(default=False)
    reminder_30m_sent = models.BooleanField(default=False)

    conversion_outcome = models.CharField(max_length=20, blank=True, default='', choices=[
        ('applied', 'Applied'),
        ('not_interested', 'Not Interested'),
        ('still_considering', 'Still Considering'),
        ('no_response', 'No Response'),
    ])

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'showing'
        ordering = ['scheduled_at']
        indexes = [
            models.Index(fields=['listing', 'status']),
            models.Index(fields=['scheduled_at']),
            models.Index(fields=['prospect', '-scheduled_at']),
        ]


class ShowingFeedback(models.Model):
    """Post-tour feedback from a prospect."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    showing = models.OneToOneField(Showing, on_delete=models.CASCADE, related_name='feedback')

    overall_rating = models.IntegerField()  # 1-5
    liked = models.JSONField(default=list)
    # ["kitchen", "natural_light", "closet_space"]
    disliked = models.JSONField(default=list)
    # ["parking", "noise", "bathroom_size"]
    comments = models.TextField(blank=True, default='')

    sentiment_score = models.FloatField(default=0.0)  # -1.0 to 1.0
    key_themes = models.JSONField(default=list)
    # [{"theme": "kitchen", "sentiment": "positive", "count": 1}]
    would_apply = models.BooleanField(null=True)
    price_perception = models.CharField(max_length=20, blank=True, default='', choices=[
        ('great_value', 'Great Value'),
        ('fair', 'Fair'),
        ('too_high', 'Too High'),
    ])

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'showing_feedback'


class ShowingSlotConfig(models.Model):
    """Configurable time slots per property."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    property = models.ForeignKey('properties.Properties', on_delete=models.CASCADE, related_name='showing_slots')

    day_of_week = models.IntegerField()  # 0=Monday, 6=Sunday
    start_time = models.TimeField()
    end_time = models.TimeField()
    max_per_day = models.IntegerField(default=8)
    slot_duration_minutes = models.IntegerField(default=60)
    is_self_guided_allowed = models.BooleanField(default=True)

    class Meta:
        db_table = 'showing_slot_config'
        unique_together = ['property', 'day_of_week']
        ordering = ['day_of_week', 'start_time']


class NurtureMessage(models.Model):
    """Automated follow-up message to a prospect."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    prospect = models.ForeignKey(Prospect, on_delete=models.CASCADE, related_name='nurture_messages')

    message_type = models.CharField(max_length=30, choices=[
        ('interest_followup', 'Interest Follow-up'),
        ('urgency', 'Urgency'),
        ('price_drop', 'Price Drop'),
        ('similar_unit', 'Similar Unit Available'),
    ])
    content = models.TextField()
    channel = models.CharField(max_length=10, default='email')
    # 'email', 'sms'

    sent_at = models.DateTimeField(null=True, blank=True)
    opened_at = models.DateTimeField(null=True, blank=True)
    clicked_at = models.DateTimeField(null=True, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'nurture_message'
        ordering = ['-created_at']

B API Endpoints

MethodPathDescription
POST/api/v1/showings/Schedule a showing from a lead. Body: {"listing_id", "prospect_id", "showing_type", "scheduled_at"}
GET/api/v1/showings/List showings. Filter: listing, date range, status, showing_type.
POST/api/v1/showings/{id}/confirm/Prospect confirms attendance.
POST/api/v1/showings/{id}/cancel/Cancel a showing.
POST/api/v1/showings/{id}/complete/Mark showing as completed.
GET/api/v1/showings/calendar/Calendar view with all showings for a date range.
POST/api/v1/showings/{id}/access-code/Generate one-time self-guided access code (8 chars, 60-min expiry).
GET/api/v1/showings/slots/Available time slots. Query: ?property={id}&date={d}
POST/api/v1/prospects/Create prospect with pre-screening data.
POST/api/v1/prospects/{id}/verify-identity/Upload government ID + selfie for self-guided access.
POST/api/v1/showings/{id}/feedback/Submit post-tour feedback (rating, liked, disliked, comments).
GET/api/v1/showings/feedback/themes/NLP theme analysis. Query: ?listing={id}
GET/api/v1/showings/funnel/Conversion funnel stats: leads → tours → feedback → applications.

POST /api/v1/showings/{id}/access-code/

// Response 200:
{
  "showing_id": "uuid",
  "access_code": "X7K9M2PQ",
  "expires_at": "2026-03-28T15:00:00Z",
  "duration_minutes": 60,
  "identity_verified": true,
  "instructions": "Enter code on the smart lock keypad. Code is valid from 2:00 PM to 3:00 PM."
}

GET /api/v1/showings/funnel/

// Response 200:
{
  "period": "last_30_days",
  "leads": 87,
  "tours_scheduled": 42,
  "tours_completed": 34,
  "no_shows": 8,
  "no_show_rate": 0.19,
  "feedback_submitted": 28,
  "applications": 11,
  "lead_to_tour_rate": 0.483,
  "tour_to_apply_rate": 0.324,
  "overall_conversion_rate": 0.126,
  "avg_no_show_risk_score": 0.21,
  "by_type": {
    "guided": {"scheduled": 24, "completed": 22, "no_show": 2, "applied": 7},
    "self_guided": {"scheduled": 14, "completed": 10, "no_show": 4, "applied": 3},
    "virtual": {"scheduled": 4, "completed": 2, "no_show": 2, "applied": 1}
  }
}

C Celery Tasks

Task: showings.tasks.send_showing_reminders

Schedule: Every 15 minutes

Purpose: Check upcoming showings. Send 24h, 2h, and 30-min reminders via email/SMS. Mark reminder flags on Showing model.

Task: showings.tasks.mark_no_shows

Schedule: Every 30 minutes

Purpose: Find showings where scheduled_at + duration_minutes < now() and status is still 'confirmed' or 'scheduled'. Mark as 'no_show'. Update prospect nurture_stage.

Task: showings.tasks.send_nurture_messages

Schedule: Daily at 10:00 AM UTC

Purpose: For prospects in 'touring' stage who completed a showing 48+ hours ago without applying: generate and send AI nurture message. Create urgency based on other tour activity on the listing.

A Claude API — Feedback NLP & Nurture

FEEDBACK_NLP_PROMPT = """Analyze this post-tour feedback for a rental unit.

Prospect name: {name}
Unit: {unit_number} at {property_name}
Overall rating: {rating}/5
Liked: {liked}
Disliked: {disliked}
Comments: "{comments}"
Would apply: {would_apply}
Price perception: {price_perception}

Extract:
1. Key themes with sentiment (positive/negative/neutral)
2. Actionable improvements for the listing or unit
3. Likelihood to apply (0-100%)
4. Suggested follow-up approach

Return JSON:
{
  "themes": [
    {"theme": "string", "sentiment": "positive|negative|neutral", "detail": "string"}
  ],
  "improvements": ["string"],
  "apply_likelihood": 0-100,
  "follow_up_approach": "string",
  "sentiment_score": -1.0 to 1.0
}"""
NURTURE_MESSAGE_PROMPT = """Write a personalized follow-up message for this prospect.

Prospect: {name}
Tour date: {tour_date}
Unit: {unit_number} — {bedrooms}BR/{bathrooms}BA, ${rent}/month
Tour feedback: {feedback_summary}
Days since tour: {days_since}
Other tours this week: {other_tour_count}
Nurture type: {message_type}

Guidelines:
- Be warm and personal, not salesy
- Reference something specific from their tour
- Create appropriate urgency without pressure
- Include a clear call-to-action (apply link)
- Keep under 150 words for SMS, 250 for email

Return JSON:
{
  "subject": "string (email only)",
  "content": "string",
  "channel": "email|sms",
  "urgency_level": "low|medium|high"
}"""

No-Show Prediction Model

NO_SHOW_PREDICTION_FACTORS = {
    "historical_attendance": 0.30,
    # Prior showing history: always shows (0.0) to always no-shows (1.0)

    "engagement_signals": 0.25,
    # Did they confirm? Open reminder emails? Click links?
    # High engagement = low risk

    "distance_estimate": 0.15,
    # Estimated distance from prospect address to property
    # Further = higher risk

    "response_speed": 0.15,
    # Time from lead inquiry to tour scheduling
    # Fast response = low risk, slow = high risk

    "lead_quality": 0.15,
    # Original lead score from 5.1
    # A-grade = low risk, D-grade = high risk
}

# Risk thresholds:
# 0.0 - 0.2 = Low risk (green)
# 0.2 - 0.5 = Medium risk (amber)
# 0.5 - 1.0 = High risk (red) — consider overbooking slot

F Screen Wireframes

Showings Calendar & Funnel

┌─────────────────────────────────────────────────────────────────────────────┐ Showings [+ Schedule] [Week ▾] ├─────────────────────────────────────────────────────────────────────────────┤ Mon 24 Tue 25 Wed 26 Thu 27 Fri 28 Sat 29 ───────── ───────── ───────── ───────── ───────── ───────── 10:00 10:00 10:00 10:00 Guided Self Guided Guided Sarah K. Mike R. Lisa W. James T. risk: 42% risk: 8% 14:00 14:00 14:00 Virtual Self Self Amy B. David H. Priya S. ├────────────────────────────────────┬────────────────────────────────────────┤ Upcoming Showings Conversion Funnel (30 days) ├────────────────────────────────────┤────────────────────────────────────────┤ Guided Sarah K. — Today 10:00 AM Leads: 87 ████████████████ Unit A-401 · Risk: 5% Tours: 42 ████████ ✓ Confirmed · Reminders sent Completed: 34 ███████ Applications: 11 ██ Self Mike R. — Today 10:00 AM Unit C-207 · Risk: 42% No-show rate: 19% ⚠ No confirmation yet Tour → Apply: 32.4% Code: X7K9M2PQ exp 11:00 AM └────────────────────────────────────┴────────────────────────────────────────┘

Routes: /showings (calendar + upcoming list), /showings/:id (showing detail with feedback), /showings/funnel (conversion analytics), /prospects/:id (prospect profile with tour history).

☑ Acceptance Checklist — 5.2 Smart Showing & Tour Management

5.3 Tenant Communications Intelligence Hub
Backend Frontend AI Celery
Why this feature matters:
The internal portfolio chat (3.3) answers staff questions about properties and financials. But tenants — the people actually living in these units — text the PM's personal WhatsApp, email the leasing office, call the front desk, and post on the resident portal. None of these channels are connected. A tenant texts "when is my rent due?" to the manager's phone at 9pm. The manager sees it in the morning, types a response, and moves on. No record in the system. No audit trail. No way to see patterns.

This creates a unified inbox across SMS, email, WhatsApp, and in-app messaging. The AI handles 80% of routine questions instantly — "When is my rent due?" gets an auto-response with the tenant's actual rent amount, due date, and payment link, pulled from the database. "My sink is leaking" gets classified as a maintenance request, and the AI auto-creates a categorized work order (plumbing, urgent) with no human intervention. But the real power is in sentiment analysis: the system tracks every tenant's communication sentiment over time. When Sarah in B-205 goes from positive ("love the new gym hours!") to negative ("this is the third time I've asked about the leak") to hostile ("I'm contacting my lawyer"), the system flags her as high churn risk 60-90 days before she gives notice. Emergency keywords — "flood", "fire", "gas leak", "smoke" — trigger an immediate manager alert with no AI auto-response, because emergencies need a human.

Unified tenant inbox across SMS/email/WhatsApp/in-app. AI classifies messages, auto-responds to routine questions using real tenant data, creates work orders from maintenance messages, tracks sentiment trends, predicts churn, and escalates emergencies.

B Django Models

# communications/models.py

class TenantConversation(models.Model):
    """A conversation thread with a tenant across any channel."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    tenant = models.ForeignKey(
        'reconciliation.Buyer', on_delete=models.CASCADE,
        related_name='conversations'
    )
    unit = models.ForeignKey('units.UnitsUnit', on_delete=models.SET_NULL, null=True, related_name='conversations')

    channel = models.CharField(max_length=15, choices=[
        ('sms', 'SMS'),
        ('email', 'Email'),
        ('whatsapp', 'WhatsApp'),
        ('in_app', 'In-App'),
    ])

    status = models.CharField(max_length=20, default='active', choices=[
        ('active', 'Active'),
        ('waiting_staff', 'Waiting on Staff'),
        ('waiting_tenant', 'Waiting on Tenant'),
        ('resolved', 'Resolved'),
        ('archived', 'Archived'),
    ])

    category = models.CharField(max_length=20, default='general', choices=[
        ('maintenance', 'Maintenance'),
        ('billing', 'Billing'),
        ('lease', 'Lease'),
        ('complaint', 'Complaint'),
        ('emergency', 'Emergency'),
        ('general', 'General'),
    ])

    urgency = models.CharField(max_length=10, default='normal', choices=[
        ('critical', 'Critical'),
        ('high', 'High'),
        ('normal', 'Normal'),
        ('low', 'Low'),
    ])

    sentiment_score = models.FloatField(default=0.0)  # -1.0 to 1.0
    ai_auto_responded = models.BooleanField(default=False)
    assigned_to = models.CharField(max_length=100, blank=True, default='')
    escalated = models.BooleanField(default=False)
    escalation_reason = models.CharField(max_length=200, blank=True, default='')
    last_message_at = models.DateTimeField(null=True, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'tenant_conversation'
        ordering = ['-last_message_at']
        indexes = [
            models.Index(fields=['tenant', '-last_message_at']),
            models.Index(fields=['status', 'category']),
            models.Index(fields=['urgency', '-last_message_at']),
        ]


class TenantMessage(models.Model):
    """Individual message in a tenant conversation."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    conversation = models.ForeignKey(
        TenantConversation, on_delete=models.CASCADE, related_name='messages'
    )

    direction = models.CharField(max_length=10, choices=[
        ('inbound', 'Inbound'),
        ('outbound', 'Outbound'),
    ])
    channel = models.CharField(max_length=15)
    content = models.TextField()

    is_ai_generated = models.BooleanField(default=False)
    is_auto_sent = models.BooleanField(default=False)

    ai_classification = models.JSONField(default=dict)
    # {"category": "maintenance", "urgency": "high",
    #  "intent": "report_issue", "confidence": 0.94}

    sentiment = models.FloatField(default=0.0)  # -1.0 to 1.0
    suggested_response = models.TextField(blank=True, default='')
    read_at = models.DateTimeField(null=True, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'tenant_message'
        ordering = ['created_at']
        indexes = [
            models.Index(fields=['conversation', 'created_at']),
        ]


class CommunicationTemplate(models.Model):
    """Reusable message template for auto-responses and broadcasts."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    name = models.CharField(max_length=100)
    category = models.CharField(max_length=20)
    channel = models.CharField(max_length=15)
    content = models.TextField()
    variables = models.JSONField(default=list)
    # ["tenant_name", "rent_amount", "due_date", "unit_number"]
    auto_send = models.BooleanField(default=False)
    trigger_intent = models.CharField(max_length=50, blank=True, default='')
    # 'rent_due_date_query', 'maintenance_acknowledgment', etc.

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'communication_template'


class BroadcastMessage(models.Model):
    """Mass communication to multiple tenants."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    title = models.CharField(max_length=200)
    content = models.TextField()
    channels = models.JSONField(default=list)
    # ["email", "sms", "in_app"]

    recipient_filter = models.JSONField(default=dict)
    # {"property_id": "uuid", "building": "A", "unit_range": "100-199"}
    recipient_count = models.IntegerField(default=0)
    sent_count = models.IntegerField(default=0)
    read_count = models.IntegerField(default=0)

    status = models.CharField(max_length=20, default='draft', choices=[
        ('draft', 'Draft'),
        ('sending', 'Sending'),
        ('sent', 'Sent'),
    ])
    sent_at = models.DateTimeField(null=True, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'broadcast_message'
        ordering = ['-created_at']


class TenantSentimentLog(models.Model):
    """Daily rolling sentiment score for churn prediction."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    tenant = models.ForeignKey(
        'reconciliation.Buyer', on_delete=models.CASCADE,
        related_name='sentiment_logs'
    )
    date = models.DateField()

    sentiment_score = models.FloatField(default=0.0)  # rolling 30-day avg
    message_count = models.IntegerField(default=0)
    risk_level = models.CharField(max_length=10, default='low', choices=[
        ('low', 'Low'),
        ('medium', 'Medium'),
        ('high', 'High'),
        ('critical', 'Critical'),
    ])
    contributing_factors = models.JSONField(default=dict)
    # {"negative_billing_msgs": 3, "unresolved_maintenance": 2,
    #  "response_delay_avg_hours": 18}
    churn_probability = models.FloatField(default=0.0)  # 0.0-1.0

    class Meta:
        db_table = 'tenant_sentiment_log'
        unique_together = ['tenant', 'date']
        ordering = ['-date']

B API Endpoints

MethodPathDescription
GET/api/v2/communications/conversations/List conversations. Filter: status, category, urgency, tenant, property.
GET/api/v2/communications/conversations/{id}/Conversation detail with all messages.
POST/api/v2/communications/conversations/{id}/reply/Staff reply. Body: {"content": "...", "channel": "sms"}
POST/api/v2/communications/inbound/Webhook for inbound SMS/WhatsApp/email. Triggers AI classification + auto-response.
GET/api/v2/communications/conversations/stats/Inbox stats: open, waiting, resolved, avg response time.
GET/api/v2/communications/templates/List response templates.
POST/api/v2/communications/broadcasts/Create broadcast. Body: {"title", "content", "channels", "recipient_filter"}
POST/api/v2/communications/broadcasts/{id}/send/Send broadcast to filtered recipients.
GET/api/v2/communications/sentiment/at-risk/Tenants with high/critical churn risk. Sorted by churn_probability desc.
GET/api/v2/communications/sentiment/{tenant_id}/Sentiment history for a specific tenant (30/60/90-day trend).
POST/api/v2/communications/simulate/Demo simulation: sends mock inbound messages, triggers AI pipeline.

POST /api/v2/communications/inbound/ (webhook)

// Request (from SMS gateway / WhatsApp API):
{
  "channel": "sms",
  "from_number": "+1 (555) 234-5678",
  "content": "Hi, when is my rent due this month? And how much is it?",
  "timestamp": "2026-03-28T14:22:00Z"
}

// Response 200:
{
  "conversation_id": "uuid",
  "message_id": "uuid",
  "classification": {
    "category": "billing",
    "urgency": "normal",
    "intent": "rent_due_date_query",
    "confidence": 0.96
  },
  "auto_response": {
    "sent": true,
    "content": "Hi Sarah! Your rent of $1,850.00 is due on April 1st. You can pay online at your resident portal: https://portal.example.com/pay. Let me know if you need anything else!",
    "channel": "sms"
  }
}

GET /api/v2/communications/sentiment/at-risk/

// Response 200:
{
  "at_risk_tenants": [
    {
      "tenant_id": "uuid",
      "tenant_name": "James Rivera",
      "unit": "B-205",
      "property": "Ashford Farms",
      "risk_level": "critical",
      "churn_probability": 0.82,
      "sentiment_trend": [-0.1, -0.3, -0.5, -0.7],
      "contributing_factors": {
        "negative_billing_msgs": 4,
        "unresolved_maintenance": 2,
        "escalation_count": 1,
        "days_since_positive_msg": 45
      },
      "recommended_action": "Schedule in-person meeting. Address open maintenance tickets. Consider rent concession."
    }
  ],
  "total_at_risk": 7,
  "critical": 2,
  "high": 5
}

C Celery Tasks

Task: communications.tasks.process_inbound_message

Schedule: Real-time (triggered by webhook)

Purpose: Classify message with AI. Check for emergency keywords. Auto-respond if confidence > 0.85 and category is routine. Create work order if maintenance. Update conversation sentiment.

Task: communications.tasks.calculate_daily_sentiment

Schedule: Daily at 03:00 AM UTC

Purpose: For each tenant with messages in the last 30 days: calculate rolling sentiment average, update risk level, compute churn probability. Flag new at-risk tenants.

Task: communications.tasks.send_broadcast

Schedule: On-demand (triggered by API)

Purpose: Send broadcast to filtered recipients across selected channels. Track delivery and read receipts.

A Claude API — Message Classification & Auto-Response

MESSAGE_CLASSIFICATION_PROMPT = """Classify this tenant message.

Message: "{content}"
Channel: {channel}
Tenant: {tenant_name}, Unit {unit_number}, {property_name}
Previous messages in thread: {thread_context}

Classify:
1. Category: maintenance | billing | lease | complaint | emergency | general
2. Urgency: critical | high | normal | low
3. Intent: specific action the tenant wants
4. Sentiment: -1.0 (very negative) to 1.0 (very positive)

EMERGENCY KEYWORDS (always category=emergency, urgency=critical):
"flood", "fire", "gas leak", "smoke", "carbon monoxide", "break-in",
"burst pipe", "no heat" (if winter), "no AC" (if summer, 90°F+)

If maintenance, also extract:
- Issue type (plumbing, electrical, HVAC, appliance, structural, pest)
- Location in unit (kitchen, bathroom, bedroom, living room, exterior)
- Severity estimate

Return JSON:
{
  "category": "string",
  "urgency": "string",
  "intent": "string",
  "sentiment": float,
  "confidence": float,
  "is_emergency": boolean,
  "maintenance_details": {
    "issue_type": "string or null",
    "location": "string or null",
    "severity": "low|medium|high|critical"
  }
}"""
AUTO_RESPONSE_PROMPT = """Generate a helpful response to this tenant message
using their actual account data.

Tenant: {tenant_name}
Unit: {unit_number} at {property_name}
Message: "{content}"
Intent: {intent}

Tenant data:
- Monthly rent: ${rent_amount}
- Rent due day: {due_day} of each month
- Lease end date: {lease_end_date}
- Balance due: ${balance_due}
- Open maintenance requests: {open_maintenance_count}
- Payment portal URL: {portal_url}

Rules:
1. Be warm, professional, and concise
2. Use the tenant's first name
3. Include SPECIFIC data from their account (amounts, dates, links)
4. If you can fully answer, do so
5. If the request needs human action, say "I've notified the management team
   and someone will follow up within [timeframe]"
6. NEVER auto-respond to emergencies — return {"auto_respond": false}
7. NEVER make up information — only use provided data

Return JSON:
{
  "auto_respond": boolean,
  "content": "string",
  "confidence": float,
  "needs_human_followup": boolean,
  "followup_reason": "string or null"
}"""

Churn Prediction Model

CHURN_PREDICTION_FACTORS = {
    "sentiment_trend": 0.30,
    # Rolling 30-day sentiment average and trajectory
    # Declining trend = high risk

    "complaint_frequency": 0.25,
    # Number of complaint/negative messages per month
    # > 3/month = high risk

    "response_engagement": 0.20,
    # Does tenant open/read messages? Respond to follow-ups?
    # Declining engagement = risk signal

    "maintenance_satisfaction": 0.15,
    # Are maintenance requests resolved? Time to resolution?
    # Unresolved tickets = frustration

    "payment_patterns": 0.10,
    # Late payments, partial payments, disputed charges
    # Increasing lateness = potential churn
}

# Risk thresholds:
# churn_probability 0.0-0.3 = Low (green)
# churn_probability 0.3-0.5 = Medium (amber)
# churn_probability 0.5-0.7 = High (orange)
# churn_probability 0.7-1.0 = Critical (red) — immediate intervention

F Screen Wireframes

Communications Hub

┌────────────────────┬──────────────────────────────┬───────────────────────┐ Inbox 12 open Sarah Chen — B-205 Tenant Profile ├────────────────────┤ Billing · Normal ├──────────────────────────────┤ Sarah Chen ! James R. B-205 Unit B-205 Emergency 2m Sarah (SMS, 2:22 PM) Rent: $1,850 "When is my rent due this Due: 1st of month Sarah C. B-205 month? And how much?" Lease ends: Jun 2026 Billing 5m AI Auto-Response (SMS) Sentiment Mike T. A-102 "Hi Sarah! Your rent of 30d: +0.4 ▁▂▃▃▄ Maintenance 1h $1,850 is due April 1st. Risk: Low (12%) Pay online: portal.link" Lisa W. C-314 Open Tickets Resolved 2h None [Type a reply...] [View Full Profile] └────────────────────┴──────────────────────────────┴───────────────────────┘

Routes: /communications (inbox + conversation + profile), /communications/broadcasts (broadcast management), /communications/sentiment (at-risk tenant dashboard), /communications/templates (template management).

☑ Acceptance Checklist — 5.3 Tenant Communications Intelligence Hub

5.4 Vendor Performance Intelligence
Backend Frontend AI Celery
Why this feature matters:
Invoice processing (3.2) extracts and validates invoices — but it is one-and-done. The system has no memory. It doesn't know that Vendor A resolves 90% of issues on the first visit while Vendor B only manages 60%. It doesn't know the HVAC in Unit C-207 is still under warranty, so the property manager pays $800 for a repair that the manufacturer would have covered for free. It doesn't notice that Unit B-112 needed the same plumbing repair three weeks apart — a clear sign the first fix was botched. And when a new work order comes in, the manager dispatches based on gut feel ("call the usual plumber") rather than data.

This feature closes the loop. Every vendor gets a scorecard with six weighted metrics: response time, first-visit resolution rate, cost accuracy vs estimates, SLA compliance, tenant satisfaction, and documentation completeness. When a new work order comes in, the AI recommends the top 3 vendors — factoring in specialty match, performance score, current workload, and historical cost for similar jobs. Before dispatching, it checks the warranty database: "This dishwasher was installed 8 months ago with a 1-year full warranty — contact Whirlpool at 1-800-XXX-XXXX instead of dispatching a vendor." Price benchmarking compares every invoice against the portfolio average for that work category: "This $450 garbage disposal replacement is 35% above your average of $333 for the same job." Repeat visit detection catches botched repairs: "Unit B-112 had the same plumbing issue fixed 3 weeks ago by the same vendor — this may be a callback, not a new billable visit." Contract management tracks insurance and license expiry dates, alerting 30 days before a vendor's insurance lapses. The result: lower costs, better vendor selection, fewer warranty dollars left on the table, and accountability for repeat failures.

Vendor scorecards with 6 weighted metrics. AI-powered smart dispatch with warranty checking. Price benchmarking against portfolio averages. Repeat visit detection for botched repairs. Contract and insurance expiry tracking.

B Django Models

# vendor_intelligence/models.py

class VendorScorecard(models.Model):
    """Periodic performance scorecard for a vendor."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    vendor = models.ForeignKey(
        'invoices.Vendor', on_delete=models.CASCADE,
        related_name='scorecards'
    )

    period = models.CharField(max_length=10, choices=[
        ('monthly', 'Monthly'),
        ('quarterly', 'Quarterly'),
    ])
    period_start = models.DateField()
    period_end = models.DateField()

    # Six core metrics (each 0-10)
    response_time_score = models.FloatField(default=0.0)
    first_visit_resolution_rate = models.FloatField(default=0.0)  # percentage
    first_visit_score = models.FloatField(default=0.0)
    cost_accuracy_score = models.FloatField(default=0.0)
    sla_compliance_score = models.FloatField(default=0.0)
    tenant_satisfaction_score = models.FloatField(default=0.0)
    documentation_score = models.FloatField(default=0.0)

    # Weighted overall
    overall_score = models.FloatField(default=0.0)  # 0-10

    # Volume
    total_jobs = models.IntegerField(default=0)
    total_invoiced_usd = models.DecimalField(max_digits=12, decimal_places=2, default=0)

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'vendor_scorecard'
        ordering = ['-period_start']
        unique_together = ['vendor', 'period', 'period_start']


class VendorDispatchRecommendation(models.Model):
    """AI recommendation for which vendor to dispatch."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    work_order = models.ForeignKey(
        'units.UnitsMaintenance', on_delete=models.CASCADE,
        related_name='dispatch_recommendations'
    )
    recommended_vendor = models.ForeignKey(
        'invoices.Vendor', on_delete=models.CASCADE,
        related_name='dispatch_recommendations'
    )

    rank = models.IntegerField(default=1)  # 1=top recommendation
    score = models.FloatField(default=0.0)
    reasoning = models.JSONField(default=dict)
    # {"specialty_match": 0.95, "performance": 8.2,
    #  "workload": "2 open jobs", "cost_history": "avg $340 for similar"}
    alternatives = models.JSONField(default=list)
    # [{"vendor_id": "uuid", "name": "...", "score": 7.8}]

    warranty_check_result = models.JSONField(default=dict)
    # {"has_warranty": true, "warranty_vendor": "Whirlpool",
    #  "warranty_end": "2027-01-15", "coverage": "full",
    #  "contact": "1-800-253-1301"}

    status = models.CharField(max_length=20, default='pending', choices=[
        ('pending', 'Pending'),
        ('accepted', 'Accepted'),
        ('rejected', 'Rejected'),
    ])

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'vendor_dispatch_recommendation'
        ordering = ['work_order', 'rank']


class WarrantyRecord(models.Model):
    """Warranty tracking for unit equipment and installations."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    unit = models.ForeignKey('units.UnitsUnit', on_delete=models.CASCADE, related_name='warranties')

    item_type = models.CharField(max_length=100)
    # 'HVAC', 'Dishwasher', 'Water Heater', 'Roof', 'Refrigerator',
    # 'Washer', 'Dryer', 'Garbage Disposal', 'Microwave', 'Oven'
    brand = models.CharField(max_length=100)
    model_number = models.CharField(max_length=100, blank=True, default='')
    installed_date = models.DateField()
    warranty_end_date = models.DateField()
    warranty_vendor = models.ForeignKey(
        'invoices.Vendor', on_delete=models.SET_NULL,
        null=True, blank=True, related_name='warranty_installations'
    )
    warranty_provider = models.CharField(max_length=200)
    # Manufacturer name or extended warranty company
    coverage_type = models.CharField(max_length=20, choices=[
        ('full', 'Full Coverage'),
        ('parts_only', 'Parts Only'),
        ('labor_only', 'Labor Only'),
    ])
    status = models.CharField(max_length=15, default='active', choices=[
        ('active', 'Active'),
        ('expired', 'Expired'),
        ('claimed', 'Claimed'),
    ])
    claim_history = models.JSONField(default=list)
    # [{"date": "2026-02-10", "issue": "...", "resolution": "...", "cost_saved": 800}]

    class Meta:
        db_table = 'warranty_record'
        ordering = ['warranty_end_date']
        indexes = [
            models.Index(fields=['unit', 'item_type']),
            models.Index(fields=['status', 'warranty_end_date']),
        ]


class VendorContract(models.Model):
    """Contract and compliance document tracking."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    vendor = models.ForeignKey(
        'invoices.Vendor', on_delete=models.CASCADE,
        related_name='contracts'
    )

    contract_type = models.CharField(max_length=50)
    # 'service_agreement', 'insurance_certificate', 'license', 'w9'
    title = models.CharField(max_length=200)
    start_date = models.DateField()
    end_date = models.DateField()
    insurance_expiry = models.DateField(null=True, blank=True)
    license_expiry = models.DateField(null=True, blank=True)
    auto_renew = models.BooleanField(default=False)

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'vendor_contract'
        ordering = ['end_date']


class RepeatVisitAlert(models.Model):
    """Flags when the same unit has the same issue within 30 days."""
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    unit = models.ForeignKey('units.UnitsUnit', on_delete=models.CASCADE, related_name='repeat_alerts')

    work_category = models.CharField(max_length=50)
    first_visit_date = models.DateField()
    first_visit_vendor = models.ForeignKey(
        'invoices.Vendor', on_delete=models.SET_NULL,
        null=True, related_name='first_visit_alerts'
    )
    repeat_visit_date = models.DateField()
    repeat_visit_vendor = models.ForeignKey(
        'invoices.Vendor', on_delete=models.SET_NULL,
        null=True, related_name='repeat_visit_alerts'
    )
    days_between = models.IntegerField()

    resolution_status = models.CharField(max_length=20, default='open', choices=[
        ('open', 'Open'),
        ('warranty_claim', 'Warranty Claim Filed'),
        ('vendor_credited', 'Vendor Credited'),
        ('different_issue', 'Different Issue'),
        ('dismissed', 'Dismissed'),
    ])

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = 'repeat_visit_alert'
        ordering = ['-repeat_visit_date']


class PriceBenchmark(models.Model):
    """Aggregate pricing benchmarks by work category."""
    work_category = models.CharField(max_length=50, primary_key=True)

    average_cost_usd = models.DecimalField(max_digits=10, decimal_places=2)
    median_cost_usd = models.DecimalField(max_digits=10, decimal_places=2)
    min_cost_usd = models.DecimalField(max_digits=10, decimal_places=2)
    max_cost_usd = models.DecimalField(max_digits=10, decimal_places=2)
    sample_count = models.IntegerField(default=0)
    vendor_count = models.IntegerField(default=0)

    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'price_benchmark'

B API Endpoints

MethodPathDescription
GET/api/v2/vendor-intelligence/scorecards/List scorecards. Filter: vendor, period, min_score.
GET/api/v2/vendor-intelligence/scorecards/{vendor_id}/Vendor scorecard detail with metric breakdown and trend.
POST/api/v2/vendor-intelligence/scorecards/calculate/Trigger scorecard recalculation for all vendors (current period).
GET/api/v2/vendor-intelligence/dispatch/{work_order_id}/Get AI dispatch recommendation (top 3 vendors + warranty check).
POST/api/v2/vendor-intelligence/dispatch/{work_order_id}/accept/Accept recommendation and dispatch vendor.
GET/api/v2/vendor-intelligence/warranties/List warranties. Filter: unit, item_type, status.
GET/api/v2/vendor-intelligence/warranties/check/Check warranty. Query: ?unit={id}&item={type}. Returns active warranty or null.
GET/api/v2/vendor-intelligence/benchmarks/Price benchmarks by work category.
POST/api/v2/vendor-intelligence/invoices/{id}/analyze-price/Analyze invoice price vs benchmarks. Returns deviation and explanation.
GET/api/v2/vendor-intelligence/compare/Compare vendors. Query: ?vendor_ids=1,2,3&category=plumbing
GET/api/v2/vendor-intelligence/contracts/expiring/Contracts and documents expiring within 30 days.
GET/api/v2/vendor-intelligence/repeat-alerts/Open repeat visit alerts. Filter: unit, vendor, resolution_status.
GET/api/v2/vendor-intelligence/stats/Dashboard stats: avg score, active warranties, expiring contracts, open alerts.

GET /api/v2/vendor-intelligence/dispatch/{work_order_id}/

// Response 200:
{
  "work_order_id": "uuid",
  "work_category": "plumbing",
  "unit": "B-112",
  "description": "Kitchen sink leak — faucet dripping constantly",
  "warranty_check": {
    "has_warranty": false,
    "message": "No active warranty found for plumbing fixtures in Unit B-112."
  },
  "recommendations": [
    {
      "rank": 1,
      "vendor_id": "uuid",
      "vendor_name": "ProFlow Plumbing LLC",
      "overall_score": 8.7,
      "reasoning": {
        "specialty_match": "Primary specialty: plumbing (95% of jobs)",
        "performance": "8.7/10 overall, 92% first-visit resolution",
        "workload": "1 open job (low)",
        "cost_history": "Avg $285 for faucet repair (below benchmark $310)"
      },
      "estimated_cost": "$250-320"
    },
    {
      "rank": 2,
      "vendor_id": "uuid",
      "vendor_name": "AllFix Maintenance",
      "overall_score": 7.4,
      "reasoning": {
        "specialty_match": "General maintenance, handles plumbing (40% of jobs)",
        "performance": "7.4/10 overall, 78% first-visit resolution",
        "workload": "3 open jobs (moderate)",
        "cost_history": "Avg $340 for faucet repair (at benchmark)"
      },
      "estimated_cost": "$300-380"
    },
    {
      "rank": 3,
      "vendor_id": "uuid",
      "vendor_name": "Quick Plumb Services",
      "overall_score": 6.9,
      "reasoning": {
        "specialty_match": "Plumbing specialist (100% of jobs)",
        "performance": "6.9/10 overall, 70% first-visit resolution",
        "workload": "0 open jobs (available)",
        "cost_history": "Avg $360 for faucet repair (16% above benchmark)"
      },
      "estimated_cost": "$320-400"
    }
  ],
  "repeat_visit_warning": {
    "is_repeat": true,
    "previous_visit": "2026-03-05",
    "previous_vendor": "Quick Plumb Services",
    "days_since": 23,
    "message": "Same unit/category was serviced 23 days ago by Quick Plumb. This may be a callback — consider requesting a warranty redo."
  }
}

POST /api/v2/vendor-intelligence/invoices/{id}/analyze-price/

// Response 200:
{
  "invoice_id": "uuid",
  "vendor_name": "Quick Plumb Services",
  "invoice_total": 450.00,
  "work_category": "plumbing",
  "benchmark": {
    "average": 333.00,
    "median": 310.00,
    "min": 180.00,
    "max": 620.00,
    "sample_count": 47,
    "vendor_count": 8
  },
  "deviation_pct": 35.1,
  "deviation_label": "significantly_above",
  "explanation": "This $450 garbage disposal replacement is 35% above your portfolio average of $333 for the same work category. Quick Plumb has charged above average on 4 of their last 6 invoices.",
  "recommendation": "Flag for review. Consider requesting itemized breakdown or negotiating rate.",
  "severity": "warning"
}

GET /api/v2/vendor-intelligence/stats/

// Response 200:
{
  "avg_vendor_score": 7.6,
  "total_vendors": 24,
  "top_performer": {"name": "ProFlow Plumbing LLC", "score": 8.7},
  "bottom_performer": {"name": "Budget Electric", "score": 5.2},
  "active_warranties": 142,
  "warranties_expiring_30d": 8,
  "expiring_contracts": 3,
  "expiring_insurance": 2,
  "open_repeat_alerts": 5,
  "total_saved_warranty_ytd": 12400.00,
  "price_anomalies_this_month": 7
}

C Celery Tasks

Task: vendor_intelligence.tasks.calculate_scorecards

Schedule: 1st of each month at 04:00 AM UTC

Purpose: For each vendor with activity in the period: calculate 6 metrics, compute weighted overall score, create VendorScorecard record. Weights: response_time(15%), first_visit(25%), cost_accuracy(20%), sla_compliance(15%), tenant_satisfaction(15%), documentation(10%).

Task: vendor_intelligence.tasks.detect_repeat_visits

Schedule: Daily at 06:00 AM UTC

Purpose: Find work orders where the same unit + work category had a completed job within the last 30 days. Create RepeatVisitAlert. Notify property manager.

Task: vendor_intelligence.tasks.update_price_benchmarks

Schedule: Weekly on Sunday at 05:00 AM UTC

Purpose: Recalculate average/median/min/max costs per work category across all approved invoices. Update PriceBenchmark table.

Task: vendor_intelligence.tasks.check_expiring_documents

Schedule: Daily at 07:00 AM UTC

Purpose: Find vendor contracts, insurance certificates, and licenses expiring within 30 days. Send email notification to property manager with list.

A Claude API — Smart Dispatch & Price Analysis

DISPATCH_REASONING_PROMPT = """Recommend the best vendor for this work order.

Work Order:
- Category: {category}
- Description: {description}
- Unit: {unit_number} at {property_name}
- Priority: {priority}
- Tenant notes: "{tenant_notes}"

Available Vendors (sorted by overall score):
{vendor_list_with_scores}

For each vendor, you have:
- Overall score (0-10), individual metric scores
- Specialty areas and percentage of jobs in this category
- Current open job count (workload)
- Historical average cost for this category
- First-visit resolution rate for this category

Also check: Is there an active warranty on the relevant equipment?
Warranty data: {warranty_data}

Recommend top 3 vendors with detailed reasoning. If warranty applies,
recommend contacting the warranty provider FIRST.

Return JSON:
{
  "warranty_applies": boolean,
  "warranty_recommendation": "string or null",
  "recommendations": [
    {
      "rank": 1,
      "vendor_id": "uuid",
      "score": float,
      "reasoning": {
        "specialty_match": "string",
        "performance": "string",
        "workload": "string",
        "cost_history": "string"
      },
      "estimated_cost_range": "string"
    }
  ]
}"""
PRICE_ANALYSIS_PROMPT = """Analyze this vendor invoice against portfolio benchmarks.

Invoice:
- Vendor: {vendor_name}
- Work category: {category}
- Total: ${invoice_total}
- Line items: {line_items}

Benchmarks for "{category}":
- Average: ${avg}
- Median: ${median}
- Min: ${min}
- Max: ${max}
- Sample size: {count} invoices from {vendor_count} vendors

This vendor's history:
- Average charge for this category: ${vendor_avg}
- Last 6 invoices: {vendor_recent}

Analyze:
1. Is this invoice within normal range?
2. If above average, is it justified (complexity, parts, urgency)?
3. Pattern: Is this vendor consistently above/below average?
4. Recommendation: approve, flag for review, or request itemization

Return JSON:
{
  "deviation_pct": float,
  "deviation_label": "within_range|slightly_above|significantly_above|below_average",
  "explanation": "string",
  "vendor_pattern": "consistent|trending_up|trending_down|volatile",
  "recommendation": "approve|flag|request_itemization",
  "severity": "info|warning|critical"
}"""

F Screen Wireframes

Vendor Intelligence Dashboard

┌─────────────────────────────────────────────────────────────────────────────┐ Vendor Intelligence [Recalculate All] ├─────────────────────────────────────────────────────────────────────────────┤ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │Avg Score │ │ Active │ │ Expiring │ │ Repeat │ │ │ │Warranties│ │Contracts │ │ Alerts │ │ 7.6/10 │ │ 142 │ │ 3 │ │ 5 │ │ 24 vendors│ │ 8 exp 30d│ │ 2 insur │ │ $12.4k ↑ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ ├──────────────────────────────────────────────┬──────────────────────────────┤ Vendor Scorecards [Q1 2026 ▾] Warranty Tracker ├──────────────────────────────────────────────┤──────────────────────────────┤ Vendor Score FVR% Jobs Trend Unit Item Expires ProFlow Plumbing 8.7 92% 34 ▁▂▃▅▇ C-207 HVAC Jan 2027 Apex Electric 8.1 88% 28 ▃▃▄▅▅ A-305 Dishwshr May 2026 AllFix Maint. 7.4 78% 52 ▅▄▃▃▂ B-112 W.Heater Apr 2026 Quick Plumb 6.9 70% 19 ▃▂▂▁▁ D-401 Fridge Sep 2026 Budget Electric 5.2 55% 11 ▅▃▂▁▁ A-102 Washer Nov 2026 [Compare Selected] [View All Warranties] └──────────────────────────────────────────────┴──────────────────────────────┘

Routes: /vendor-intelligence (dashboard with scorecards + warranties), /vendor-intelligence/vendor/:id (vendor detail with metric history), /vendor-intelligence/dispatch/:woId (dispatch recommendation view), /vendor-intelligence/benchmarks (price benchmarks), /vendor-intelligence/alerts (repeat visit alerts).

☑ Acceptance Checklist — 5.4 Vendor Performance Intelligence

Global Architecture Reference
REF Shared Infrastructure, Celery Config, Environment
Backend Celery

B New Django Apps to Create

App NameFeaturesDepends On
renewals1.2 Renewal Intelligencetenants, units, properties
collections1.3 Smart Collectionstenants, units, properties
turnover2.1 Turnover Orchestrationtenants, units, properties, vendors
pricing2.2 Dynamic Pricingunits, properties
inspections3.1 Move-Out Photo AItenants, units, turnover
chat3.3 Portfolio Intelligence(reads from all apps)
staging4.1 Virtual Stagingunits, properties
screening4.2 Tenant Screeningunits, properties
abstractions4.3 Lease Abstractiontenants, units, properties
inspections (extend)4.4 Move-In Inspection AItenants, units, properties, turnover
leases4.5 AI Lease Generation & E-Signingtenants, units, properties, screening
onboarding4.6 Tenant Onboarding Automationtenants, units, properties, leases
syndication5.1 AI Listing Syndicationunits, properties, staging, turnover
showings5.2 Smart Showing & Tour Managementunits, properties, syndication
communications5.3 Tenant Communications Hubtenants, units, properties
vendor_intelligence5.4 Vendor Performance Intelligenceinvoices (Vendor model), units

Existing apps to extend: tenants (1.1 Lifecycle fields), invoices (3.2 AI extraction model), predictive_maintenance (2.3 risk scores), inspections (4.4 adds Move-In alongside existing Move-Out).

C Celery Beat Schedule (All Tasks)

# config/celery.py

CELERY_BEAT_SCHEDULE = {
    # Phase 1
    'classify-lease-statuses': {
        'task': 'tenants.classify_lease_statuses',
        'schedule': crontab(hour=2, minute=0),       # 02:00 UTC daily
    },
    'nightly-renewal-scan': {
        'task': 'renewals.nightly_renewal_scan',
        'schedule': crontab(hour=3, minute=0),       # 03:00 UTC daily
    },
    'daily-collections-scan': {
        'task': 'collections.daily_collections_scan',
        'schedule': crontab(hour=8, minute=0),       # 08:00 UTC daily
    },
    'collections-escalation': {
        'task': 'collections.escalation_engine',
        'schedule': crontab(hour='*/4', minute=30),  # Every 4 hours
    },

    # Phase 2
    'daily-pricing-engine': {
        'task': 'pricing.daily_pricing_engine',
        'schedule': crontab(hour=4, minute=0),       # 04:00 UTC daily
    },
    'weekly-risk-scoring': {
        'task': 'predictive_maintenance.compute_all_risk_scores',
        'schedule': crontab(hour=5, minute=0, day_of_week=1),  # Monday 05:00
    },
    'daily-vacancy-tracker': {
        'task': 'turnover.update_vacancy_days',
        'schedule': crontab(hour=1, minute=0),       # 01:00 UTC daily
    },

    # Phase 3
    'daily-digest': {
        'task': 'chat.generate_daily_digest',
        'schedule': crontab(hour=6, minute=0),       # 06:00 UTC daily
    },

    # Phase 4 (new)
    'check-signature-reminders': {
        'task': 'leases.check_signature_reminders',
        'schedule': crontab(hour=10, minute=0),      # 10:00 UTC daily
    },
    'expire-unsigned-leases': {
        'task': 'leases.expire_unsigned_leases',
        'schedule': crontab(hour=2, minute=30),      # 02:30 UTC daily
    },
    'monitor-jurisdiction-changes': {
        'task': 'leases.monitor_jurisdiction_changes',
        'schedule': crontab(hour=6, minute=0, day_of_week=0),  # Sunday 06:00
    },
    'process-onboarding-tasks': {
        'task': 'onboarding.process_scheduled_onboarding_tasks',
        'schedule': crontab(minute=0),               # Every hour
    },
    'detect-stalled-onboarding': {
        'task': 'onboarding.detect_stalled_onboardings',
        'schedule': crontab(hour=9, minute=0),       # 09:00 UTC daily
    },

    # Phase 5 (new)
    'sync-listing-analytics': {
        'task': 'syndication.sync_all_listing_analytics',
        'schedule': crontab(hour='*/6', minute=15),  # Every 6 hours
    },
    'auto-delist-leased-units': {
        'task': 'syndication.auto_delist_leased_units',
        'schedule': crontab(hour='*/2', minute=0),   # Every 2 hours
    },
    'weekly-competitor-scan': {
        'task': 'syndication.scan_competing_listings',
        'schedule': crontab(hour=3, minute=0, day_of_week=1),  # Monday 03:00
    },
    'showing-reminders': {
        'task': 'showings.send_showing_reminders',
        'schedule': crontab(minute='*/30'),           # Every 30 min
    },
    'post-tour-feedback-surveys': {
        'task': 'showings.send_feedback_surveys',
        'schedule': crontab(minute='*/30'),           # Every 30 min
    },
    'daily-prospect-nurture': {
        'task': 'showings.nurture_prospects',
        'schedule': crontab(hour=10, minute=30),     # 10:30 UTC daily
    },
    'daily-sentiment-analysis': {
        'task': 'communications.calculate_daily_sentiment',
        'schedule': crontab(hour=1, minute=30),      # 01:30 UTC daily
    },
    'monthly-vendor-scorecards': {
        'task': 'vendor_intelligence.calculate_monthly_scorecards',
        'schedule': crontab(hour=4, minute=0, day_of_month=1),  # 1st of month
    },
    'weekly-price-benchmarks': {
        'task': 'vendor_intelligence.update_price_benchmarks',
        'schedule': crontab(hour=3, minute=30, day_of_week=0),  # Sunday 03:30
    },
    'daily-contract-expiry-check': {
        'task': 'vendor_intelligence.check_contract_expiry',
        'schedule': crontab(hour=8, minute=30),      # 08:30 UTC daily
    },
}

B Environment Variables (New)

# Add to .env

# Claude API
ANTHROPIC_API_KEY=sk-ant-...
CLAUDE_MODEL=claude-sonnet-4-20250514
CLAUDE_MAX_TOKENS=4096

# Virtual Staging (3rd party)
STAGING_API_KEY=...
STAGING_API_URL=https://api.staging-provider.com/v1

# SMS (for Collections)
TWILIO_ACCOUNT_SID=...
TWILIO_AUTH_TOKEN=...
TWILIO_FROM_NUMBER=+1...

# Email (for Collections + Renewals + Onboarding)
SENDGRID_API_KEY=...
FROM_EMAIL=noreply@unitcycle.com

# E-Signature (4.5 Lease Generation)
ESIGN_BASE_URL=https://demo.unitcycle.com/signing
ESIGN_TOKEN_EXPIRY_DAYS=14

# Smart Lock Integration (4.6 Onboarding — optional)
SMART_LOCK_PROVIDER=...  # yale, august, latch
SMART_LOCK_API_KEY=...

# Listing Syndication (5.1)
ZILLOW_API_KEY=...
APARTMENTS_COM_API_KEY=...
ZUMPER_API_KEY=...

# Showing Management (5.2)
SHOWING_ACCESS_CODE_DURATION_MINUTES=60
SHOWING_MAX_PER_DAY=8

F Angular Route Summary (All New)

// Add all new routes to app.routes.ts children array:

// Phase 1
{ path: 'lifecycle', loadComponent: ... },
{ path: 'lifecycle/:leaseId', loadComponent: ... },
{ path: 'renewals', loadComponent: ... },
{ path: 'renewals/:id', loadComponent: ... },
{ path: 'collections', loadComponent: ... },
{ path: 'collections/:caseId', loadComponent: ... },

// Phase 2
{ path: 'turnover', loadComponent: ... },
{ path: 'turnover/:caseId', loadComponent: ... },
{ path: 'pricing', loadComponent: ... },
{ path: 'pricing/:unitId', loadComponent: ... },
// predictive-maintenance routes already exist

// Phase 3
{ path: 'inspections', loadComponent: ... },
{ path: 'inspections/new', loadComponent: ... },
{ path: 'inspections/:id', loadComponent: ... },
// invoices routes already exist - add processing sub-routes
{ path: 'chat', loadComponent: ... },
{ path: 'chat/:conversationId', loadComponent: ... },

// Phase 4
{ path: 'staging', loadComponent: ... },
{ path: 'staging/:projectId', loadComponent: ... },
{ path: 'screening', loadComponent: ... },
{ path: 'screening/new', loadComponent: ... },
{ path: 'screening/:id', loadComponent: ... },
{ path: 'abstractions', loadComponent: ... },
{ path: 'abstractions/upload', loadComponent: ... },
{ path: 'abstractions/:id', loadComponent: ... },
{ path: 'move-in-inspections', loadComponent: ... },
{ path: 'move-in-inspections/new', loadComponent: ... },
{ path: 'move-in-inspections/:id', loadComponent: ... },
{ path: 'move-in-inspections/:id/compare/:moveOutId', loadComponent: ... },
{ path: 'lease-generation', loadComponent: ... },
{ path: 'lease-generation/new', loadComponent: ... },
{ path: 'lease-generation/:id', loadComponent: ... },
{ path: 'signing/:token', loadComponent: ... },  // Public, no auth
{ path: 'onboarding', loadComponent: ... },
{ path: 'onboarding/:id', loadComponent: ... },

// Phase 5
{ path: 'listings', loadComponent: ... },
{ path: 'listings/:id', loadComponent: ... },
{ path: 'listings/leads', loadComponent: ... },
{ path: 'showings', loadComponent: ... },
{ path: 'showings/calendar', loadComponent: ... },
{ path: 'showings/:id', loadComponent: ... },
{ path: 'communications', loadComponent: ... },
{ path: 'communications/:conversationId', loadComponent: ... },
{ path: 'vendor-intelligence', loadComponent: ... },
{ path: 'vendor-intelligence/scorecards/:vendorId', loadComponent: ... },
{ path: 'vendor-intelligence/warranties', loadComponent: ... },

F New Angular Services Summary

ServiceFileFeatures
LifecycleServicecore/services/lifecycle.service.ts1.1
RenewalServicecore/services/renewal.service.ts1.2
CollectionsServicecore/services/collections.service.ts1.3
TurnoverServicecore/services/turnover.service.ts2.1
PricingServicecore/services/pricing.service.ts2.2
InspectionServicecore/services/inspection.service.ts3.1
ChatServicecore/services/chat.service.ts3.3
StagingServicecore/services/staging.service.ts4.1
ScreeningServicecore/services/screening.service.ts4.2
AbstractionServicecore/services/abstraction.service.ts4.3
MoveInInspectionServicecore/services/move-in-inspection.service.ts4.4
LeaseGenerationServicecore/services/lease-generation.service.ts4.5
OnboardingServicecore/services/onboarding.service.ts4.6
ListingSyndicationServicecore/services/listing-syndication.service.ts5.1
ShowingServicecore/services/showing.service.ts5.2
TenantCommunicationsServicecore/services/tenant-communications.service.ts5.3
VendorIntelligenceServicecore/services/vendor-intelligence.service.ts5.4

All services follow the existing pattern: @Injectable({ providedIn: 'root' }), inject HttpClient, use environment.apiUrl base.

F Design System Reference

TokenValueUsage
--primary#0F172ANavy. Headers, sidebar, primary text.
--gold#EAB308Accent. CTAs, active states, highlights.
--teal#0D9488Success states, positive metrics.
font-headingPlus Jakarta SansAll headings, card titles, nav items.
font-bodyDM SansBody text, descriptions, labels.
font-monoJetBrains MonoDollar amounts, codes, technical data.

All monetary values: right-aligned, font-family: JetBrains Mono, formatted as $1,234.56. Use tabular-nums font-feature-settings.

UnitCycle AI Platform — Technical Specification v1.0

Generated 2026-03-27 • 19 Features • 5 Phases • Django 5 + Angular 19 + Claude API

Existing codebase: /home/claude/projects/unitcycle-demo/