Lien Waiver Tracker
Overview
Track and manage lien waivers throughout the construction payment process. Ensure proper waivers are received before releasing payments, monitor waiver status by subcontractor, and minimize lien exposure.
Lien Waiver Types
┌─────────────────────────────────────────────────────────────────┐ │ LIEN WAIVER TYPES │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ CONDITIONAL UNCONDITIONAL │ │ ─────────── ───────────── │ │ 📋 Progress - Conditional ✅ Progress - Unconditional │ │ Effective when paid Immediately effective │ │ Use with payment Use after check clears │ │ │ │ 📋 Final - Conditional ✅ Final - Unconditional │ │ For final payment For final payment │ │ Upon receipt of funds After funds received │ │ │ └─────────────────────────────────────────────────────────────────┘
Technical Implementation
from dataclasses import dataclass, field from typing import List, Dict, Optional from datetime import datetime, timedelta from enum import Enum
class WaiverType(Enum): CONDITIONAL_PROGRESS = "conditional_progress" UNCONDITIONAL_PROGRESS = "unconditional_progress" CONDITIONAL_FINAL = "conditional_final" UNCONDITIONAL_FINAL = "unconditional_final"
class WaiverStatus(Enum): REQUESTED = "requested" RECEIVED = "received" VERIFIED = "verified" REJECTED = "rejected" MISSING = "missing"
class PaymentStatus(Enum): PENDING = "pending" APPROVED = "approved" HELD = "held" RELEASED = "released"
@dataclass class Subcontractor: id: str name: str trade: str contract_amount: float contact_name: str contact_email: str tier: int = 1 # 1 = direct, 2 = sub-sub
@dataclass class LienWaiver: id: str subcontractor_id: str waiver_type: WaiverType payment_application: int # Pay app number through_date: datetime amount: float status: WaiverStatus = WaiverStatus.REQUESTED requested_date: datetime = field(default_factory=datetime.now) received_date: Optional[datetime] = None verified_by: str = "" file_path: str = "" notes: str = ""
@dataclass class PaymentApplication: number: int period_end: datetime subcontractor_id: str amount_requested: float amount_approved: float retainage: float status: PaymentStatus = PaymentStatus.PENDING waivers_complete: bool = False payment_date: Optional[datetime] = None
@dataclass class LienExposure: subcontractor_id: str subcontractor_name: str total_paid: float unconditional_waivers: float conditional_pending: float exposure: float
class LienWaiverTracker: """Track and manage construction lien waivers."""
def __init__(self, project_id: str, project_name: str):
self.project_id = project_id
self.project_name = project_name
self.subcontractors: Dict[str, Subcontractor] = {}
self.waivers: Dict[str, LienWaiver] = {}
self.pay_apps: Dict[str, PaymentApplication] = {}
def add_subcontractor(self, id: str, name: str, trade: str,
contract_amount: float, contact_name: str,
contact_email: str, tier: int = 1) -> Subcontractor:
"""Add subcontractor to tracking."""
sub = Subcontractor(
id=id,
name=name,
trade=trade,
contract_amount=contract_amount,
contact_name=contact_name,
contact_email=contact_email,
tier=tier
)
self.subcontractors[id] = sub
return sub
def create_payment_application(self, number: int, period_end: datetime,
subcontractor_id: str, amount_requested: float,
retainage_rate: float = 0.10) -> PaymentApplication:
"""Create payment application record."""
if subcontractor_id not in self.subcontractors:
raise ValueError(f"Subcontractor {subcontractor_id} not found")
retainage = amount_requested * retainage_rate
amount_approved = amount_requested - retainage
pay_app = PaymentApplication(
number=number,
period_end=period_end,
subcontractor_id=subcontractor_id,
amount_requested=amount_requested,
amount_approved=amount_approved,
retainage=retainage
)
key = f"{subcontractor_id}-{number}"
self.pay_apps[key] = pay_app
# Create waiver request
self.request_waiver(subcontractor_id, number, period_end, amount_approved)
return pay_app
def request_waiver(self, subcontractor_id: str, pay_app_number: int,
through_date: datetime, amount: float,
waiver_type: WaiverType = WaiverType.CONDITIONAL_PROGRESS) -> LienWaiver:
"""Request lien waiver from subcontractor."""
waiver_id = f"LW-{subcontractor_id}-{pay_app_number}"
waiver = LienWaiver(
id=waiver_id,
subcontractor_id=subcontractor_id,
waiver_type=waiver_type,
payment_application=pay_app_number,
through_date=through_date,
amount=amount
)
self.waivers[waiver_id] = waiver
return waiver
def receive_waiver(self, waiver_id: str, file_path: str,
verified_by: str = "") -> LienWaiver:
"""Record receipt of lien waiver."""
if waiver_id not in self.waivers:
raise ValueError(f"Waiver {waiver_id} not found")
waiver = self.waivers[waiver_id]
waiver.status = WaiverStatus.RECEIVED
waiver.received_date = datetime.now()
waiver.file_path = file_path
waiver.verified_by = verified_by
# Check if all waivers for pay app complete
self._check_pay_app_waivers(waiver.subcontractor_id, waiver.payment_application)
return waiver
def verify_waiver(self, waiver_id: str, verified_by: str) -> LienWaiver:
"""Verify waiver details are correct."""
if waiver_id not in self.waivers:
raise ValueError(f"Waiver {waiver_id} not found")
waiver = self.waivers[waiver_id]
waiver.status = WaiverStatus.VERIFIED
waiver.verified_by = verified_by
return waiver
def reject_waiver(self, waiver_id: str, reason: str) -> LienWaiver:
"""Reject waiver (incorrect amount, wrong form, etc.)."""
if waiver_id not in self.waivers:
raise ValueError(f"Waiver {waiver_id} not found")
waiver = self.waivers[waiver_id]
waiver.status = WaiverStatus.REJECTED
waiver.notes = f"Rejected: {reason}"
return waiver
def convert_to_unconditional(self, waiver_id: str, payment_date: datetime) -> LienWaiver:
"""Convert conditional waiver to unconditional after payment clears."""
if waiver_id not in self.waivers:
raise ValueError(f"Waiver {waiver_id} not found")
waiver = self.waivers[waiver_id]
# Create new unconditional waiver
new_type = (WaiverType.UNCONDITIONAL_PROGRESS
if waiver.waiver_type == WaiverType.CONDITIONAL_PROGRESS
else WaiverType.UNCONDITIONAL_FINAL)
return self.request_waiver(
waiver.subcontractor_id,
waiver.payment_application,
waiver.through_date,
waiver.amount,
new_type
)
def _check_pay_app_waivers(self, subcontractor_id: str, pay_app_number: int):
"""Check if all waivers for pay app are received."""
key = f"{subcontractor_id}-{pay_app_number}"
if key not in self.pay_apps:
return
pay_app = self.pay_apps[key]
# Check all related waivers
related_waivers = [
w for w in self.waivers.values()
if w.subcontractor_id == subcontractor_id
and w.payment_application == pay_app_number
]
pay_app.waivers_complete = all(
w.status in [WaiverStatus.RECEIVED, WaiverStatus.VERIFIED]
for w in related_waivers
)
def calculate_exposure(self, subcontractor_id: str) -> LienExposure:
"""Calculate lien exposure for subcontractor."""
if subcontractor_id not in self.subcontractors:
raise ValueError(f"Subcontractor {subcontractor_id} not found")
sub = self.subcontractors[subcontractor_id]
# Sum payments
total_paid = sum(
pa.amount_approved for pa in self.pay_apps.values()
if pa.subcontractor_id == subcontractor_id
and pa.status == PaymentStatus.RELEASED
)
# Sum unconditional waivers
unconditional = sum(
w.amount for w in self.waivers.values()
if w.subcontractor_id == subcontractor_id
and w.waiver_type in [WaiverType.UNCONDITIONAL_PROGRESS, WaiverType.UNCONDITIONAL_FINAL]
and w.status in [WaiverStatus.RECEIVED, WaiverStatus.VERIFIED]
)
# Sum conditional pending
conditional = sum(
w.amount for w in self.waivers.values()
if w.subcontractor_id == subcontractor_id
and w.waiver_type in [WaiverType.CONDITIONAL_PROGRESS, WaiverType.CONDITIONAL_FINAL]
and w.status in [WaiverStatus.RECEIVED, WaiverStatus.VERIFIED]
)
# Exposure = Paid - Unconditional waivers
exposure = total_paid - unconditional
return LienExposure(
subcontractor_id=subcontractor_id,
subcontractor_name=sub.name,
total_paid=total_paid,
unconditional_waivers=unconditional,
conditional_pending=conditional,
exposure=exposure
)
def get_missing_waivers(self) -> List[Dict]:
"""Get list of missing or pending waivers."""
missing = []
for waiver in self.waivers.values():
if waiver.status in [WaiverStatus.REQUESTED, WaiverStatus.MISSING]:
sub = self.subcontractors.get(waiver.subcontractor_id)
missing.append({
"waiver_id": waiver.id,
"subcontractor": sub.name if sub else waiver.subcontractor_id,
"pay_app": waiver.payment_application,
"amount": waiver.amount,
"type": waiver.waiver_type.value,
"requested_date": waiver.requested_date,
"days_outstanding": (datetime.now() - waiver.requested_date).days
})
return sorted(missing, key=lambda x: -x["days_outstanding"])
def get_waiver_status_by_sub(self, subcontractor_id: str) -> Dict:
"""Get waiver status summary for subcontractor."""
if subcontractor_id not in self.subcontractors:
raise ValueError(f"Subcontractor {subcontractor_id} not found")
sub = self.subcontractors[subcontractor_id]
waivers = [w for w in self.waivers.values() if w.subcontractor_id == subcontractor_id]
by_status = {}
for w in waivers:
s = w.status.value
by_status[s] = by_status.get(s, 0) + 1
return {
"subcontractor": sub.name,
"total_waivers": len(waivers),
"by_status": by_status,
"total_amount": sum(w.amount for w in waivers),
"verified_amount": sum(w.amount for w in waivers if w.status == WaiverStatus.VERIFIED)
}
def can_release_payment(self, subcontractor_id: str, pay_app_number: int) -> Dict:
"""Check if payment can be released."""
key = f"{subcontractor_id}-{pay_app_number}"
if key not in self.pay_apps:
return {"can_release": False, "reason": "Payment application not found"}
pay_app = self.pay_apps[key]
# Check for conditional waiver
waivers = [
w for w in self.waivers.values()
if w.subcontractor_id == subcontractor_id
and w.payment_application == pay_app_number
and w.waiver_type == WaiverType.CONDITIONAL_PROGRESS
]
if not waivers:
return {"can_release": False, "reason": "No conditional waiver received"}
for w in waivers:
if w.status not in [WaiverStatus.RECEIVED, WaiverStatus.VERIFIED]:
return {"can_release": False, "reason": f"Waiver {w.id} not verified"}
return {"can_release": True, "reason": "All waivers verified", "amount": pay_app.amount_approved}
def generate_report(self) -> str:
"""Generate lien waiver status report."""
lines = [
"# Lien Waiver Status Report",
"",
f"**Project:** {self.project_name}",
f"**Date:** {datetime.now().strftime('%Y-%m-%d')}",
"",
"## Summary",
"",
f"- Total Subcontractors: {len(self.subcontractors)}",
f"- Total Waivers Tracked: {len(self.waivers)}",
f"- Missing Waivers: {len(self.get_missing_waivers())}",
"",
"## Exposure by Subcontractor",
"",
"| Subcontractor | Paid | Unconditional | Exposure |",
"|---------------|------|---------------|----------|"
]
total_exposure = 0
for sub_id in self.subcontractors:
exposure = self.calculate_exposure(sub_id)
total_exposure += exposure.exposure
lines.append(
f"| {exposure.subcontractor_name} | ${exposure.total_paid:,.0f} | "
f"${exposure.unconditional_waivers:,.0f} | ${exposure.exposure:,.0f} |"
)
lines.extend([
f"| **TOTAL** | | | **${total_exposure:,.0f}** |",
"",
"## Missing Waivers",
""
])
missing = self.get_missing_waivers()
if missing:
lines.append("| Subcontractor | Pay App | Amount | Days Outstanding |")
lines.append("|---------------|---------|--------|------------------|")
for m in missing[:10]:
lines.append(
f"| {m['subcontractor']} | #{m['pay_app']} | "
f"${m['amount']:,.0f} | {m['days_outstanding']} |"
)
else:
lines.append("*No missing waivers*")
return "\n".join(lines)
Quick Start
Initialize tracker
tracker = LienWaiverTracker("PRJ-001", "Office Tower")
Add subcontractors
tracker.add_subcontractor( "SUB-001", "ABC Mechanical", "HVAC", contract_amount=500000, contact_name="John Smith", contact_email="john@abcmech.com" )
tracker.add_subcontractor( "SUB-002", "XYZ Electric", "Electrical", contract_amount=350000, contact_name="Jane Doe", contact_email="jane@xyzelectric.com" )
Create payment applications
pa1 = tracker.create_payment_application( number=1, period_end=datetime.now(), subcontractor_id="SUB-001", amount_requested=50000 )
Receive waiver
waiver_id = f"LW-SUB-001-1" tracker.receive_waiver(waiver_id, "/waivers/sub001_pa1.pdf", "PM")
Check if can release payment
result = tracker.can_release_payment("SUB-001", 1) print(f"Can release: {result['can_release']} - {result['reason']}")
Calculate exposure
exposure = tracker.calculate_exposure("SUB-001") print(f"Lien exposure: ${exposure.exposure:,.2f}")
Generate report
print(tracker.generate_report())
Requirements
pip install (no external dependencies)