Retention Tracker
Overview
Track retainage (retention) amounts held and released throughout construction projects. Monitor amounts by subcontractor, track release milestones, and ensure proper documentation for retention release.
Retainage Flow
┌─────────────────────────────────────────────────────────────────┐
│ RETAINAGE LIFECYCLE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Progress Billing → Substantial → Final Release │
│ ──────────────── ─────────── ───────────── │
│ 10% withheld 50% released 50% released │
│ Each pay app At punch list At final │
│ Cumulative completion completion │
│ │
│ Owner holds from GC → GC holds from subs → Flow-down │
│ │
└─────────────────────────────────────────────────────────────────┘
Technical Implementation
from dataclasses import dataclass, field
from typing import List, Dict, Optional
from datetime import datetime, timedelta
from enum import Enum
class RetentionStatus(Enum):
HELD = "held"
PARTIAL_RELEASE = "partial_release"
PENDING_RELEASE = "pending_release"
RELEASED = "released"
class ReleaseMilestone(Enum):
SUBSTANTIAL_COMPLETION = "substantial_completion"
PUNCH_LIST_COMPLETE = "punch_list_complete"
FINAL_COMPLETION = "final_completion"
WARRANTY_EXPIRATION = "warranty_expiration"
@dataclass
class RetentionEntry:
pay_app_number: int
billing_date: datetime
gross_billing: float
retention_rate: float
retention_amount: float
status: RetentionStatus = RetentionStatus.HELD
@dataclass
class RetentionRelease:
id: str
release_date: datetime
milestone: ReleaseMilestone
amount: float
remaining_after: float
approved_by: str
conditions_met: List[str] = field(default_factory=list)
lien_waivers_received: bool = False
consent_of_surety: bool = False
@dataclass
class SubcontractorRetention:
subcontractor_id: str
subcontractor_name: str
trade: str
contract_value: float
retention_rate: float
entries: List[RetentionEntry] = field(default_factory=list)
releases: List[RetentionRelease] = field(default_factory=list)
total_billed: float = 0.0
total_retained: float = 0.0
total_released: float = 0.0
balance_held: float = 0.0
status: RetentionStatus = RetentionStatus.HELD
class RetentionTracker:
"""Track construction retainage amounts."""
# Default release schedule
DEFAULT_RELEASE_SCHEDULE = {
ReleaseMilestone.SUBSTANTIAL_COMPLETION: 0.50, # 50% at substantial
ReleaseMilestone.FINAL_COMPLETION: 0.50, # 50% at final
}
def __init__(self, project_name: str, default_rate: float = 0.10):
self.project_name = project_name
self.default_rate = default_rate
self.subcontractors: Dict[str, SubcontractorRetention] = {}
self.release_schedule = dict(self.DEFAULT_RELEASE_SCHEDULE)
# Project-level retention (from owner)
self.owner_retention = SubcontractorRetention(
subcontractor_id="OWNER",
subcontractor_name="Owner Retention",
trade="GC",
contract_value=0,
retention_rate=default_rate
)
def set_release_schedule(self, schedule: Dict[ReleaseMilestone, float]):
"""Set custom release schedule."""
self.release_schedule = schedule
def add_subcontractor(self, id: str, name: str, trade: str,
contract_value: float,
retention_rate: float = None) -> SubcontractorRetention:
"""Add subcontractor to track."""
sub = SubcontractorRetention(
subcontractor_id=id,
subcontractor_name=name,
trade=trade,
contract_value=contract_value,
retention_rate=retention_rate if retention_rate else self.default_rate
)
self.subcontractors[id] = sub
return sub
def record_billing(self, subcontractor_id: str, pay_app_number: int,
billing_date: datetime, gross_billing: float) -> RetentionEntry:
"""Record billing and calculate retention."""
if subcontractor_id not in self.subcontractors:
raise ValueError(f"Subcontractor {subcontractor_id} not found")
sub = self.subcontractors[subcontractor_id]
retention_amount = gross_billing * sub.retention_rate
entry = RetentionEntry(
pay_app_number=pay_app_number,
billing_date=billing_date,
gross_billing=gross_billing,
retention_rate=sub.retention_rate,
retention_amount=retention_amount
)
sub.entries.append(entry)
# Update totals
sub.total_billed += gross_billing
sub.total_retained += retention_amount
sub.balance_held = sub.total_retained - sub.total_released
return entry
def record_owner_billing(self, pay_app_number: int, billing_date: datetime,
gross_billing: float) -> RetentionEntry:
"""Record owner-level retention."""
retention_amount = gross_billing * self.owner_retention.retention_rate
entry = RetentionEntry(
pay_app_number=pay_app_number,
billing_date=billing_date,
gross_billing=gross_billing,
retention_rate=self.owner_retention.retention_rate,
retention_amount=retention_amount
)
self.owner_retention.entries.append(entry)
self.owner_retention.total_billed += gross_billing
self.owner_retention.total_retained += retention_amount
self.owner_retention.balance_held = (
self.owner_retention.total_retained - self.owner_retention.total_released
)
return entry
def release_retention(self, subcontractor_id: str, milestone: ReleaseMilestone,
amount: float = None, approved_by: str = "",
conditions: List[str] = None,
lien_waivers: bool = False,
consent_of_surety: bool = False) -> RetentionRelease:
"""Release retention for subcontractor."""
if subcontractor_id not in self.subcontractors:
raise ValueError(f"Subcontractor {subcontractor_id} not found")
sub = self.subcontractors[subcontractor_id]
# Calculate release amount if not specified
if amount is None:
release_pct = self.release_schedule.get(milestone, 0.5)
amount = sub.balance_held * release_pct
if amount > sub.balance_held:
amount = sub.balance_held
release_id = f"REL-{subcontractor_id}-{len(sub.releases)+1:03d}"
release = RetentionRelease(
id=release_id,
release_date=datetime.now(),
milestone=milestone,
amount=amount,
remaining_after=sub.balance_held - amount,
approved_by=approved_by,
conditions_met=conditions or [],
lien_waivers_received=lien_waivers,
consent_of_surety=consent_of_surety
)
sub.releases.append(release)
sub.total_released += amount
sub.balance_held -= amount
if sub.balance_held <= 0:
sub.status = RetentionStatus.RELEASED
elif sub.total_released > 0:
sub.status = RetentionStatus.PARTIAL_RELEASE
return release
def release_owner_retention(self, milestone: ReleaseMilestone,
amount: float = None,
approved_by: str = "") -> RetentionRelease: