Complete engineering specification for 19 AI-powered features across 5 phases. Backend models, API contracts, Celery tasks, Angular components, algorithms, and wireframes.
Many features integrate INTO existing UI. Do NOT rebuild these. Read this section before touching any code.
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).
src/app/features/property-management/unit-detail/unit-detail.component.html (~800 lines) • Section: Header area, line ~65. Status badge next to unit number.Active (teal), Month-to-Month (amber), Notice (red), Vacant (gray). Badge comes from lease_status field on the lease, not the unit status.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.
src/app/features/property-management/unit-detail/unit-detail.component.ts (~1100 lines) • Section: getLeaseProgress() method at line ~715, getLeaseTimeRemaining() at line ~735lease_begin_date to lease_end_date. Returns "Expired" if past end date. Colors: teal (>90d), amber (30-90d), orange (<30d).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.
src/app/features/property-management/property-detail/property-detail.component.ts (~2600 lines) • Section: getUnitStatusClassWithInsurance(unit: UnitGridItem) at line ~2629, UnitGridItem interface at line ~56unit.status: occupied = green, vacant = gray, maintenance = orange. Insurance overlay adds yellow ring.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.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.
src/app/features/property-management/property-detail/property-detail.component.html (~3000 lines) • Section: Tooltip popup. Uses hoveredUnit signal and getTooltipLeaseProgress(unit).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.
src/app/features/property-management/unit-detail/unit-detail.component.html • Section: Header stat row (line ~80-120), after Balance cell./api/v1/renewals/?lease={lease_id}.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.
src/app/features/property-management/unit-detail/unit-detail.component.html • Section: Balance stat cell (line ~110-119)/api/v1/collections/?tenant={tenant_id}&status=open.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.
src/app/features/property-management/unit-detail/unit-detail.component.html • Section: Overview tab, below unit details section./api/v1/pricing/units/{unit_id}/history/.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.
src/app/features/property-management/unit-detail/unit-detail.component.html • Section: Maintenance tab, header area/api/v1/predictive-maintenance/risk-scores/?unit={unit_id}.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.
src/app/features/property-management/property-detail/property-detail.component.html • Section: Property header (occupancy stats bar)/api/v1/tenants/leases/lifecycle-stats/?property={property_id}.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.
src/app/features/dashboard/dashboard.component.html • Section: Dashboard cards area (add new card)| Feature | Route | Sidebar Nav Location | Component 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 |
| File | Lines | What It Contains |
|---|---|---|
src/app/features/property-management/unit-detail/unit-detail.component.html | ~800 | Unit 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 | ~1100 | Lifecycle methods: getLeaseProgress(), getLeaseTimeRemaining(), currentLease computed, signals for unit/charges/maintenance/leases |
src/app/features/property-management/property-detail/property-detail.component.html | ~3000 | Property 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 | ~2600 | UnitGridItem interface (line 56), buildingGridData loader (line 2462), honeycomb hover/tooltip logic (line 2558+), getUnitStatusClassWithInsurance (line 2629) |
src/app/core/services/property.service.ts | ~300 | API service: getProperties, getUnit, getUnitCharges, getLeasesByUnit, getMaintenanceRequestsByUnit |
django_api/tenants/models.py | ~289 | Tenants, TenantLease (PK: UUID, FKs: tenant/unit/property, fields: lease_begin_date, lease_end_date, monthly_rent_amount, status) |
django_api/units/models.py | ~333 | UnitsUnit (PK: UUID, fields: unit_number, bedrooms, bathrooms, square_feet, market_rent, current_rent, status), UnitsMaintenance |
django_api/invoices/models.py | ~189 | Invoices (with confidence_score, vendor/property/unit matching fields, line_items), InvoiceLineItems, InvoiceAttachments |
django_api/predictive_maintenance/models.py | ~131 | Equipment, MaintenancePrediction (existing app to extend) |
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.
lease_end_date < today and status = 'active'. The initial migration must reclassify these into month_to_month.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 | Type | Default | Purpose |
|---|---|---|---|
lease_status | CharField(20) | 'active' | Current lifecycle state. Indexed. |
lease_status_changed_at | DateTimeField | null | When the status last changed. |
lease_status_changed_by | CharField(50) | 'system' | Who/what triggered the change. |
holdover_start_date | DateField | null | Date lease became month-to-month. |
notice_date | DateField | null | Date notice was given. |
notice_move_out_date | DateField | null | Expected move-out per notice. |
actual_move_out_date | DateField | null | Actual move-out (set on vacancy). |
| Method | Path | Description |
|---|---|---|
| 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. |
// 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." }
// 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
}
// 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
}
tenants.tasks.classify_lease_statusesSchedule: Daily at 02:00 AM UTC
Algorithm:
lease_status = 'active' AND lease_end_date < today().lease_status = 'month_to_month', holdover_start_date = lease_end_date + 1 day.lease_status = 'notice' AND notice_move_out_date < today() AND no actual_move_out_date.lease_status = 'vacant', actual_move_out_date = notice_move_out_date.UnitsUnit.status to 'vacant' for all newly vacated leases.LeaseStatusLog entries for every transition."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."
tenants.tasks.initial_lease_reclassificationSchedule: One-time migration task (run via management command)
Purpose: Reclassify the 1,146 existing expired leases that still have status='active'.
# 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()
| Path | Component | Description |
|---|---|---|
/lifecycle | LifecycleDashboardComponent | Main lifecycle dashboard with hex grid + table |
/lifecycle/:leaseId | LeaseLifecycleDetailComponent | Single 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)
},
| Component | Location | Inputs/Outputs | Service 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) |
// 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/`);
}
}
| Status | Background | Text | Hex |
|---|---|---|---|
| Active | #ECFDF5 | #065F46 | Teal/green honeycomb |
| Month-to-Month | #FEF3C7 | #92400E | Amber honeycomb |
| Notice | #FEE2E2 | #991B1B | Red honeycomb |
| Vacant | #F1F5F9 | #64748B | Gray honeycomb |
// 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);
});
| From | To | Trigger | Automation |
|---|---|---|---|
| Active | Month-to-Month | lease_end_date < today and no renewal | Daily Celery task |
| Active | Notice | Manual: tenant gives notice | API call |
| Month-to-Month | Notice | Manual: tenant gives notice | API call |
| Month-to-Month | Active | New lease signed (renewal) | Via renewal feature (1.2) |
| Notice | Vacant | notice_move_out_date < today | Daily Celery task |
| Vacant | Active | New lease created | Via new lease creation |
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.
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.
# 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 | Type | Purpose |
|---|---|---|
churn_score | Float 0.0-1.0 | Probability tenant will not renew. Higher = more likely to leave. |
churn_risk_level | Enum | low (<0.3), medium (0.3-0.5), high (0.5-0.7), critical (>0.7) |
churn_factors | JSON | Breakdown of factors contributing to score. Keys: late_payments, maintenance_requests, rent_gap, tenure_short, market_trend |
rent_strategy | Enum | AI-chosen strategy based on churn risk and market conditions |
rent_increase_pct | Float | Percentage increase from current rent to recommended |
| Method | Path | Description |
|---|---|---|
| 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). |
// 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
}
}
renewals.tasks.nightly_renewal_scanSchedule: Daily at 03:00 AM UTC
Steps:
lease_status IN ('active', 'month_to_month') AND lease_end_date is within 30-120 days from today (or already MTM).RenewalRecommendation with status in ('pending', 'approved', 'sent').RenewalRecommendation record.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
| Churn Score | Rent Gap | Strategy | Max Increase |
|---|---|---|---|
| > 0.7 (critical) | Any | hold | 0% |
| 0.5-0.7 (high) | Below market | below_market_retain | 2% |
| 0.3-0.5 (medium) | Below market | market_rate | 5% or to market |
| < 0.3 (low) | Below market | market_rate | 8% or to market |
| < 0.3 (low) | At/above market | above_market_premium | 3% above market |
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
| Path | Component |
|---|---|
/renewals | RenewalDashboardComponent |
/renewals/:id | RenewalDetailComponent |
| Component | Location | Purpose |
|---|---|---|
RenewalDashboardComponent | features/renewals/ | Main dashboard with stats cards and table |
RenewalDetailComponent | features/renewals/ | Detail view with churn factors chart, letter preview |
ChurnScoreGaugeComponent | features/renewals/components/ | Circular gauge showing churn probability |
ChurnFactorsChartComponent | features/renewals/components/ | Horizontal bar chart of churn factors |
RenewalLetterPreviewComponent | features/renewals/components/ | Letter text preview with edit capability |
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.
unit-detail.component.html (line ~110-119). The main Collections Dashboard and Case Detail are NEW screens at /collections.
# 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']
| Method | Path | Description |
|---|---|---|
| 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 |
collections.tasks.daily_collections_scanSchedule: Daily at 08:00 AM UTC
TenantLedgerEntry for open charges past due date.tenant_risk_profile based on payment history.collections.tasks.escalation_engineSchedule: Every 4 hours
| Days Overdue | Level | Action | Channel | Good History Tone | Repeat Offender Tone |
|---|---|---|---|---|---|
| 1 | 1 | Friendly reminder | SMS | Friendly | Firm |
| 3 | 2 | Detailed notice | Friendly | Firm | |
| 5 | 3 | Formal notice | Email + Letter | Firm | Formal |
| 8 | 4 | Payment plan offer | Firm | Formal | |
| 12 | 5 | Legal escalation warning | Certified letter | Formal | Legal |
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]
Routes: /collections (dashboard), /collections/:caseId (detail with timeline view).
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.
/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.┌─────────────────────────────────────────────────────────────────────────────┐ │ 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 ▾] │ └─────────────────────────────────────────────────────────────────────────────┘
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)})
// 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.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
tenant_risk_profile) used in the Risk filter. Without F1.3, risk shows as "Unknown."Pipeline: Notice → Inspect → Make Ready → Listed → Leased. Auto-create inspection work order when notice given. Deposit deduction calculator. Kanban board UI.
# 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']
| Method | Path | Description |
|---|---|---|
| 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. |
turnover.tasks.auto_create_turnover_caseTrigger: Django signal when TenantLease.lease_status changes to 'notice'.
Routes: /turnover (Kanban board), /turnover/:caseId (case detail with checklist, deductions, timeline).
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)
| Category | Item | Est. Cost |
|---|---|---|
| Cleaning | Deep clean entire unit | $250 |
| Paint | Touch-up / full repaint walls | $400 |
| Flooring | Carpet clean or replacement | $300 |
| Appliances | Test and clean all appliances | $100 |
| HVAC | Replace filters, test system | $75 |
| Security | Re-key locks | $85 |
| Plumbing | Check faucets, toilets, drains | $50 |
| Electrical | Test outlets, switches, smoke detectors | $50 |
Daily pricing engine for vacant units. Market rent estimation from comparable units. Rent-to-market gap analysis across the full portfolio. Revenue opportunity dashboard.
unit-detail.component.html) showing current vs market rent with gap analysis. The Revenue Intelligence Dashboard is a NEW screen at /pricing.
# 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'
| Method | Path | Description |
|---|---|---|
| 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. |
// 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
}
]
}
pricing.tasks.daily_pricing_engineSchedule: 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)
Routes: /pricing (portfolio dashboard), /pricing/:unitId (unit pricing history with chart).
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.
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.
# 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'
| Method | Path | Description |
|---|---|---|
| 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. |
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)
Routes: /predictive-maintenance (existing, extend with heatmap), /predictive-maintenance/risk-heatmap.
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.
# 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']
| Method | Path | Description |
|---|---|---|
| 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. |
# 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)
| Category | Minor | Moderate | Severe |
|---|---|---|---|
| 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 |
Routes: /inspections/new (create), /inspections/:id (review grid), /inspections/:id/letter (deduction letter preview).
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.
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.
# 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'
| Method | Path | Description |
|---|---|---|
| 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. |
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."""
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"
Routes: /invoices (existing list, add processing queue tab), /invoices/processing/:id (side-by-side review).
Natural language queries against the entire portfolio via Claude with tool use. Streaming responses. Inline data tables and action buttons. Daily digest generation.
# 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'
| Method | Path | Description |
|---|---|---|
| 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). |
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"]
}
}
]
Routes: /chat (main chat with sidebar), /chat/:conversationId (specific conversation).
Upload empty room photos. AI generates virtually staged versions. Before/after comparison slider. Auto-generate listing description from staged photos.
# 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']
| Method | Path | Description |
|---|---|---|
| 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. |
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."""
Routes: /staging (project list), /staging/:projectId (before/after slider view).
Application intake form. AI scores applicants on income ratio, employment stability, rental history. Approve/deny recommendation with reasoning. Fair housing compliance built in.
# 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']),
]
| Method | Path | Description |
|---|---|---|
| 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). |
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,
}
| Score Range | Recommendation | Action |
|---|---|---|
| 75-100 | Approve | Auto-generate approval letter |
| 55-74 | Conditional Approve | Require additional deposit or guarantor |
| 40-54 | Manual Review | Flag for property manager review |
| 0-39 | Deny | Generate adverse action notice |
Routes: /screening (application list), /screening/:id (application detail + scoring), /screening/new (application form).
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.
# 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 = {
# 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",
}
| Method | Path | Description |
|---|---|---|
| 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. |
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
Routes: /abstractions (list), /abstractions/upload (upload new), /abstractions/:id (side-by-side review).
Guided room-by-room inspection with Claude Vision analysis, digital signatures, PDF report generation, and side-by-side comparison with move-out.
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'
| Method | Path | Description |
|---|---|---|
| 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. |
# ── 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)"
}
"""
Routes: /inspections/move-in (list), /inspections/move-in/:id (detail), /inspections/move-in/:id/compare/:moveOutId (side-by-side).
AI-powered lease generation from a clause library with jurisdiction compliance, per-clause risk scoring, e-signature workflow, renewal generation, and translation.
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'
| Method | Path | Description |
|---|---|---|
| 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. |
# ── 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"
}
"""
Routes: /leases/generate (builder), /leases/generated/:id (detail/preview), /sign/:token (public signing page).
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.
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'
| Method | Path | Description |
|---|---|---|
| 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. |
# ── 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.
"""
Routes: /onboarding (list all active), /onboarding/:id (detail), /onboarding/:id/chat (tenant FAQ).
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.
# 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']
| Method | Path | Description |
|---|---|---|
| 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. |
// 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/..."}
]
}
// 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
}
// 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
}
syndication.tasks.sync_platform_analyticsSchedule: Every 6 hours
Purpose: Pull view/inquiry/favorite counts from each platform API and update ListingAnalytics.
syndication.tasks.auto_delist_leased_unitsSchedule: Every 30 minutes
Purpose: Query all active listings where the unit now has an active lease. Delist from all platforms. Update listing status.
syndication.tasks.flag_stale_listingsSchedule: 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.
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"
}"""
Routes: /syndication (dashboard + lead inbox), /syndication/:listingId (listing detail with analytics + competitors), /syndication/leads (full lead inbox view).
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.
# 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']
| Method | Path | Description |
|---|---|---|
| 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. |
// 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."
}
// 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}
}
}
showings.tasks.send_showing_remindersSchedule: Every 15 minutes
Purpose: Check upcoming showings. Send 24h, 2h, and 30-min reminders via email/SMS. Mark reminder flags on Showing model.
showings.tasks.mark_no_showsSchedule: 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.
showings.tasks.send_nurture_messagesSchedule: 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.
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_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
Routes: /showings (calendar + upcoming list), /showings/:id (showing detail with feedback), /showings/funnel (conversion analytics), /prospects/:id (prospect profile with tour history).
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.
# 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']
| Method | Path | Description |
|---|---|---|
| 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. |
// 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"
}
}
// 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
}
communications.tasks.process_inbound_messageSchedule: 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.
communications.tasks.calculate_daily_sentimentSchedule: 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.
communications.tasks.send_broadcastSchedule: On-demand (triggered by API)
Purpose: Send broadcast to filtered recipients across selected channels. Track delivery and read receipts.
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_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
Routes: /communications (inbox + conversation + profile), /communications/broadcasts (broadcast management), /communications/sentiment (at-risk tenant dashboard), /communications/templates (template management).
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.
# 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'
| Method | Path | Description |
|---|---|---|
| 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. |
// 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."
}
}
// 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"
}
// 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
}
vendor_intelligence.tasks.calculate_scorecardsSchedule: 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%).
vendor_intelligence.tasks.detect_repeat_visitsSchedule: 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.
vendor_intelligence.tasks.update_price_benchmarksSchedule: 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.
vendor_intelligence.tasks.check_expiring_documentsSchedule: 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.
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"
}"""
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).
| App Name | Features | Depends On |
|---|---|---|
renewals | 1.2 Renewal Intelligence | tenants, units, properties |
collections | 1.3 Smart Collections | tenants, units, properties |
turnover | 2.1 Turnover Orchestration | tenants, units, properties, vendors |
pricing | 2.2 Dynamic Pricing | units, properties |
inspections | 3.1 Move-Out Photo AI | tenants, units, turnover |
chat | 3.3 Portfolio Intelligence | (reads from all apps) |
staging | 4.1 Virtual Staging | units, properties |
screening | 4.2 Tenant Screening | units, properties |
abstractions | 4.3 Lease Abstraction | tenants, units, properties |
inspections (extend) | 4.4 Move-In Inspection AI | tenants, units, properties, turnover |
leases | 4.5 AI Lease Generation & E-Signing | tenants, units, properties, screening |
onboarding | 4.6 Tenant Onboarding Automation | tenants, units, properties, leases |
syndication | 5.1 AI Listing Syndication | units, properties, staging, turnover |
showings | 5.2 Smart Showing & Tour Management | units, properties, syndication |
communications | 5.3 Tenant Communications Hub | tenants, units, properties |
vendor_intelligence | 5.4 Vendor Performance Intelligence | invoices (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).
# 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
},
}
# 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
// 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: ... },
| Service | File | Features |
|---|---|---|
LifecycleService | core/services/lifecycle.service.ts | 1.1 |
RenewalService | core/services/renewal.service.ts | 1.2 |
CollectionsService | core/services/collections.service.ts | 1.3 |
TurnoverService | core/services/turnover.service.ts | 2.1 |
PricingService | core/services/pricing.service.ts | 2.2 |
InspectionService | core/services/inspection.service.ts | 3.1 |
ChatService | core/services/chat.service.ts | 3.3 |
StagingService | core/services/staging.service.ts | 4.1 |
ScreeningService | core/services/screening.service.ts | 4.2 |
AbstractionService | core/services/abstraction.service.ts | 4.3 |
MoveInInspectionService | core/services/move-in-inspection.service.ts | 4.4 |
LeaseGenerationService | core/services/lease-generation.service.ts | 4.5 |
OnboardingService | core/services/onboarding.service.ts | 4.6 |
ListingSyndicationService | core/services/listing-syndication.service.ts | 5.1 |
ShowingService | core/services/showing.service.ts | 5.2 |
TenantCommunicationsService | core/services/tenant-communications.service.ts | 5.3 |
VendorIntelligenceService | core/services/vendor-intelligence.service.ts | 5.4 |
All services follow the existing pattern: @Injectable({ providedIn: 'root' }), inject HttpClient, use environment.apiUrl base.
| Token | Value | Usage |
|---|---|---|
--primary | #0F172A | Navy. Headers, sidebar, primary text. |
--gold | #EAB308 | Accent. CTAs, active states, highlights. |
--teal | #0D9488 | Success states, positive metrics. |
font-heading | Plus Jakarta Sans | All headings, card titles, nav items. |
font-body | DM Sans | Body text, descriptions, labels. |
font-mono | JetBrains Mono | Dollar 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/