Phase 1: core models, security, seed data, and backend views

Implements full Phase 1 of the activeblue_familylaw Odoo 18 module:
- 17 Python models (fl.case, fl.party, fl.child, fl.support.calculation,
  fl.fee.waiver, fl.income.withholding, fl.deadline, fl.hearing,
  fl.deposition, fl.discovery, fl.document, fl.caselaw, fl.analysis,
  fl.ai.engine, fl.argument, fl.statute, fl.issue.tag) + hr.expense extension
- 3 wizard stubs (intake, analysis, generate-packet)
- Security: 4 groups (admin/paralegal/portal-petitioner/portal-respondent)
  + record rules scoping portal users to their own cases
- Seed data: issue tags, FL statutes, FL DCF support schedule, ir.sequence
- 13 backend view XML files with FL 61.30 worksheet, fee waiver
  eligibility banner, DV safety resources, emancipation alerts
- Static CSS/JS stubs for Phase 6 portal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Carlos Garcia
2026-05-04 18:52:04 -04:00
commit 1d52d85a78
46 changed files with 5626 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
from . import fl_statute
from . import fl_child
from . import fl_party
from . import fl_support
from . import fl_fee_waiver
from . import fl_income_withholding
from . import fl_expense_case
from . import fl_deadline
from . import fl_hearing
from . import fl_deposition
from . import fl_discovery
from . import fl_document
from . import fl_caselaw
from . import fl_analysis
from . import fl_ai_engine
from . import fl_argument
from . import fl_case

View File

@@ -0,0 +1,78 @@
import json
from odoo import models
OLLAMA_URL = 'http://192.168.2.10:11434/api/generate'
OLLAMA_MODEL = 'llama3.1'
class FlAiEngine(models.AbstractModel):
"""
Phase 5 — Full Ollama integration.
Phase 1: Stub service model.
This is an AbstractModel — not stored in the database.
Used as a service class for AI analysis calls.
"""
_name = 'fl.ai.engine'
_description = 'Family Law AI Analysis Engine (Ollama)'
def analyze_case(self, case_id):
"""
Phase 5 entry point.
Full workflow:
1. Rule-based issue tagging
2. Build case context JSON
3. Call Ollama (llama3.1)
4. Parse JSON response
5. Store fl.analysis record
"""
case = self.env['fl.case'].browse(case_id)
analysis = self.env['fl.analysis'].create({
'case_id': case.id,
'state': 'pending',
'model_used': OLLAMA_MODEL,
'plain_english_summary': (
'AI analysis not yet implemented. '
'Full analysis will be available in Phase 5.'
),
'plain_english_summary_es': (
'El análisis de IA aún no está implementado. '
'El análisis completo estará disponible en la Fase 5.'
),
'state': 'complete',
})
return analysis
def _call_ollama(self, prompt):
"""Call Ollama API and return parsed JSON response."""
try:
import requests
except ImportError:
raise RuntimeError(
'requests library not available. '
'Install with: pip install requests'
)
response = requests.post(
OLLAMA_URL,
json={
'model': OLLAMA_MODEL,
'prompt': prompt,
'stream': False,
'options': {
'temperature': 0.1,
'top_p': 0.9,
'num_predict': 2000,
},
},
timeout=180,
)
response.raise_for_status()
raw = response.json().get('response', '{}').strip()
# Strip markdown code fences if present
if raw.startswith('```'):
parts = raw.split('```')
raw = parts[1] if len(parts) > 1 else raw
if raw.startswith('json'):
raw = raw[4:]
return json.loads(raw.strip())

View File

@@ -0,0 +1,79 @@
from odoo import fields, models
class FlAnalysis(models.Model):
"""
Phase 5 — Full Ollama AI analysis implementation.
Phase 1: Stub with fields required by fl_case computed fields.
"""
_name = 'fl.analysis'
_description = 'AI Case Analysis Result'
_order = 'create_date desc'
case_id = fields.Many2one(
'fl.case', ondelete='cascade', index=True
)
analysis_date = fields.Datetime(
string='Analysis Date',
default=fields.Datetime.now
)
model_used = fields.Char(
string='AI Model',
default='llama3.1'
)
# ── Results (referenced by fl_case related fields) ─────────────────────
attorney_referral_flag = fields.Boolean(
string='Attorney Referral Recommended',
default=False
)
attorney_referral_reason = fields.Text(
string='Attorney Referral Reason'
)
plain_english_summary = fields.Text(
string='Plain English Summary (EN)',
help='3-5 sentence summary of case analysis — no legal jargon'
)
plain_english_summary_es = fields.Text(
string='Plain English Summary (ES)',
help='Resumen en español — sin jerga legal'
)
# ── Analysis Detail (Phase 5) ──────────────────────────────────────────
petitioner_arguments = fields.Text(
string='Petitioner Arguments (JSON)'
)
respondent_counterarguments = fields.Text(
string='Respondent Counter-Arguments (JSON)'
)
procedural_risks = fields.Text(
string='Procedural Risks (JSON)'
)
matched_caselaw_ids = fields.Many2many(
'fl.caselaw',
'fl_analysis_caselaw_rel',
'analysis_id', 'caselaw_id',
string='Matched Case Law'
)
confidence_level = fields.Selection([
('high', 'High'),
('medium', 'Medium'),
('low', 'Low'),
], string='Confidence Level')
case_complexity = fields.Selection([
('simple', 'Simple'),
('moderate', 'Moderate'),
('complex', 'Complex'),
], string='Case Complexity')
raw_response = fields.Text(
string='Raw AI Response',
help='Full JSON response from Ollama — for debugging'
)
error_message = fields.Text(
string='Error (if analysis failed)'
)
state = fields.Selection([
('pending', 'Pending'),
('complete', 'Complete'),
('failed', 'Failed'),
], string='Status', default='pending')

View File

@@ -0,0 +1,47 @@
from odoo import fields, models
class FlArgument(models.Model):
"""
Phase 5 — AI-generated legal argument builder.
Phase 1: Stub.
"""
_name = 'fl.argument'
_description = 'Legal Argument'
_order = 'sequence, id'
case_id = fields.Many2one(
'fl.case', ondelete='cascade', index=True
)
sequence = fields.Integer(default=10)
argument_text = fields.Text(
string='Argument',
required=True
)
argument_type = fields.Selection([
('petitioner', 'Petitioner Argument'),
('respondent', 'Respondent Counter-Argument'),
('procedural', 'Procedural Point'),
], string='Type', default='petitioner')
strength = fields.Selection([
('strong', 'Strong'),
('moderate', 'Moderate'),
('weak', 'Weak'),
], string='Argument Strength')
caselaw_ids = fields.Many2many(
'fl.caselaw',
'fl_argument_caselaw_rel',
'argument_id', 'caselaw_id',
string='Supporting Case Law'
)
statute_ids = fields.Many2many(
'fl.statute',
'fl_argument_statute_rel',
'argument_id', 'statute_id',
string='Supporting Statutes'
)
notes = fields.Text(string='Notes')
ai_generated = fields.Boolean(
string='AI Generated',
default=False
)

View File

@@ -0,0 +1,952 @@
from odoo import _, api, fields, models
from dateutil.relativedelta import relativedelta
class FlCase(models.Model):
_name = 'fl.case'
_description = 'Florida Family Law Case'
_inherit = ['mail.thread', 'mail.activity.mixin', 'portal.mixin']
_order = 'create_date desc'
_rec_name = 'name'
# ══════════════════════════════════════════════════════════════════════
# IDENTITY
# ══════════════════════════════════════════════════════════════════════
name = fields.Char(
string='Case Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: _('New'),
help='Auto-generated internal reference (FL-YYYY-NNNNN)'
)
court_case_number = fields.Char(
string='Court Case Number',
tracking=True,
help='Assigned by Miami-Dade Clerk after filing. '
'Format: YYYY-DR-XXXXXX-XX (e.g. 2025-DR-012345-01)'
)
case_type = fields.Selection([
('modification', 'Child Support Modification'),
('dissolution_children', 'Dissolution of Marriage — With Children'),
('dissolution_no_children', 'Dissolution of Marriage — No Children'),
('paternity', 'Paternity'),
('alimony_modification', 'Alimony Modification'),
('custody_modification', 'Timesharing / Custody Modification'),
], string='Case Type', required=True, tracking=True)
circuit = fields.Char(
string='Circuit', default='11th', readonly=True,
help='11th Judicial Circuit — Miami-Dade County'
)
county = fields.Char(
string='County', default='Miami-Dade', readonly=True
)
division = fields.Selection([
('1', 'Division 1'), ('2', 'Division 2'),
('3', 'Division 3'), ('4', 'Division 4'),
('5', 'Division 5'), ('6', 'Division 6'),
('7', 'Division 7'), ('8', 'Division 8'),
], string='Court Division', tracking=True)
judge_id = fields.Many2one(
'res.partner', string='Assigned Judge',
domain=[('is_company', '=', False)]
)
mediator_id = fields.Many2one(
'res.partner', string='Mediator'
)
# ══════════════════════════════════════════════════════════════════════
# STAGE / STATUS
# ══════════════════════════════════════════════════════════════════════
stage = fields.Selection([
('intake', 'Intake & Qualification'),
('preparation', 'Document Preparation'),
('filed', 'Filed — Awaiting Service'),
('service_complete', 'Service Complete'),
('discovery', 'Discovery'),
('deposition', 'Deposition Stage'),
('mediation', 'Mediation'),
('hearing_scheduled', 'Hearing Scheduled'),
('order_entered', 'Order Entered'),
('closed', 'Closed'),
('referred_out', 'Referred to Attorney'),
], string='Stage', default='intake', tracking=True)
active = fields.Boolean(default=True)
# ══════════════════════════════════════════════════════════════════════
# PARTIES
# ══════════════════════════════════════════════════════════════════════
petitioner_id = fields.Many2one(
'res.partner', string='Petitioner',
required=True, tracking=True
)
respondent_id = fields.Many2one(
'res.partner', string='Respondent',
tracking=True
)
petitioner_attorney_id = fields.Many2one(
'res.partner', string='Petitioner Attorney',
help='Leave blank if petitioner is pro se'
)
respondent_attorney_id = fields.Many2one(
'res.partner', string='Respondent Attorney',
tracking=True,
help='If respondent has counsel, pro se petitioner faces significant disadvantage'
)
respondent_has_counsel = fields.Boolean(
string='Respondent Has Counsel',
compute='_compute_respondent_has_counsel', store=True
)
party_ids = fields.One2many(
'fl.party', 'case_id', string='Party Details'
)
# ══════════════════════════════════════════════════════════════════════
# CHILDREN
# ══════════════════════════════════════════════════════════════════════
child_ids = fields.One2many(
'fl.child', 'case_id', string='Children'
)
children_count = fields.Integer(
string='Number of Children',
compute='_compute_children_count', store=True
)
has_minor_children = fields.Boolean(
string='Has Minor Children',
compute='_compute_has_minor_children', store=True
)
# ══════════════════════════════════════════════════════════════════════
# KEY DATES
# ══════════════════════════════════════════════════════════════════════
filing_date = fields.Date(
string='Filing Date', tracking=True
)
service_date = fields.Date(
string='Service Date', tracking=True
)
last_order_date = fields.Date(
string='Date of Last Order',
tracking=True,
help='Required for modification cases — date of the existing order being modified'
)
marriage_date = fields.Date(string='Date of Marriage')
separation_date = fields.Date(string='Date of Separation')
years_of_marriage = fields.Float(
string='Years of Marriage',
compute='_compute_years_of_marriage', store=True
)
# ══════════════════════════════════════════════════════════════════════
# RESIDENCY (FL 61.021)
# ══════════════════════════════════════════════════════════════════════
petitioner_fl_resident_since = fields.Date(
string='Petitioner FL Resident Since',
help='FL 61.021: Must be FL resident for 6 months before filing'
)
residency_requirement_met = fields.Boolean(
string='Residency Requirement Met',
compute='_compute_residency_met', store=True
)
residency_warning = fields.Char(
string='Residency Warning',
compute='_compute_residency_met'
)
# ══════════════════════════════════════════════════════════════════════
# SAFETY FLAGS
# ══════════════════════════════════════════════════════════════════════
domestic_violence_flag = fields.Boolean(
string='Domestic Violence Present',
tracking=True,
help='FL 741.30 / FL 61.13(2)(c). '
'Affects mediation (separate rooms required), timesharing, '
'and ALL court proceedings. Forces attorney referral.'
)
dv_injunction_active = fields.Boolean(
string='Active Injunction (FL 741.30)'
)
dv_safety_note = fields.Html(
string='Safety Information',
compute='_compute_dv_safety_note'
)
# ══════════════════════════════════════════════════════════════════════
# EXISTING ORDER (MODIFICATION CASES)
# ══════════════════════════════════════════════════════════════════════
current_order_amount = fields.Float(
string='Current Monthly Support Order ($)',
tracking=True,
help='Amount in the existing court order that is being modified'
)
current_order_per_child = fields.Boolean(
string='Amount is Per-Child (not total)',
help='Check if the existing order states an amount per child rather than total'
)
current_order_total = fields.Float(
string='Current Order Total ($)',
compute='_compute_current_order_total', store=True
)
# ══════════════════════════════════════════════════════════════════════
# CHILD SUPPORT CALCULATION (FL 61.30)
# ══════════════════════════════════════════════════════════════════════
support_calc_id = fields.Many2one(
'fl.support.calculation',
string='Active Support Calculation',
ondelete='cascade'
)
calculated_support = fields.Float(
string='Calculated Support ($)',
related='support_calc_id.total_support_obligation',
store=True
)
support_difference = fields.Float(
string='Difference from Current Order ($)',
compute='_compute_support_difference', store=True
)
support_difference_pct = fields.Float(
string='Difference %',
compute='_compute_support_difference', store=True
)
# ══════════════════════════════════════════════════════════════════════
# MODIFICATION THRESHOLD TEST (FL 61.30(1)(b))
# ══════════════════════════════════════════════════════════════════════
threshold_met = fields.Boolean(
string='Modification Threshold Met',
compute='_compute_threshold', store=True,
help='FL 61.30(1)(b): Modification qualifies when change is '
'both ≥ $50 AND ≥ 15% of the current order'
)
threshold_result = fields.Html(
string='Threshold Analysis',
compute='_compute_threshold'
)
substantial_change_basis = fields.Selection([
('income_decrease_petitioner', 'Petitioner Income Decreased'),
('income_decrease_respondent', 'Respondent Income Decreased'),
('income_increase_petitioner', 'Petitioner Income Increased'),
('income_increase_respondent', 'Respondent Income Increased'),
('timesharing_change', 'Timesharing Schedule Changed'),
('child_needs_change', "Child's Needs Changed"),
('child_emancipation', 'Child Reaching 18 / Emancipation'),
('other', 'Other Substantial Change'),
], string='Basis for Modification (FL 61.14)')
substantial_change_detail = fields.Text(
string='Describe the Substantial Change'
)
# ══════════════════════════════════════════════════════════════════════
# TIMESHARING
# ══════════════════════════════════════════════════════════════════════
timesharing_changed = fields.Boolean(
string='Timesharing Schedule Has Changed'
)
petitioner_overnights = fields.Integer(
string='Petitioner Overnights / Year',
help='Number of overnights per year the petitioner has with the children'
)
respondent_overnights = fields.Integer(
string='Respondent Overnights / Year',
compute='_compute_respondent_overnights', store=True
)
petitioner_timesharing_pct = fields.Float(
string='Petitioner Timesharing %',
compute='_compute_timesharing_pct', store=True
)
substantial_timesharing_applies = fields.Boolean(
string='Substantial Timesharing Adjustment Applies',
compute='_compute_timesharing_pct', store=True,
help='FL 61.30(11)(b): Applies if either parent has > 73 overnights/year (20%)'
)
# ══════════════════════════════════════════════════════════════════════
# FEE WAIVER (FL 57.082)
# ══════════════════════════════════════════════════════════════════════
fee_waiver_eligible = fields.Boolean(
string='Fee Waiver Potentially Eligible',
compute='_compute_fee_waiver_eligibility', store=True,
help='Based on petitioner income vs 200% FPL (FL 57.082)'
)
fee_waiver_id = fields.Many2one(
'fl.fee.waiver', string='Fee Waiver Application'
)
# ══════════════════════════════════════════════════════════════════════
# PARENTING CLASS (FL 61.21)
# ══════════════════════════════════════════════════════════════════════
parenting_class_required = fields.Boolean(
string='Parenting Class Required',
compute='_compute_parenting_class_required', store=True,
help='FL 61.21: Mandatory when minor children are involved'
)
petitioner_parenting_class_done = fields.Boolean(
string='Petitioner Parenting Class Complete'
)
petitioner_parenting_class_date = fields.Date(
string='Petitioner Completion Date'
)
respondent_parenting_class_done = fields.Boolean(
string='Respondent Parenting Class Complete'
)
respondent_parenting_class_date = fields.Date(
string='Respondent Completion Date'
)
parenting_class_warning = fields.Char(
string='Parenting Class Status',
compute='_compute_parenting_class_warning'
)
# ══════════════════════════════════════════════════════════════════════
# DEADLINES & CALENDAR
# ══════════════════════════════════════════════════════════════════════
deadline_ids = fields.One2many(
'fl.deadline', 'case_id', string='Deadlines'
)
next_deadline = fields.Date(
string='Next Deadline',
compute='_compute_next_deadline', store=True
)
next_deadline_label = fields.Char(
string='Next Deadline Description',
compute='_compute_next_deadline'
)
overdue_deadline_count = fields.Integer(
string='Overdue Deadlines',
compute='_compute_next_deadline'
)
# ══════════════════════════════════════════════════════════════════════
# HEARINGS
# ══════════════════════════════════════════════════════════════════════
hearing_ids = fields.One2many(
'fl.hearing', 'case_id', string='Hearings'
)
next_hearing_date = fields.Datetime(
string='Next Hearing',
compute='_compute_next_hearing', store=True
)
discovery_cutoff_date = fields.Date(
string='Discovery Cutoff',
compute='_compute_discovery_cutoff', store=True,
help='30 days before next hearing date'
)
# ══════════════════════════════════════════════════════════════════════
# DEPOSITIONS & DISCOVERY
# ══════════════════════════════════════════════════════════════════════
income_disputed = fields.Boolean(
string='Income Figures Disputed',
tracking=True,
help='If True, deposition workflow is activated'
)
deposition_ids = fields.One2many(
'fl.deposition', 'case_id', string='Depositions'
)
discovery_ids = fields.One2many(
'fl.discovery', 'case_id', string='Discovery Items'
)
# ══════════════════════════════════════════════════════════════════════
# DOCUMENTS
# ══════════════════════════════════════════════════════════════════════
document_ids = fields.One2many(
'fl.document', 'case_id', string='Case Documents'
)
# ══════════════════════════════════════════════════════════════════════
# PROJECT / TASK INTEGRATION
# ══════════════════════════════════════════════════════════════════════
project_id = fields.Many2one(
'project.project', string='Case Project',
help='Auto-created project for case task management'
)
task_count = fields.Integer(
string='Tasks', compute='_compute_task_count'
)
# ══════════════════════════════════════════════════════════════════════
# CASE LAW & AI ANALYSIS
# ══════════════════════════════════════════════════════════════════════
caselaw_ids = fields.Many2many(
'fl.caselaw', string='Applicable Case Law'
)
analysis_ids = fields.One2many(
'fl.analysis', 'case_id', string='AI Analyses'
)
latest_analysis_id = fields.Many2one(
'fl.analysis',
string='Latest Analysis',
compute='_compute_latest_analysis', store=True
)
attorney_referral_flag = fields.Boolean(
string='Attorney Referral Recommended',
compute='_compute_attorney_referral_flag', store=True
)
ai_plain_english = fields.Text(
string='AI Summary (English)',
related='latest_analysis_id.plain_english_summary'
)
ai_plain_english_es = fields.Text(
string='AI Summary (Spanish)',
related='latest_analysis_id.plain_english_summary_es'
)
# ══════════════════════════════════════════════════════════════════════
# EXPENSES
# ══════════════════════════════════════════════════════════════════════
expense_ids = fields.One2many(
'hr.expense', 'fl_case_id', string='Case Expenses'
)
total_expenses = fields.Float(
string='Total Case Expenses ($)',
compute='_compute_total_expenses', store=True
)
# ══════════════════════════════════════════════════════════════════════
# POST-ORDER
# ══════════════════════════════════════════════════════════════════════
new_order_amount = fields.Float(
string='New Order Amount ($)', tracking=True
)
new_order_date = fields.Date(
string='New Order Date', tracking=True
)
income_withholding_id = fields.Many2one(
'fl.income.withholding',
string='Income Withholding Order'
)
retroactivity_date = fields.Date(
string='Retroactivity Date',
compute='_compute_retroactivity_date', store=True,
help='FL 61.30(17): Modification is retroactive to filing date ONLY. '
'Cannot seek support modification for periods before filing.'
)
respondent_answered = fields.Boolean(
string='Respondent Filed Answer',
tracking=True
)
# ══════════════════════════════════════════════════════════════════════
# COMPUTED METHODS
# ══════════════════════════════════════════════════════════════════════
@api.depends('respondent_attorney_id')
def _compute_respondent_has_counsel(self):
for rec in self:
rec.respondent_has_counsel = bool(rec.respondent_attorney_id)
@api.depends('child_ids')
def _compute_children_count(self):
for rec in self:
rec.children_count = len(rec.child_ids)
@api.depends('child_ids.emancipated', 'child_ids.date_of_birth')
def _compute_has_minor_children(self):
for rec in self:
rec.has_minor_children = any(
not c.emancipated for c in rec.child_ids
)
@api.depends('marriage_date', 'separation_date', 'filing_date')
def _compute_years_of_marriage(self):
for rec in self:
end_date = rec.separation_date or rec.filing_date
if rec.marriage_date and end_date:
delta = relativedelta(end_date, rec.marriage_date)
rec.years_of_marriage = round(
delta.years + delta.months / 12 + delta.days / 365, 2
)
else:
rec.years_of_marriage = 0.0
@api.depends('petitioner_fl_resident_since', 'filing_date')
def _compute_residency_met(self):
for rec in self:
if rec.petitioner_fl_resident_since and rec.filing_date:
required_date = rec.petitioner_fl_resident_since + relativedelta(months=6)
rec.residency_requirement_met = rec.filing_date >= required_date
if not rec.residency_requirement_met:
months_short = relativedelta(required_date, rec.filing_date)
rec.residency_warning = (
f'⚠️ FL 61.021: Petitioner must be a FL resident for 6 months '
f'before filing. Currently {months_short.months} month(s) short.'
)
else:
rec.residency_warning = '✅ Residency requirement met (FL 61.021)'
elif rec.petitioner_fl_resident_since and not rec.filing_date:
rec.residency_requirement_met = False
rec.residency_warning = 'Enter filing date to verify residency requirement'
else:
rec.residency_requirement_met = False
rec.residency_warning = '⚠️ Enter FL residency start date to verify FL 61.021'
@api.depends('domestic_violence_flag', 'dv_injunction_active')
def _compute_dv_safety_note(self):
for rec in self:
if rec.domestic_violence_flag:
injunction_note = (
'<br/><b>Active injunction on file.</b>'
if rec.dv_injunction_active else ''
)
rec.dv_safety_note = (
'<div style="background:#ffeeba;border:2px solid #e6ac00;'
'padding:12px;border-radius:4px;">'
'<h4 style="color:#856404;margin:0 0 8px 0;">'
'⚠️ DOMESTIC VIOLENCE SAFETY NOTICE</h4>'
'<p>Domestic violence has been flagged in this case. '
'This affects your rights and safety throughout this proceeding.</p>'
'<ul>'
'<li><b>Mediation:</b> Separate rooms are REQUIRED (FL 44.102)</li>'
'<li><b>Representation:</b> Pro se representation is strongly discouraged</li>'
'<li><b>Contact:</b> Do not communicate with the opposing party directly</li>'
'</ul>'
f'{injunction_note}'
'<p><b>Resources:</b><br/>'
'Legal Services of Greater Miami: <a href="tel:3055760080">(305) 576-0080</a><br/>'
'Safespace Miami: <a href="tel:3055365565">(305) 536-5565</a><br/>'
'National DV Hotline: <a href="tel:18007997233">1-800-799-7233</a></p>'
'</div>'
)
else:
rec.dv_safety_note = ''
@api.depends('current_order_amount', 'current_order_per_child', 'children_count')
def _compute_current_order_total(self):
for rec in self:
if rec.current_order_per_child and rec.children_count:
rec.current_order_total = (
rec.current_order_amount * rec.children_count
)
else:
rec.current_order_total = rec.current_order_amount
@api.depends('calculated_support', 'current_order_total')
def _compute_support_difference(self):
for rec in self:
current = rec.current_order_total or 0.0
proposed = rec.calculated_support or 0.0
rec.support_difference = proposed - current
if current > 0:
rec.support_difference_pct = (
abs(proposed - current) / current
) * 100
else:
rec.support_difference_pct = 0.0
@api.depends('support_difference', 'support_difference_pct', 'case_type')
def _compute_threshold(self):
"""
FL 61.30(1)(b): Modification qualifies when BOTH:
- |difference| >= $50 AND
- |difference| / current_amount >= 15%
"""
for rec in self:
if rec.case_type != 'modification':
rec.threshold_met = False
rec.threshold_result = ''
continue
diff = abs(rec.support_difference)
pct = rec.support_difference_pct
current = rec.current_order_total or 0.0
dollar_test = diff >= 50.0
pct_test = pct >= 15.0
if current == 0:
rec.threshold_met = False
rec.threshold_result = (
'<span style="color:orange;">⚠️ Enter current order amount '
'to run threshold test</span>'
)
continue
color_dollar = '#28a745' if dollar_test else '#dc3545'
color_pct = '#28a745' if pct_test else '#dc3545'
direction = 'increase' if rec.support_difference > 0 else 'decrease'
rec.threshold_met = dollar_test and pct_test
overall_color = '#28a745' if rec.threshold_met else '#dc3545'
overall_icon = '' if rec.threshold_met else ''
overall_label = 'QUALIFIES FOR MODIFICATION' if rec.threshold_met else 'DOES NOT QUALIFY'
rec.threshold_result = (
f'<div style="border:1px solid {overall_color};padding:10px;'
f'border-radius:4px;">'
f'<b style="color:{overall_color};">{overall_icon} {overall_label}</b>'
f'<br/><br/>'
f'Current order: <b>${current:,.2f}/mo</b><br/>'
f'Proposed: <b>${rec.calculated_support:,.2f}/mo</b> '
f'({direction} of <b>${diff:,.2f}</b>)<br/><br/>'
f'<span style="color:{color_dollar};">'
f'{"" if dollar_test else ""} $50 test: '
f'${diff:,.2f} {"" if dollar_test else "<"} $50</span><br/>'
f'<span style="color:{color_pct};">'
f'{"" if pct_test else ""} 15% test: '
f'{pct:.1f}% {"" if pct_test else "<"} 15%</span>'
f'</div>'
)
@api.depends('petitioner_overnights')
def _compute_respondent_overnights(self):
for rec in self:
if rec.petitioner_overnights:
rec.respondent_overnights = 365 - rec.petitioner_overnights
else:
rec.respondent_overnights = 0
@api.depends('petitioner_overnights')
def _compute_timesharing_pct(self):
for rec in self:
if rec.petitioner_overnights:
pct = rec.petitioner_overnights / 365
rec.petitioner_timesharing_pct = round(pct * 100, 2)
# FL 61.30(11)(b): substantial = either parent > 73 overnights (20%)
pet_substantial = rec.petitioner_overnights > 73
resp_substantial = (365 - rec.petitioner_overnights) > 73
rec.substantial_timesharing_applies = pet_substantial or resp_substantial
else:
rec.petitioner_timesharing_pct = 0.0
rec.substantial_timesharing_applies = False
@api.depends('party_ids.effective_monthly_income', 'party_ids.role')
def _compute_fee_waiver_eligibility(self):
"""
Quick eligibility check: petitioner income < 200% FPL (FL 57.082).
Full determination via fl.fee.waiver model.
2025: 1-person 200% FPL = $30,120/yr = $2,510/mo
"""
FPL_200PCT_1PERSON_MONTHLY = 2510.0
for rec in self:
petitioner_party = rec.party_ids.filtered(
lambda p: p.role == 'petitioner'
)
if petitioner_party:
income = petitioner_party[0].effective_monthly_income or 0.0
rec.fee_waiver_eligible = income <= FPL_200PCT_1PERSON_MONTHLY
else:
rec.fee_waiver_eligible = False
@api.depends('has_minor_children')
def _compute_parenting_class_required(self):
for rec in self:
rec.parenting_class_required = rec.has_minor_children
@api.depends(
'parenting_class_required',
'petitioner_parenting_class_done',
'respondent_parenting_class_done',
)
def _compute_parenting_class_warning(self):
for rec in self:
if not rec.parenting_class_required:
rec.parenting_class_warning = 'Not required'
continue
pet_done = rec.petitioner_parenting_class_done
resp_done = rec.respondent_parenting_class_done
if pet_done and resp_done:
rec.parenting_class_warning = '✅ Both parents completed'
elif pet_done:
rec.parenting_class_warning = '⚠️ Respondent parenting class pending'
elif resp_done:
rec.parenting_class_warning = '⚠️ Petitioner parenting class pending'
else:
rec.parenting_class_warning = '❌ Neither parent has completed parenting class'
@api.depends('deadline_ids.due_date', 'deadline_ids.completed', 'deadline_ids.waived')
def _compute_next_deadline(self):
today = fields.Date.today()
for rec in self:
pending = rec.deadline_ids.filtered(
lambda d: not d.completed and not d.waived
)
overdue = pending.filtered(lambda d: d.due_date < today)
upcoming = pending.filtered(
lambda d: d.due_date >= today
).sorted('due_date')
rec.overdue_deadline_count = len(overdue)
if upcoming:
next_dl = upcoming[0]
rec.next_deadline = next_dl.due_date
rec.next_deadline_label = next_dl.name
elif overdue:
oldest = overdue.sorted('due_date')[0]
rec.next_deadline = oldest.due_date
rec.next_deadline_label = f'OVERDUE: {oldest.name}'
else:
rec.next_deadline = False
rec.next_deadline_label = 'No pending deadlines'
@api.depends('hearing_ids.hearing_date')
def _compute_next_hearing(self):
now = fields.Datetime.now()
for rec in self:
future = rec.hearing_ids.filtered(
lambda h: h.hearing_date and h.hearing_date > now
and h.state not in ('cancelled',)
).sorted('hearing_date')
rec.next_hearing_date = future[0].hearing_date if future else False
@api.depends('next_hearing_date')
def _compute_discovery_cutoff(self):
for rec in self:
if rec.next_hearing_date:
rec.discovery_cutoff_date = (
rec.next_hearing_date.date() - relativedelta(days=30)
)
else:
rec.discovery_cutoff_date = False
def _compute_task_count(self):
for rec in self:
if rec.project_id:
rec.task_count = self.env['project.task'].search_count([
('project_id', '=', rec.project_id.id)
])
else:
rec.task_count = 0
@api.depends('analysis_ids')
def _compute_latest_analysis(self):
for rec in self:
latest = rec.analysis_ids.sorted('create_date', reverse=True)
rec.latest_analysis_id = latest[0] if latest else False
@api.depends('latest_analysis_id.attorney_referral_flag', 'domestic_violence_flag', 'respondent_has_counsel')
def _compute_attorney_referral_flag(self):
for rec in self:
rec.attorney_referral_flag = (
rec.domestic_violence_flag
or rec.respondent_has_counsel
or (rec.latest_analysis_id and rec.latest_analysis_id.attorney_referral_flag)
)
@api.depends('expense_ids.total_amount')
def _compute_total_expenses(self):
for rec in self:
rec.total_expenses = sum(rec.expense_ids.mapped('total_amount'))
@api.depends('filing_date')
def _compute_retroactivity_date(self):
"""
FL 61.30(17): Modification is retroactive to the filing date ONLY.
"""
for rec in self:
rec.retroactivity_date = rec.filing_date
# ══════════════════════════════════════════════════════════════════════
# CRUD OVERRIDES
# ══════════════════════════════════════════════════════════════════════
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code(
'fl.case'
) or _('New')
records = super().create(vals_list)
for record in records:
# 1. Create Odoo project for task management
record._create_case_project()
# 2. Generate deadlines if filing date already set
if record.filing_date:
self.env['fl.deadline'].generate_deadlines_for_case(record)
# 3. Check fee waiver eligibility (quick check)
record._check_fee_waiver_eligibility()
# 4. Handle DV flag
if record.domestic_violence_flag:
record._handle_dv_flag()
return records
def write(self, vals):
result = super().write(vals)
# Recalculate service-anchored deadlines when service_date is set/changed
if 'service_date' in vals:
for rec in self:
if rec.service_date:
self.env['fl.deadline'].recalculate_service_deadlines(rec)
# Handle DV flag being set
if vals.get('domestic_violence_flag'):
for rec in self:
if rec.domestic_violence_flag:
rec._handle_dv_flag()
# Generate income withholding order when new_order_amount is set
if vals.get('new_order_amount') and vals.get('new_order_date'):
for rec in self:
rec._check_generate_income_withholding()
return result
# ══════════════════════════════════════════════════════════════════════
# WORKFLOW METHODS
# ══════════════════════════════════════════════════════════════════════
def _create_case_project(self):
"""Create a linked Odoo project for case task management."""
project = self.env['project.project'].create({
'name': f'Case: {self.name}{self.petitioner_id.name}',
'partner_id': self.petitioner_id.id,
'description': (
f'Family law case project for {self.petitioner_id.name}. '
f'Case type: {dict(self._fields["case_type"].selection).get(self.case_type, "")}'
),
})
self.project_id = project
def _check_fee_waiver_eligibility(self):
"""Post a chatter note if petitioner appears to qualify for fee waiver."""
if self.fee_waiver_eligible and not self.fee_waiver_id:
self.message_post(
body=(
'💡 <b>Fee Waiver Opportunity:</b> Based on the income information '
'provided, this petitioner may qualify for a civil indigent fee '
'waiver (FL 57.082). This would waive filing fees, service fees, '
'and potentially mediator fees. '
'Consider creating a Fee Waiver application from the case form.'
),
subtype_xmlid='mail.mt_note',
)
def _handle_dv_flag(self):
"""
Handle domestic violence flag being set on a case.
Per spec: Force attorney referral, post safety resources,
require separate mediation rooms, disable direct contact.
"""
self.message_post(
body=(
'<div style="background:#f8d7da;border:2px solid #dc3545;'
'padding:12px;border-radius:4px;">'
'<h4 style="color:#721c24;margin:0 0 8px 0;">'
'🚨 SAFETY ALERT — DOMESTIC VIOLENCE FLAGGED</h4>'
'<ul>'
'<li>Separate mediation rooms are <b>REQUIRED</b> (FL 44.102)</li>'
'<li>Pro se representation in DV cases is <b>STRONGLY DISCOURAGED</b></li>'
'<li>Attorney referral has been flagged on this case</li>'
'<li>Do NOT allow direct portal contact between parties</li>'
'</ul>'
'<b>Resources:</b><br/>'
'Legal Services of Greater Miami: (305) 576-0080<br/>'
'Safespace Miami: (305) 536-5565<br/>'
'National DV Hotline: 1-800-799-7233<br/>'
'TTY: 1-800-787-3224'
'</div>'
),
subtype_xmlid='mail.mt_note',
)
def _check_generate_income_withholding(self):
"""
FL 61.1301: Auto-generate income withholding order when new order amount is set.
Mandatory unless good cause or written agreement.
"""
if self.income_withholding_id:
return # Already exists
iwo = self.env['fl.income.withholding'].create({
'case_id': self.id,
'obligor_id': self.respondent_id.id,
'obligee_id': self.petitioner_id.id,
'monthly_support_amount': self.new_order_amount,
})
self.income_withholding_id = iwo
self.message_post(
body=(
f'⚖️ <b>Income Withholding Order created</b> (FL 61.1301).<br/>'
f'Amount: <b>${self.new_order_amount:,.2f}/mo</b>.<br/>'
f'Income withholding is MANDATORY under FL 61.1301 unless good cause '
f'is shown or both parties agree in writing to an alternative payment method.'
),
subtype_xmlid='mail.mt_note',
)
def trigger_ai_analysis(self):
"""
Trigger AI case analysis via Ollama.
Stub for Phase 1 — full implementation in Phase 5.
"""
self.message_post(
body='🤖 AI analysis queued. Full AI analysis will be available in Phase 5.',
subtype_xmlid='mail.mt_note',
)
# ══════════════════════════════════════════════════════════════════════
# ACTIONS
# ══════════════════════════════════════════════════════════════════════
def action_view_tasks(self):
return {
'type': 'ir.actions.act_window',
'name': 'Case Tasks',
'res_model': 'project.task',
'view_mode': 'tree,form',
'domain': [('project_id', '=', self.project_id.id)],
'context': {'default_project_id': self.project_id.id},
}
def action_open_support_calculator(self):
if not self.support_calc_id:
calc = self.env['fl.support.calculation'].create({
'case_id': self.id,
'calculation_type': 'proposed',
})
self.support_calc_id = calc
return {
'type': 'ir.actions.act_window',
'name': 'FL 61.30 Support Calculator',
'res_model': 'fl.support.calculation',
'res_id': self.support_calc_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_create_fee_waiver(self):
waiver = self.env['fl.fee.waiver'].create({
'case_id': self.id,
'party_id': self.petitioner_id.id,
})
self.fee_waiver_id = waiver
return {
'type': 'ir.actions.act_window',
'name': 'Fee Waiver Application',
'res_model': 'fl.fee.waiver',
'res_id': waiver.id,
'view_mode': 'form',
'target': 'current',
}
def action_run_ai_analysis(self):
return {
'type': 'ir.actions.act_window',
'name': 'Run AI Analysis',
'res_model': 'fl.analysis.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_case_id': self.id},
}

View File

@@ -0,0 +1,95 @@
import urllib.parse
from odoo import api, fields, models
class FlCaselaw(models.Model):
"""
Phase 5 — Full caselaw library with AI integration.
Phase 1: Full model definition (seed data loaded in Phase 5).
"""
_name = 'fl.caselaw'
_description = 'Florida Family Law Case'
_inherit = ['mail.thread']
_order = 'year desc, short_name'
_rec_name = 'short_name'
citation = fields.Char(
string='Full Citation',
required=True,
help='e.g. Smith v. Jones, 123 So.3d 456 (Fla. 3d DCA 2019)'
)
short_name = fields.Char(
string='Short Name',
required=True,
help='e.g. Smith v. Jones'
)
court = fields.Selection([
('fl_supreme', 'Florida Supreme Court'),
('1st_dca', '1st District Court of Appeal'),
('2nd_dca', '2nd District Court of Appeal'),
('3rd_dca', '3rd DCA (Miami-Dade / Monroe)'),
('4th_dca', '4th District Court of Appeal'),
('5th_dca', '5th District Court of Appeal'),
('11th_circuit', '11th Circuit Court (Trial — Miami-Dade)'),
('other', 'Other'),
], string='Court', required=True)
year = fields.Integer(string='Year', required=True)
statute_ref_ids = fields.Many2many(
'fl.statute',
'fl_caselaw_statute_rel',
'caselaw_id', 'statute_id',
string='FL Statutes'
)
issue_tag_ids = fields.Many2many(
'fl.issue.tag',
'fl_caselaw_tag_rel',
'caselaw_id', 'tag_id',
string='Issue Tags'
)
holding = fields.Text(string='Holding', required=True)
facts_summary = fields.Text(string='Facts Summary')
relevance = fields.Text(string='Why It Matters for Pro Se')
favorable_to = fields.Selection([
('petitioner', 'Petitioner / Moving Party'),
('respondent', 'Respondent'),
('neutral', 'Neutral / Procedural'),
('depends', 'Depends on Facts'),
], string='Generally Favorable To', required=True)
full_text_url = fields.Char(string='Full Opinion URL')
google_scholar_url = fields.Char(
string='Google Scholar URL',
compute='_compute_google_scholar_url'
)
case_ids = fields.Many2many(
'fl.case',
'fl_case_caselaw_rel',
'caselaw_id', 'case_id',
string='Cases Citing This'
)
active = fields.Boolean(default=True)
# Plain English summaries (AI-generated, human-reviewed)
plain_english = fields.Text(
string='Plain English Explanation (EN)',
help='What this case means for a pro se litigant — no legal jargon'
)
plain_english_es = fields.Text(
string='Plain English Explanation (ES)',
help='Explicación en español para litigantes pro se'
)
# Post-2023 flag for alimony cases
superseded_by_2023_reform = fields.Boolean(
string='Superseded by 2023 Alimony Reform (HB 1409)',
help='Pre-2023 permanent alimony cases are superseded by FL 61.08 amendment'
)
@api.depends('citation')
def _compute_google_scholar_url(self):
base = 'https://scholar.google.com/scholar?q='
for rec in self:
if rec.citation:
rec.google_scholar_url = base + urllib.parse.quote(rec.citation)
else:
rec.google_scholar_url = ''

View File

@@ -0,0 +1,166 @@
from odoo import api, fields, models
from dateutil.relativedelta import relativedelta
class FlChild(models.Model):
_name = 'fl.child'
_description = 'Child on Case'
_order = 'date_of_birth'
case_id = fields.Many2one(
'fl.case', string='Case',
required=True, ondelete='cascade', index=True
)
name = fields.Char(string='Full Name', required=True)
date_of_birth = fields.Date(string='Date of Birth', required=True)
age = fields.Integer(
string='Age', compute='_compute_age', store=True
)
gender = fields.Selection([
('m', 'Male'),
('f', 'Female'),
('x', 'Non-binary / Other'),
], string='Gender')
school = fields.Char(string='School')
medical_provider = fields.Char(string='Primary Care Provider')
# ── Support Per Child ──────────────────────────────────────────────────
support_amount = fields.Float(
string='Support Amount This Child ($)',
help='Per-child support amount from the calculation'
)
health_insurance_premium = fields.Float(
string='Health Insurance Premium ($)',
help='Monthly cost of health insurance for this child only'
)
childcare_cost = fields.Float(
string='Work-Related Childcare ($)',
help='FL 61.30(7): Only work or job-search related childcare costs qualify'
)
extraordinary_expenses = fields.Float(
string='Extraordinary Expenses ($)',
help='FL 61.30(9): Medical or educational expenses beyond ordinary child-rearing costs'
)
# ── Emancipation Tracking ──────────────────────────────────────────────
emancipation_date = fields.Date(
string='Emancipation Date',
compute='_compute_emancipation',
store=True,
help='18th birthday (or high school graduation, whichever is later). '
'Support terminates on this date.'
)
approaching_emancipation = fields.Boolean(
string='Approaching Emancipation',
compute='_compute_approaching_emancipation',
store=True,
help='True when emancipation is within 90 days'
)
emancipation_alert_sent = fields.Boolean(
string='Emancipation Alert Sent',
default=False
)
emancipated = fields.Boolean(
string='Emancipated',
compute='_compute_emancipated',
store=True
)
days_until_emancipation = fields.Integer(
string='Days Until Emancipation',
compute='_compute_approaching_emancipation'
)
# ── Early Emancipation Factors ─────────────────────────────────────────
married = fields.Boolean(
string='Child is Married',
help='Marriage constitutes emancipation under FL law'
)
active_military = fields.Boolean(
string='Active Military Service',
help='Active duty military service constitutes emancipation under FL law'
)
declared_emancipated = fields.Boolean(
string='Court-Declared Emancipated',
help='Court has entered an order declaring this child emancipated'
)
emancipation_notes = fields.Text(
string='Emancipation Notes',
help='Document basis for any early emancipation determination'
)
# ── Computed ──────────────────────────────────────────────────────────
@api.depends('date_of_birth')
def _compute_age(self):
today = fields.Date.today()
for rec in self:
if rec.date_of_birth:
delta = relativedelta(today, rec.date_of_birth)
rec.age = delta.years
else:
rec.age = 0
@api.depends('date_of_birth')
def _compute_emancipation(self):
for rec in self:
if rec.date_of_birth:
rec.emancipation_date = rec.date_of_birth + relativedelta(years=18)
else:
rec.emancipation_date = False
@api.depends('emancipation_date', 'married', 'active_military', 'declared_emancipated')
def _compute_approaching_emancipation(self):
today = fields.Date.today()
for rec in self:
# Early emancipation factors
if rec.married or rec.active_military or rec.declared_emancipated:
rec.approaching_emancipation = False
rec.days_until_emancipation = 0
continue
if rec.emancipation_date:
days_remaining = (rec.emancipation_date - today).days
rec.days_until_emancipation = max(days_remaining, 0)
rec.approaching_emancipation = 0 < days_remaining <= 90
else:
rec.approaching_emancipation = False
rec.days_until_emancipation = 0
@api.depends('emancipation_date', 'married', 'active_military', 'declared_emancipated')
def _compute_emancipated(self):
today = fields.Date.today()
for rec in self:
if rec.married or rec.active_military or rec.declared_emancipated:
rec.emancipated = True
elif rec.emancipation_date:
rec.emancipated = today >= rec.emancipation_date
else:
rec.emancipated = False
# ── Cron ──────────────────────────────────────────────────────────────
def _cron_emancipation_alerts(self):
"""
Run daily via ir.cron.
Sends chatter alert on cases where a child is approaching emancipation
and no alert has been sent yet.
"""
approaching = self.search([
('approaching_emancipation', '=', True),
('emancipation_alert_sent', '=', False),
])
for child in approaching:
days = child.days_until_emancipation
child.case_id.message_post(
body=(
f'<strong>⚠️ EMANCIPATION ALERT</strong><br/>'
f'<b>{child.name}</b> will turn 18 on '
f'<b>{child.emancipation_date}</b> '
f'({days} days from today).<br/>'
f'Child support for this child terminates on that date.<br/>'
f'<b>Action required:</b> A Motion to Modify should be filed '
f'at least 60 days before emancipation to address the change '
f'in support obligation.'
),
subtype_xmlid='mail.mt_note',
)
child.emancipation_alert_sent = True

View File

@@ -0,0 +1,248 @@
from datetime import date, datetime, time
from odoo import api, fields, models
from dateutil.relativedelta import relativedelta
class FlDeadline(models.Model):
"""
Phase 2 — Full implementation with calendar integration and cron alerts.
Phase 1: Core fields and generate_deadlines_for_case stub.
"""
_name = 'fl.deadline'
_description = 'Case Deadline'
_inherit = ['mail.thread']
_order = 'due_date asc'
case_id = fields.Many2one(
'fl.case', required=True, ondelete='cascade', index=True
)
name = fields.Char(string='Deadline', required=True)
deadline_type = fields.Selection([
('filing', 'Filing'),
('service', 'Service of Process'),
('response', 'Response / Answer'),
('discovery_open', 'Discovery Opens'),
('financial_disclosure', 'Financial Disclosure Exchange'),
('deposition_notice', 'Deposition Notice Deadline'),
('deposition', 'Deposition'),
('discovery_cutoff', 'Discovery Cutoff'),
('parenting_class', 'Parenting Class Completion'),
('mediation', 'Mediation'),
('hearing', 'Hearing'),
('compliance', 'Compliance Deadline'),
('emancipation', 'Emancipation'),
('default_motion', 'Motion for Default'),
('custom', 'Custom'),
], required=True)
statute_reference = fields.Char(
string='Statute / Rule',
help='e.g. FL 1.140 — 20 days to answer'
)
due_date = fields.Date(string='Due Date', required=True, tracking=True)
anchor_date = fields.Date(
string='Anchor Date',
help='Date this deadline is calculated from'
)
offset_days = fields.Integer(
string='Offset Days',
help='Days from anchor date to due date'
)
completed = fields.Boolean(string='Completed', tracking=True)
completed_date = fields.Date(string='Completion Date')
waived = fields.Boolean(string='Waived')
notes = fields.Text(string='Notes')
# ── Calendar Integration (Phase 2) ────────────────────────────────────
calendar_event_id = fields.Many2one(
'calendar.event', string='Calendar Event'
)
# ── Alerts ────────────────────────────────────────────────────────────
alert_7day_sent = fields.Boolean(default=False)
alert_3day_sent = fields.Boolean(default=False)
alert_1day_sent = fields.Boolean(default=False)
overdue_alert_sent = fields.Boolean(default=False)
is_overdue = fields.Boolean(
string='Overdue',
compute='_compute_is_overdue', store=True
)
days_until_due = fields.Integer(
string='Days Until Due',
compute='_compute_days_until_due'
)
@api.depends('due_date', 'completed', 'waived')
def _compute_is_overdue(self):
today = fields.Date.today()
for rec in self:
rec.is_overdue = (
not rec.completed
and not rec.waived
and bool(rec.due_date)
and rec.due_date < today
)
@api.depends('due_date')
def _compute_days_until_due(self):
today = fields.Date.today()
for rec in self:
if rec.due_date:
rec.days_until_due = (rec.due_date - today).days
else:
rec.days_until_due = 0
def action_mark_complete(self):
self.completed = True
self.completed_date = fields.Date.today()
# ── Generation Engine ─────────────────────────────────────────────────
@api.model
def generate_deadlines_for_case(self, case):
"""
Auto-generate procedural deadlines from filing_date.
Phase 1: Core deadlines only.
Phase 2: Full calendar events + alert setup.
"""
if not case.filing_date:
return
# Avoid duplicates on re-run
existing_types = case.deadline_ids.mapped('deadline_type')
rules = [
{
'name': 'Serve Respondent (Target)',
'deadline_type': 'service',
'offset_days': 30,
'anchor': case.filing_date,
'statute_reference': 'FL 1.070 — 120-day maximum',
},
]
if case.has_minor_children:
rules.append({
'name': 'Parenting Class — Petitioner (FL 61.21)',
'deadline_type': 'parenting_class',
'offset_days': 45,
'anchor': case.filing_date,
'statute_reference': 'FL 61.21',
})
for rule in rules:
if rule['deadline_type'] in existing_types:
continue
due = rule['anchor'] + relativedelta(days=rule['offset_days'])
self.create({
'case_id': case.id,
'name': rule['name'],
'deadline_type': rule['deadline_type'],
'due_date': due,
'anchor_date': rule['anchor'],
'offset_days': rule['offset_days'],
'statute_reference': rule.get('statute_reference', ''),
})
@api.model
def recalculate_service_deadlines(self, case):
"""
Recalculate deadlines anchored to service_date when it is set.
Called from fl_case.write when service_date changes.
"""
if not case.service_date:
return
service_anchored = [
{
'name': 'Respondent Answer Deadline',
'deadline_type': 'response',
'offset_days': 20,
'statute_reference': 'FL 1.140 — 20 days to answer',
},
{
'name': 'Mandatory Financial Disclosure Exchange',
'deadline_type': 'financial_disclosure',
'offset_days': 45,
'statute_reference': 'FL 12.285 — 45 days',
},
{
'name': 'Discovery Opens (Case at Issue)',
'deadline_type': 'discovery_open',
'offset_days': 20,
'statute_reference': 'FL 12.280',
},
]
if case.has_minor_children:
service_anchored.append({
'name': 'Parenting Class — Respondent (FL 61.21)',
'deadline_type': 'parenting_class',
'offset_days': 60,
'statute_reference': 'FL 61.21',
})
existing = {d.deadline_type: d for d in case.deadline_ids}
for rule in service_anchored:
due = case.service_date + relativedelta(days=rule['offset_days'])
dl_type = rule['deadline_type']
if dl_type in existing:
existing[dl_type].write({
'due_date': due,
'anchor_date': case.service_date,
})
else:
self.create({
'case_id': case.id,
'name': rule['name'],
'deadline_type': dl_type,
'due_date': due,
'anchor_date': case.service_date,
'offset_days': rule['offset_days'],
'statute_reference': rule.get('statute_reference', ''),
})
def _cron_deadline_alerts(self):
"""Run daily — send deadline alerts at 7, 3, 1 days and overdue."""
today = fields.Date.today()
upcoming = self.search([
('completed', '=', False),
('waived', '=', False),
('due_date', '>=', today),
])
for dl in upcoming:
days = (dl.due_date - today).days
if days == 7 and not dl.alert_7day_sent:
dl._send_deadline_alert('7 days')
dl.alert_7day_sent = True
elif days == 3 and not dl.alert_3day_sent:
dl._send_deadline_alert('3 days')
dl.alert_3day_sent = True
elif days == 1 and not dl.alert_1day_sent:
dl._send_deadline_alert('1 day')
dl.alert_1day_sent = True
overdue = self.search([
('completed', '=', False),
('waived', '=', False),
('due_date', '<', today),
('overdue_alert_sent', '=', False),
])
for dl in overdue:
dl._send_deadline_alert('OVERDUE')
dl.overdue_alert_sent = True
def _send_deadline_alert(self, timing):
icon = '🔴' if timing == 'OVERDUE' else ''
self.case_id.message_post(
body=(
f'{icon} <b>Deadline Alert — {timing}</b><br/>'
f'<b>{self.name}</b> is due on <b>{self.due_date}</b>.<br/>'
f'Statute: {self.statute_reference or "N/A"}'
),
subtype_xmlid='mail.mt_note',
)

View File

@@ -0,0 +1,55 @@
from odoo import api, fields, models
class FlDeposition(models.Model):
"""
Phase 3 — Full implementation with notice validation, duces tecum, no-show workflow.
Phase 1: Stub with core fields.
"""
_name = 'fl.deposition'
_description = 'Deposition Record'
_inherit = ['mail.thread']
_order = 'scheduled_date asc'
case_id = fields.Many2one(
'fl.case', required=True, ondelete='cascade', index=True
)
deponent_id = fields.Many2one(
'res.partner', string='Deponent', required=True
)
deponent_type = fields.Selection([
('opposing_party', 'Opposing Party'),
('employer', 'Employer'),
('accountant_cpa', 'Accountant / CPA'),
('business_partner', 'Business Partner'),
('vocational_expert', 'Vocational Expert'),
('witness', 'Witness'),
('other', 'Other'),
], string='Deponent Type', required=True)
notice_date = fields.Date(
string='Notice Served Date',
help='FL 1.310(b): Minimum 10 days notice required before deposition'
)
scheduled_date = fields.Datetime(string='Deposition Date / Time')
location = fields.Char(string='Location / Zoom Link')
state = fields.Selection([
('draft', 'Drafting Notice'),
('noticed', 'Notice Served'),
('confirmed', 'Confirmed'),
('completed', 'Completed'),
('no_show', 'Deponent No-Show'),
('cancelled', 'Cancelled'),
('rescheduled', 'Rescheduled'),
], string='Status', default='draft', tracking=True)
duces_tecum = fields.Boolean(
string='Duces Tecum (Document Production)',
help='Deponent is required to bring documents'
)
max_duration_hours = fields.Float(
default=7.0,
help='FL 1.310(d): Maximum 7 hours per deponent per day'
)
income_verified = fields.Boolean(string='Income Figures Verified')
income_verified_amount = fields.Float(string='Verified Income Amount ($)')
key_findings = fields.Text(string='Key Findings')
notes = fields.Text(string='Notes')

View File

@@ -0,0 +1,70 @@
from odoo import api, fields, models
from dateutil.relativedelta import relativedelta
class FlDiscovery(models.Model):
"""
Phase 3 — Full implementation.
Phase 1: Stub with core fields.
Covers all FL discovery methods:
- Interrogatories (FL 1.340) — 30 days to respond
- Request for Production (FL 1.350) — 30 days to respond
- Request for Admissions (FL 1.370) — 30 days to respond
- Subpoena — third party documents (FL 1.351)
- Depositions — tracked in fl.deposition
"""
_name = 'fl.discovery'
_description = 'Discovery Item'
_inherit = ['mail.thread']
_order = 'served_date asc'
case_id = fields.Many2one(
'fl.case', required=True, ondelete='cascade', index=True
)
discovery_type = fields.Selection([
('interrogatories', 'Interrogatories (FL 1.340)'),
('production', 'Request for Production (FL 1.350)'),
('admissions', 'Request for Admissions (FL 1.370)'),
('subpoena', 'Subpoena — Third Party (FL 1.351)'),
('deposition', 'Deposition (FL 1.310)'),
], string='Discovery Type', required=True)
directed_to = fields.Selection([
('petitioner', 'Petitioner'),
('respondent', 'Respondent'),
('third_party', 'Third Party'),
], string='Directed To', required=True)
third_party_id = fields.Many2one(
'res.partner', string='Third Party'
)
description = fields.Char(string='Description / Subject')
served_date = fields.Date(string='Served Date')
response_due_date = fields.Date(
string='Response Due Date',
compute='_compute_response_due', store=True,
help='FL 1.340/1.350/1.370: 30 days to respond'
)
response_received_date = fields.Date(string='Response Received Date')
response_complete = fields.Boolean(string='Response Complete')
objections_raised = fields.Boolean(string='Objections Raised')
objection_detail = fields.Text(string='Objection Details')
deficiency_notice_sent = fields.Boolean(string='Deficiency Notice Sent')
state = fields.Selection([
('draft', 'Drafting'),
('served', 'Served'),
('responded', 'Response Received'),
('deficient', 'Response Deficient'),
('compelled', 'Motion to Compel Filed'),
('complete', 'Complete'),
], string='Status', default='draft', tracking=True)
notes = fields.Text(string='Notes')
@api.depends('served_date', 'discovery_type')
def _compute_response_due(self):
for rec in self:
if rec.served_date and rec.discovery_type != 'deposition':
rec.response_due_date = (
rec.served_date + relativedelta(days=30)
)
else:
rec.response_due_date = False

View File

@@ -0,0 +1,52 @@
from odoo import fields, models
class FlDocument(models.Model):
"""
Phase 4 — Full implementation with QWeb report generation.
Phase 1: Stub with core fields.
"""
_name = 'fl.document'
_description = 'Case Document'
_inherit = ['mail.thread']
_order = 'create_date desc'
case_id = fields.Many2one(
'fl.case', required=True, ondelete='cascade', index=True
)
name = fields.Char(string='Document Name', required=True)
document_type = fields.Selection([
('financial_affidavit_short', 'Financial Affidavit Short (FL-12.902(b))'),
('financial_affidavit_long', 'Financial Affidavit Long (FL-12.902(c))'),
('support_worksheet', 'Child Support Worksheet (FL-12.902(e))'),
('motion_to_modify', 'Motion to Modify Child Support'),
('notice_deposition', 'Notice of Taking Deposition'),
('motion_to_compel', 'Motion to Compel (FL 1.380)'),
('motion_default', 'Motion for Default (FL 12.922)'),
('income_withholding', 'Income Withholding Order (FL 61.1301)'),
('parenting_plan', 'Parenting Plan (FL-12.995(a))'),
('fee_waiver', 'Application for Civil Indigent Status (FL 57.082)'),
('notice_ssn', 'Notice of SSN (FL-12.930(a))'),
('mandatory_disclosure', 'Certificate of Mandatory Disclosure (FL-12.932)'),
('duces_tecum', 'Subpoena Duces Tecum'),
('other', 'Other Document'),
], string='Document Type')
state = fields.Selection([
('draft', 'Draft'),
('generated', 'Generated'),
('signed', 'Signed'),
('filed', 'Filed with Court'),
], string='Status', default='draft', tracking=True)
attachment_ids = fields.Many2many(
'ir.attachment',
'fl_document_attachment_rel',
'document_id', 'attachment_id',
string='Files'
)
notes = fields.Text(string='Notes')
requires_notarization = fields.Boolean(
string='Requires Notarization',
help='Financial affidavits must be notarized before filing'
)
notarized = fields.Boolean(string='Notarized')
filed_date = fields.Date(string='Filed with Court Date')

View File

@@ -0,0 +1,27 @@
from odoo import fields, models
class HrExpenseFLCase(models.Model):
"""
Extend hr.expense to link expenses to a family law case.
Enables cost-tracking per case (filing fees, process server, court reporter, etc.)
"""
_inherit = 'hr.expense'
fl_case_id = fields.Many2one(
'fl.case',
string='Family Law Case',
index=True,
help='Link this expense to a specific family law case for cost tracking'
)
expense_category = fields.Selection([
('filing_fee', 'Court Filing Fee'),
('service_fee', 'Service of Process Fee'),
('court_reporter', 'Court Reporter'),
('mediation', 'Mediation Fee'),
('copying', 'Document Copying / Printing'),
('postage', 'Postage / Certified Mail'),
('translation', 'Translation / Interpretation'),
('expert', 'Expert Witness'),
('other', 'Other Case Expense'),
], string='Case Expense Type')

View File

@@ -0,0 +1,162 @@
from odoo import api, fields, models
class FlFeeWaiver(models.Model):
"""
FL Statute 57.082 — Determination of Civil Indigent Status.
Income threshold: < 200% of federal poverty level.
2025 Federal Poverty Level (HHS):
Base (1 person): $15,060/year
Per additional person: $5,380/year
Update FPL_BASE and FPL_PER_PERSON constants annually.
Source: https://aspe.hhs.gov/topics/poverty-economic-mobility/poverty-guidelines
"""
_name = 'fl.fee.waiver'
_description = 'Fee Waiver / Civil Indigent Status (FL 57.082)'
_inherit = ['mail.thread']
_order = 'create_date desc'
# 2025 FPL constants — UPDATE ANNUALLY
FPL_BASE_2025 = 15060 # 1-person household
FPL_PER_PERSON_2025 = 5380 # Each additional person
case_id = fields.Many2one(
'fl.case', string='Case',
ondelete='cascade', index=True
)
party_id = fields.Many2one(
'res.partner', string='Applicant',
required=True
)
# ── Income & Household ─────────────────────────────────────────────────
household_size = fields.Integer(
string='Household Size',
required=True,
default=1,
help='Include all persons in the household (applicant + dependents + spouse/partner)'
)
monthly_gross_income = fields.Float(
string='Monthly Gross Income ($)',
required=True,
help='Total gross monthly income from all sources'
)
annual_gross_income = fields.Float(
string='Annual Gross Income ($)',
compute='_compute_annual', store=True
)
# ── FPL Threshold Calculation ──────────────────────────────────────────
fpl_annual = fields.Float(
string='Federal Poverty Level — Annual ($)',
compute='_compute_threshold', store=True,
help='100% FPL for this household size'
)
fpl_200pct_threshold = fields.Float(
string='200% FPL Threshold ($)',
compute='_compute_threshold', store=True,
help='200% FPL — income must be below this to qualify'
)
eligible = fields.Boolean(
string='Fee Waiver Eligible',
compute='_compute_eligibility', store=True
)
eligibility_note = fields.Char(
string='Eligibility Result',
compute='_compute_eligibility'
)
# ── Miami-Dade Mediator Fee Waiver (FL 44.108) ─────────────────────────
mediator_fee_waiver_eligible = fields.Boolean(
string='Mediator Fee Waiver Eligible (FL 44.108)',
compute='_compute_mediator_waiver', store=True,
help='Miami-Dade County provides free mediators for qualifying income. '
'Same income threshold as civil indigent status (200% FPL).'
)
# ── Status ────────────────────────────────────────────────────────────
state = fields.Selection([
('draft', 'Draft'),
('submitted', 'Submitted to Clerk'),
('approved', 'Approved'),
('denied', 'Denied'),
], string='Status', default='draft', tracking=True)
application_date = fields.Date(
string='Application Date',
default=fields.Date.today
)
approval_date = fields.Date(string='Approval Date')
denial_reason = fields.Text(string='Denial Reason')
expiration_date = fields.Date(
string='Expiration Date',
help='Fee waiver approvals typically expire — check with clerk'
)
clerk_stamp_attachment_ids = fields.Many2many(
'ir.attachment',
'fl_fee_waiver_attachment_rel',
'waiver_id', 'attachment_id',
string='Clerk-Stamped Copy'
)
notes = fields.Text(string='Notes')
# ── Computed ──────────────────────────────────────────────────────────
@api.depends('monthly_gross_income')
def _compute_annual(self):
for rec in self:
rec.annual_gross_income = (rec.monthly_gross_income or 0.0) * 12
@api.depends('household_size')
def _compute_threshold(self):
for rec in self:
size = max(rec.household_size or 1, 1)
fpl = self.FPL_BASE_2025 + (size - 1) * self.FPL_PER_PERSON_2025
rec.fpl_annual = fpl
rec.fpl_200pct_threshold = fpl * 2
@api.depends('annual_gross_income', 'fpl_200pct_threshold')
def _compute_eligibility(self):
for rec in self:
annual = rec.annual_gross_income or 0.0
threshold = rec.fpl_200pct_threshold or 0.0
if annual and threshold:
rec.eligible = annual <= threshold
if rec.eligible:
rec.eligibility_note = (
f'✅ ELIGIBLE — Annual income ${annual:,.0f} '
f'is below 200% FPL (${threshold:,.0f})'
)
else:
excess = annual - threshold
rec.eligibility_note = (
f'❌ NOT ELIGIBLE — Annual income ${annual:,.0f} '
f'exceeds 200% FPL (${threshold:,.0f}) '
f'by ${excess:,.0f}'
)
else:
rec.eligible = False
rec.eligibility_note = 'Enter income and household size to check eligibility'
@api.depends('eligible')
def _compute_mediator_waiver(self):
# Miami-Dade FL 44.108 — same threshold as civil indigent status
for rec in self:
rec.mediator_fee_waiver_eligible = rec.eligible
def action_submit(self):
self.state = 'submitted'
def action_approve(self):
self.state = 'approved'
self.approval_date = fields.Date.today()
def action_deny(self):
self.state = 'denied'

View File

@@ -0,0 +1,47 @@
from odoo import api, fields, models
class FlHearing(models.Model):
"""
Phase 2 — Full implementation.
Phase 1: Stub with fields needed by fl_case computed fields.
"""
_name = 'fl.hearing'
_description = 'Case Hearing'
_inherit = ['mail.thread']
_order = 'hearing_date asc'
case_id = fields.Many2one(
'fl.case', required=True, ondelete='cascade', index=True
)
name = fields.Char(string='Hearing Description', required=True)
hearing_date = fields.Datetime(
string='Hearing Date / Time', tracking=True
)
location = fields.Char(
string='Location',
default='Lawson E. Thomas Courthouse Center, 175 NW 1st Ave, Miami, FL 33128'
)
courtroom = fields.Char(string='Courtroom')
judge_id = fields.Many2one(
'res.partner', string='Judge',
related='case_id.judge_id', store=True
)
hearing_type = fields.Selection([
('status_conference', 'Status Conference'),
('motion', 'Motion Hearing'),
('temporary_relief', 'Temporary Relief Hearing'),
('mediation', 'Mediation'),
('final', 'Final Hearing'),
('contempt', 'Contempt Hearing'),
('other', 'Other'),
], string='Hearing Type', default='final')
state = fields.Selection([
('scheduled', 'Scheduled'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
('continued', 'Continued'),
], string='Status', default='scheduled', tracking=True)
notes = fields.Text(string='Notes')
outcome = fields.Text(string='Outcome / Result')
order_entered = fields.Boolean(string='Order Entered')

View File

@@ -0,0 +1,165 @@
from odoo import api, fields, models
class FlIncomeWithholding(models.Model):
"""
FL 61.1301 — Income Deduction (Withholding) Order.
MANDATORY after every child support or alimony order unless:
- Good cause shown, OR
- Written agreement between parties for alternative payment method.
All payments route through Florida State Disbursement Unit (SDU).
Employer receives copy and deducts from obligor's paycheck.
"""
_name = 'fl.income.withholding'
_description = 'Income Withholding Order (FL 61.1301)'
_inherit = ['mail.thread']
_order = 'issued_date desc'
case_id = fields.Many2one(
'fl.case', string='Case',
ondelete='cascade', index=True
)
name = fields.Char(
string='Reference',
compute='_compute_name', store=True
)
# ── Parties ────────────────────────────────────────────────────────────
obligor_id = fields.Many2one(
'res.partner',
string='Obligor (Person Paying)',
required=True
)
obligee_id = fields.Many2one(
'res.partner',
string='Obligee (Person Receiving)',
required=True
)
employer_id = fields.Many2one(
'res.partner',
string="Obligor's Employer",
help='Employer who will receive and execute the withholding order'
)
employer_contact = fields.Char(
string='Employer HR / Payroll Contact'
)
# ── Amounts ────────────────────────────────────────────────────────────
monthly_support_amount = fields.Float(
string='Monthly Support Amount ($)',
required=True,
tracking=True
)
arrears_amount = fields.Float(
string='Arrears Amount ($)',
help='Total unpaid support owed prior to this order'
)
arrears_monthly_payment = fields.Float(
string='Monthly Arrears Payment ($)',
help='Additional monthly amount toward arrears payoff'
)
total_monthly_withholding = fields.Float(
string='Total Monthly Withholding ($)',
compute='_compute_total', store=True,
help='Support + arrears payment per month'
)
# ── Florida State Disbursement Unit ────────────────────────────────────
# ALL payments must go through FL SDU — never directly to the other party
fl_sdu_case_number = fields.Char(
string='FL SDU Case Number',
help='Florida State Disbursement Unit case number. '
'Obtain from DOR: https://www.floridarevenue.com/childsupport'
)
fl_sdu_address = fields.Char(
string='FL SDU Payment Address',
default='Florida State Disbursement Unit, PO Box 8500, Tallahassee, FL 32314-8500',
readonly=True
)
fl_sdu_website = fields.Char(
default='https://www.floridarevenue.com/childsupport',
readonly=True
)
# ── Dates & Status ────────────────────────────────────────────────────
issued_date = fields.Date(
string='Order Issued Date',
tracking=True
)
served_on_employer_date = fields.Date(
string='Served on Employer Date'
)
first_withholding_date = fields.Date(
string='First Withholding Payroll Date',
help='Date of first paycheck with withholding applied'
)
state = fields.Selection([
('draft', 'Draft'),
('issued', 'Issued by Court'),
('served', 'Served on Employer'),
('active', 'Active — Withholding'),
('modified', 'Modified'),
('terminated', 'Terminated'),
], string='Status', default='draft', tracking=True)
# ── Exceptions (rare) ─────────────────────────────────────────────────
good_cause_exception = fields.Boolean(
string='Good Cause Exception',
help='FL 61.1301(1)(a): Court may waive withholding for good cause shown. '
'Must be documented in court order. RARE.'
)
written_agreement = fields.Boolean(
string='Written Agreement Between Parties',
help='Parties may agree in writing to an alternative payment method. '
'Agreement must be filed with the court.'
)
exception_reason = fields.Text(
string='Exception Reason / Agreement Details'
)
# ── Notes ─────────────────────────────────────────────────────────────
notes = fields.Text(string='Notes')
# ── Computed ──────────────────────────────────────────────────────────
@api.depends('case_id', 'issued_date')
def _compute_name(self):
for rec in self:
case_ref = rec.case_id.name if rec.case_id else 'NEW'
date_str = str(rec.issued_date) if rec.issued_date else 'Draft'
rec.name = f'IWO — {case_ref}{date_str}'
@api.depends('monthly_support_amount', 'arrears_monthly_payment')
def _compute_total(self):
for rec in self:
rec.total_monthly_withholding = (
(rec.monthly_support_amount or 0.0)
+ (rec.arrears_monthly_payment or 0.0)
)
# ── State Actions ─────────────────────────────────────────────────────
def action_issue(self):
self.state = 'issued'
self.issued_date = self.issued_date or fields.Date.today()
def action_serve(self):
self.state = 'served'
self.served_on_employer_date = (
self.served_on_employer_date or fields.Date.today()
)
def action_activate(self):
self.state = 'active'
def action_terminate(self):
self.state = 'terminated'

View File

@@ -0,0 +1,318 @@
from odoo import api, fields, models
class FlIncomeSource(models.Model):
_name = 'fl.income.source'
_description = 'Party Income Source'
_order = 'monthly_amount desc'
party_id = fields.Many2one(
'fl.party', string='Party',
required=True, ondelete='cascade', index=True
)
source_type = fields.Selection([
('wages', 'Wages / Salary (W-2)'),
('self_employment', 'Self-Employment / Business Income'),
('rental', 'Rental Income'),
('investment', 'Investment / Dividend Income'),
('pension', 'Pension / Retirement'),
('social_security', 'Social Security'),
('disability', 'Disability Benefits (SSI/SSDI)'),
('unemployment', 'Unemployment Compensation'),
('workers_comp', "Workers' Compensation"),
('alimony_received', 'Alimony Received'),
('child_support_received', 'Child Support Received (other case)'),
('overtime', 'Overtime / Bonuses'),
('tips', 'Tips / Gratuities'),
('commission', 'Commissions'),
('trust', 'Trust / Estate Income'),
('other', 'Other Income'),
], string='Source Type', required=True)
description = fields.Char(string='Description / Payer')
monthly_amount = fields.Float(string='Monthly Amount ($)', required=True)
annual_amount = fields.Float(
string='Annual Amount ($)',
compute='_compute_annual', store=True
)
verified = fields.Boolean(
string='Verified',
help='Income verified via documentation (pay stub, tax return, bank statement)'
)
verification_document = fields.Char(
string='Verification Document',
help='e.g. "2023 W-2", "Last 3 pay stubs", "2023 1040 Schedule C"'
)
@api.depends('monthly_amount')
def _compute_annual(self):
for rec in self:
rec.annual_amount = rec.monthly_amount * 12
class FlParty(models.Model):
_name = 'fl.party'
_description = 'Case Party Details'
_inherit = ['mail.thread']
_order = 'role, id'
case_id = fields.Many2one(
'fl.case', string='Case',
required=True, ondelete='cascade', index=True
)
partner_id = fields.Many2one(
'res.partner', string='Contact',
required=True
)
role = fields.Selection([
('petitioner', 'Petitioner'),
('respondent', 'Respondent'),
], string='Role', required=True)
display_name_computed = fields.Char(
string='Display Name',
compute='_compute_display_name_field', store=True
)
# ── Employment ─────────────────────────────────────────────────────────
employment_type = fields.Selection([
('employed', 'W-2 Employed'),
('self_employed', 'Self-Employed / Business Owner'),
('unemployed', 'Unemployed'),
('underemployed', 'Voluntarily Underemployed'),
('disabled', 'Disabled — Receiving Benefits'),
('retired', 'Retired'),
('student', 'Full-Time Student'),
('unknown', 'Unknown / To Be Discovered'),
], string='Employment Type', required=True, default='employed', tracking=True)
employer_name = fields.Char(string='Employer Name')
employer_address = fields.Char(string='Employer Address')
employer_phone = fields.Char(
string='Employer Phone',
help='Used for deposition notice and employer subpoena'
)
# ── Income (FL 61.30(2)) ───────────────────────────────────────────────
gross_monthly_income = fields.Float(
string='Gross Monthly Income ($)',
tracking=True
)
income_source_ids = fields.One2many(
'fl.income.source', 'party_id',
string='Income Sources'
)
income_sources_total = fields.Float(
string='Income Sources Total ($)',
compute='_compute_income_sources_total', store=True,
help='Sum of all income source monthly amounts'
)
# Statutory deductions to reach Net Monthly Income (FL 61.30(3))
fed_tax_monthly = fields.Float(
string='Federal Income Tax Withholding ($)',
help='Actual federal tax withheld per month (from pay stub)'
)
fica_ss_monthly = fields.Float(
string='Social Security Tax (6.2%) ($)',
compute='_compute_fica', store=True
)
fica_medicare_monthly = fields.Float(
string='Medicare Tax (1.45%) ($)',
compute='_compute_fica', store=True
)
mandatory_retirement = fields.Float(
string='Mandatory Retirement Contribution ($)',
help='Only mandatory contributions qualify — not voluntary 401k'
)
mandatory_union_dues = fields.Float(string='Mandatory Union Dues ($)')
health_insurance_self = fields.Float(
string='Health Insurance — Self Only ($)',
help='FL 61.30(3)(e): Self-only portion of health insurance premium. '
'Do NOT include the child portion here.'
)
other_court_ordered_support = fields.Float(
string='Other Court-Ordered Child Support ($)',
help='Court-ordered support for children from other relationships (FL 61.30(3)(g))'
)
net_monthly_income = fields.Float(
string='Net Monthly Income ($)',
compute='_compute_net_income', store=True,
tracking=True,
help='Gross income minus all allowable FL 61.30(3) deductions'
)
# ── Income Imputation (FL 61.30(2)(b)) ───────────────────────────────
income_imputed = fields.Boolean(
string='Income Imputed',
help='Check if this party is voluntarily unemployed/underemployed '
'and income should be imputed'
)
imputed_amount = fields.Float(
string='Imputed Monthly Income ($)',
help='FL 61.30(2)(b): Requires showing BOTH ability AND availability of work. '
'Default to FL minimum wage if voluntarily unemployed.'
)
imputation_basis = fields.Text(
string='Basis for Imputation',
help='FL 61.30(2)(b): Document ability (education, skills, health) '
'AND availability (local job market, prior work history). '
'Cannot impute income to disabled party without medical evidence.'
)
fl_minimum_wage_hourly = fields.Float(
string='FL Minimum Wage ($/hr)',
default=13.00,
help='Update annually per Florida minimum wage schedule. '
'2025: $13.00/hr. Full-time = $13.00 × 40hr × 52wk / 12 = $2,253.33/mo'
)
fl_minimum_wage_monthly = fields.Float(
string='FL Minimum Wage ($/mo)',
compute='_compute_fl_min_wage_monthly', store=True
)
effective_monthly_income = fields.Float(
string='Effective Monthly Income ($)',
compute='_compute_effective_income', store=True,
help='Net monthly income used in support calculation — '
'uses imputed amount if income_imputed = True'
)
# ── Lifestyle Analysis (Barner v. Barner) ────────────────────────────
lifestyle_inconsistency_flag = fields.Boolean(
string='Income/Lifestyle Inconsistency Flagged',
help='Flag when reported income appears inconsistent with '
'observed lifestyle (Barner v. Barner). '
'Document indicators: vehicles, vacations, residence, spending.'
)
lifestyle_notes = fields.Text(
string='Lifestyle Analysis Notes',
help='Document specific lifestyle inconsistencies: '
'e.g., drives a 2023 BMW but reports $800/mo income; '
'took 3 vacations in past year; lives in $3,000/mo apartment.'
)
# ── Portal Access ──────────────────────────────────────────────────────
portal_user_id = fields.Many2one(
'res.users', string='Portal User Account'
)
portal_access_granted = fields.Boolean(
string='Portal Access Granted'
)
portal_invite_sent = fields.Boolean(
string='Portal Invitation Sent'
)
preferred_language = fields.Selection([
('en_US', 'English'),
('es_MX', 'Spanish / Español'),
], string='Preferred Language', default='en_US')
# ── SSN (FL-12.930(a)) ────────────────────────────────────────────────
# SECURITY: Store last 4 digits only.
# Full SSN is NOT stored in this system.
# User handwrites full SSN on the physical FL-12.930(a) form before filing.
ssn_last4 = fields.Char(
string='SSN Last 4 Digits',
size=4,
help='Last 4 digits only. Full SSN is not stored — '
'user handwrites SSN on physical FL-12.930(a) form.'
)
ssn_notice_filed = fields.Boolean(
string='Notice of Social Security Number Filed (FL-12.930(a))',
help='Required to be filed with the court'
)
ssn_handwritten_confirmed = fields.Boolean(
string='I have handwritten my SSN on the FL-12.930(a) form',
help='Checkbox confirms user has added SSN to physical form before filing'
)
# ── Service of Process ────────────────────────────────────────────────
service_address = fields.Text(
string='Address for Service of Process'
)
service_method = fields.Selection([
('personal', 'Personal Service (Process Server)'),
('certified_mail', 'Certified Mail (if agreed by both parties)'),
('publication', 'Service by Publication (last resort — respondent location unknown)'),
], string='Service Method')
process_server_id = fields.Many2one(
'res.partner', string='Process Server'
)
service_by_publication_warning = fields.Boolean(
compute='_compute_service_publication_warning'
)
# ── Computed ──────────────────────────────────────────────────────────
@api.depends('partner_id', 'role')
def _compute_display_name_field(self):
for rec in self:
role_label = dict(rec._fields['role'].selection).get(rec.role, '')
name = rec.partner_id.name or ''
rec.display_name_computed = f'{name} ({role_label})' if role_label else name
@api.depends('income_source_ids.monthly_amount')
def _compute_income_sources_total(self):
for rec in self:
rec.income_sources_total = sum(
rec.income_source_ids.mapped('monthly_amount')
)
@api.depends('gross_monthly_income')
def _compute_fica(self):
for rec in self:
gross = rec.gross_monthly_income or 0.0
rec.fica_ss_monthly = round(gross * 0.062, 2)
rec.fica_medicare_monthly = round(gross * 0.0145, 2)
@api.depends(
'gross_monthly_income',
'fed_tax_monthly',
'fica_ss_monthly',
'fica_medicare_monthly',
'mandatory_retirement',
'mandatory_union_dues',
'health_insurance_self',
'other_court_ordered_support',
)
def _compute_net_income(self):
for rec in self:
gross = rec.gross_monthly_income or 0.0
deductions = (
(rec.fed_tax_monthly or 0.0)
+ (rec.fica_ss_monthly or 0.0)
+ (rec.fica_medicare_monthly or 0.0)
+ (rec.mandatory_retirement or 0.0)
+ (rec.mandatory_union_dues or 0.0)
+ (rec.health_insurance_self or 0.0)
+ (rec.other_court_ordered_support or 0.0)
)
rec.net_monthly_income = max(gross - deductions, 0.0)
@api.depends('fl_minimum_wage_hourly')
def _compute_fl_min_wage_monthly(self):
for rec in self:
# Full-time: 40hr/wk × 52wk / 12 months
rec.fl_minimum_wage_monthly = round(
rec.fl_minimum_wage_hourly * 40 * 52 / 12, 2
)
@api.depends('net_monthly_income', 'income_imputed', 'imputed_amount')
def _compute_effective_income(self):
for rec in self:
if rec.income_imputed and rec.imputed_amount:
rec.effective_monthly_income = rec.imputed_amount
else:
rec.effective_monthly_income = rec.net_monthly_income
@api.depends('service_method')
def _compute_service_publication_warning(self):
for rec in self:
rec.service_by_publication_warning = (
rec.service_method == 'publication'
)

View File

@@ -0,0 +1,56 @@
from odoo import fields, models
class FlStatute(models.Model):
_name = 'fl.statute'
_description = 'Florida Statute Reference'
_order = 'name'
name = fields.Char(
string='Statute',
required=True,
help='e.g. FL 61.30'
)
title = fields.Char(string='Title', required=True)
description = fields.Text(string='Summary')
category = fields.Selection([
('child_support', 'Child Support'),
('modification', 'Modification'),
('alimony', 'Alimony'),
('timesharing', 'Timesharing / Parental Responsibility'),
('dissolution', 'Dissolution of Marriage'),
('paternity', 'Paternity'),
('domestic_violence', 'Domestic Violence'),
('enforcement', 'Enforcement'),
('disclosure', 'Mandatory Disclosure'),
('fee_waiver', 'Fee Waiver / Indigent Status'),
('procedure', 'Procedure'),
('discovery', 'Discovery'),
('other', 'Other'),
], string='Category')
url = fields.Char(string='Official URL')
active = fields.Boolean(default=True)
_sql_constraints = [
('name_uniq', 'unique(name)', 'Statute reference must be unique.'),
]
class FlIssueTag(models.Model):
_name = 'fl.issue.tag'
_description = 'Legal Issue Tag'
_order = 'name'
name = fields.Char(string='Tag', required=True)
name_es = fields.Char(string='Tag (Spanish)')
color = fields.Integer(string='Color Index', default=0)
case_type = fields.Selection([
('modification', 'Modification'),
('dissolution', 'Dissolution'),
('paternity', 'Paternity'),
('all', 'All Cases'),
], string='Applies To', default='all')
_sql_constraints = [
('name_uniq', 'unique(name)', 'Issue tag name must be unique.'),
]

View File

@@ -0,0 +1,426 @@
from odoo import api, fields, models
class FlSupportScheduleEntry(models.Model):
"""
FL Department of Revenue — Basic Support Obligation Schedule.
Loaded from data/fl_support_schedule.xml.
MUST be updated annually when FL updates the schedule.
Source: https://www.floridarevenue.com/childsupport/guidelines
"""
_name = 'fl.support.schedule.entry'
_description = 'FL Support Obligation Schedule Entry'
_order = 'income_min, children_count'
income_min = fields.Float(
string='Combined Income — Min ($/mo)',
required=True
)
income_max = fields.Float(
string='Combined Income — Max ($/mo)',
required=True
)
children_count = fields.Integer(
string='Number of Children',
required=True
)
obligation_amount = fields.Float(
string='Basic Obligation ($)',
required=True
)
effective_date = fields.Date(
string='Schedule Effective Date',
required=True
)
active = fields.Boolean(default=True)
def name_get(self):
result = []
for rec in self:
result.append((
rec.id,
f'${rec.income_min:,.0f}${rec.income_max:,.0f} / '
f'{rec.children_count} child(ren) = ${rec.obligation_amount:,.0f}'
))
return result
class FlSupportCalculation(models.Model):
"""
FL 61.30 Child Support Guidelines Worksheet.
One calculation record per case (current vs. proposed vs. historical).
"""
_name = 'fl.support.calculation'
_description = 'FL 61.30 Child Support Calculation'
_inherit = ['mail.thread']
_order = 'calculation_date desc'
case_id = fields.Many2one(
'fl.case', string='Case',
ondelete='cascade', index=True
)
calculation_date = fields.Date(
string='Calculation Date',
default=fields.Date.today
)
calculation_type = fields.Selection([
('current', 'Current Order (Baseline)'),
('proposed', 'Proposed Modification'),
('historical', 'Historical Reference'),
], string='Type', default='proposed', required=True)
notes = fields.Text(string='Notes')
# ── Party Net Incomes ──────────────────────────────────────────────────
petitioner_net_income = fields.Float(
string='Petitioner Net Monthly Income ($)',
tracking=True
)
respondent_net_income = fields.Float(
string='Respondent Net Monthly Income ($)',
tracking=True
)
combined_net_income = fields.Float(
string='Combined Net Monthly Income ($)',
compute='_compute_combined', store=True
)
petitioner_income_pct = fields.Float(
string='Petitioner Income %',
compute='_compute_income_pcts', store=True
)
respondent_income_pct = fields.Float(
string='Respondent Income %',
compute='_compute_income_pcts', store=True
)
# ── Basic Support Obligation ───────────────────────────────────────────
number_of_children = fields.Integer(
string='Number of Children',
related='case_id.children_count', store=True
)
basic_support_obligation = fields.Float(
string='Basic Support Obligation ($)',
compute='_compute_basic_obligation', store=True,
help='Looked up from FL DCF Schedule based on combined income and number of children'
)
support_schedule_id = fields.Many2one(
'fl.support.schedule.entry',
string='Schedule Entry Used',
compute='_compute_basic_obligation', store=True
)
above_schedule = fields.Boolean(
string='Above Schedule Maximum',
compute='_compute_basic_obligation', store=True,
help='If True, FL 61.30(6) percentage formula was applied'
)
# ── Adjustments (FL 61.30(7),(8),(9)) ─────────────────────────────────
# Health insurance for children (FL 61.30(8))
child_health_insurance_total = fields.Float(
string='Child Health Insurance Premium Total ($)',
help='Total monthly premium for children only — not self-only portion'
)
health_insurance_by_petitioner = fields.Float(
string=' Paid by Petitioner ($)'
)
health_insurance_by_respondent = fields.Float(
string=' Paid by Respondent ($)'
)
# Work-related childcare (FL 61.30(7))
childcare_total = fields.Float(
string='Work-Related Childcare Total ($)',
help='FL 61.30(7): Only work or job-search related childcare qualifies'
)
childcare_by_petitioner = fields.Float(
string=' Paid by Petitioner ($)'
)
childcare_by_respondent = fields.Float(
string=' Paid by Respondent ($)'
)
# Extraordinary expenses (FL 61.30(9))
extraordinary_expenses = fields.Float(
string='Extraordinary Medical / Educational Expenses ($)',
help='FL 61.30(9): Expenses beyond ordinary child-rearing costs'
)
# ── Adjusted Support Obligation ────────────────────────────────────────
adjusted_support_obligation = fields.Float(
string='Adjusted Support Obligation ($)',
compute='_compute_adjusted_obligation', store=True,
help='Basic Obligation + Health Insurance + Childcare + Extraordinary'
)
# ── Timesharing Adjustment (FL 61.30(11)(b)) ──────────────────────────
petitioner_overnights = fields.Integer(
string='Petitioner Overnights / Year',
related='case_id.petitioner_overnights', store=True
)
respondent_overnights = fields.Integer(
string='Respondent Overnights / Year',
related='case_id.respondent_overnights', store=True
)
substantial_timesharing = fields.Boolean(
string='Substantial Timesharing Applies',
related='case_id.substantial_timesharing_applies', store=True,
help='FL 61.30(11)(b): Applies if either parent has > 73 overnights/year (20%)'
)
timesharing_adjustment = fields.Float(
string='Timesharing Adjustment ($)',
compute='_compute_timesharing_adjustment', store=True
)
# ── Final Obligations ──────────────────────────────────────────────────
total_support_obligation = fields.Float(
string='Total Support Obligation ($)',
compute='_compute_total_obligation', store=True
)
petitioner_obligation = fields.Float(
string='Petitioner Share ($)',
compute='_compute_party_obligations', store=True
)
respondent_obligation = fields.Float(
string='Respondent Share ($)',
compute='_compute_party_obligations', store=True
)
net_payment_amount = fields.Float(
string='Net Payment Amount ($)',
compute='_compute_net_payment', store=True,
help='Net amount actually exchanged between parties after credits'
)
payment_direction = fields.Selection([
('petitioner_pays', 'Petitioner Pays Respondent'),
('respondent_pays', 'Respondent Pays Petitioner'),
('no_payment', 'No Net Payment'),
], string='Payment Direction',
compute='_compute_net_payment', store=True
)
# ── Deviation (FL 61.30(1)(a)) ────────────────────────────────────────
deviation_requested = fields.Boolean(
string='Deviation Requested',
help='FL 61.30(1)(a): Court may deviate from guidelines with written findings'
)
deviation_reason = fields.Text(
string='Deviation Reason',
help='Valid reasons: extraordinary medical needs, independent child income, '
'seasonal income variations, shared physical custody arrangements'
)
deviation_amount = fields.Float(
string='Deviation Amount ($)',
help='Positive = above guidelines; Negative = below guidelines'
)
final_amount_with_deviation = fields.Float(
string='Final Amount With Deviation ($)',
compute='_compute_final_with_deviation', store=True
)
# ── Summary ───────────────────────────────────────────────────────────
calculation_summary = fields.Text(
string='Calculation Summary',
compute='_compute_summary'
)
# ══════════════════════════════════════════════════════════════════════
# COMPUTED METHODS
# ══════════════════════════════════════════════════════════════════════
@api.depends('petitioner_net_income', 'respondent_net_income')
def _compute_combined(self):
for rec in self:
rec.combined_net_income = (
(rec.petitioner_net_income or 0.0)
+ (rec.respondent_net_income or 0.0)
)
@api.depends('combined_net_income', 'petitioner_net_income', 'respondent_net_income')
def _compute_income_pcts(self):
for rec in self:
combined = rec.combined_net_income or 0.0
if combined > 0:
rec.petitioner_income_pct = (
rec.petitioner_net_income or 0.0
) / combined
rec.respondent_income_pct = (
rec.respondent_net_income or 0.0
) / combined
else:
rec.petitioner_income_pct = 0.5
rec.respondent_income_pct = 0.5
@api.depends('combined_net_income', 'number_of_children')
def _compute_basic_obligation(self):
"""
Look up Basic Support Obligation from FL DCF Schedule.
If combined income exceeds schedule maximum, apply FL 61.30(6) percentages:
1 child = 5%
2 children = 7.5%
3 children = 9.5%
4 children = 11%
5 children = 12%
6+ children = 12.5%
"""
PCT_MAP = {1: 0.05, 2: 0.075, 3: 0.095, 4: 0.11, 5: 0.12, 6: 0.125}
for rec in self:
if not rec.combined_net_income or not rec.number_of_children:
rec.basic_support_obligation = 0.0
rec.support_schedule_id = False
rec.above_schedule = False
continue
children = min(rec.number_of_children, 6)
entry = self.env['fl.support.schedule.entry'].search([
('income_min', '<=', rec.combined_net_income),
('income_max', '>=', rec.combined_net_income),
('children_count', '=', children),
('active', '=', True),
], order='effective_date desc', limit=1)
if entry:
rec.basic_support_obligation = entry.obligation_amount
rec.support_schedule_id = entry
rec.above_schedule = False
else:
# Above schedule maximum — FL 61.30(6) formula
pct = PCT_MAP.get(children, 0.125)
rec.basic_support_obligation = round(
rec.combined_net_income * pct, 2
)
rec.support_schedule_id = False
rec.above_schedule = True
@api.depends(
'basic_support_obligation',
'child_health_insurance_total',
'childcare_total',
'extraordinary_expenses',
)
def _compute_adjusted_obligation(self):
for rec in self:
rec.adjusted_support_obligation = (
(rec.basic_support_obligation or 0.0)
+ (rec.child_health_insurance_total or 0.0)
+ (rec.childcare_total or 0.0)
+ (rec.extraordinary_expenses or 0.0)
)
@api.depends(
'adjusted_support_obligation',
'petitioner_overnights',
'substantial_timesharing',
'petitioner_income_pct',
'respondent_income_pct',
)
def _compute_timesharing_adjustment(self):
"""
FL 61.30(11)(b) Substantial Timesharing Adjustment.
Only applies if either parent has > 73 overnights/year (20%).
Formula:
1. Each parent's share = adjusted_obligation × parent's income %
2. Multiply each share × 1.5 (cross-credit factor)
3. Multiply each result × other parent's timesharing %
4. Net payment = |pet_adjusted - resp_adjusted|
"""
for rec in self:
if not rec.substantial_timesharing:
rec.timesharing_adjustment = 0.0
continue
pet_nights = rec.petitioner_overnights or 0
resp_nights = 365 - pet_nights
pet_time_pct = pet_nights / 365
resp_time_pct = resp_nights / 365
adj = rec.adjusted_support_obligation or 0.0
pet_income_pct = rec.petitioner_income_pct or 0.5
resp_income_pct = rec.respondent_income_pct or 0.5
pet_share = adj * pet_income_pct
resp_share = adj * resp_income_pct
pet_adjusted = pet_share * 1.5 * resp_time_pct
resp_adjusted = resp_share * 1.5 * pet_time_pct
rec.timesharing_adjustment = round(abs(pet_adjusted - resp_adjusted), 2)
@api.depends('adjusted_support_obligation', 'timesharing_adjustment', 'substantial_timesharing')
def _compute_total_obligation(self):
for rec in self:
if rec.substantial_timesharing:
rec.total_support_obligation = rec.timesharing_adjustment
else:
rec.total_support_obligation = rec.adjusted_support_obligation
@api.depends('total_support_obligation', 'petitioner_income_pct', 'respondent_income_pct')
def _compute_party_obligations(self):
for rec in self:
total = rec.total_support_obligation or 0.0
rec.petitioner_obligation = round(total * (rec.petitioner_income_pct or 0.5), 2)
rec.respondent_obligation = round(total * (rec.respondent_income_pct or 0.5), 2)
@api.depends(
'petitioner_obligation', 'respondent_obligation',
'health_insurance_by_petitioner', 'health_insurance_by_respondent',
'childcare_by_petitioner', 'childcare_by_respondent',
)
def _compute_net_payment(self):
for rec in self:
# Credit parties for amounts they directly pay
pet_credits = (
(rec.health_insurance_by_petitioner or 0.0)
+ (rec.childcare_by_petitioner or 0.0)
)
resp_credits = (
(rec.health_insurance_by_respondent or 0.0)
+ (rec.childcare_by_respondent or 0.0)
)
pet_net = (rec.petitioner_obligation or 0.0) - pet_credits
resp_net = (rec.respondent_obligation or 0.0) - resp_credits
net = resp_net - pet_net
rec.net_payment_amount = abs(net)
if net > 0.01:
rec.payment_direction = 'petitioner_pays'
elif net < -0.01:
rec.payment_direction = 'respondent_pays'
else:
rec.payment_direction = 'no_payment'
@api.depends('total_support_obligation', 'deviation_requested', 'deviation_amount')
def _compute_final_with_deviation(self):
for rec in self:
if rec.deviation_requested and rec.deviation_amount:
rec.final_amount_with_deviation = (
rec.total_support_obligation + rec.deviation_amount
)
else:
rec.final_amount_with_deviation = rec.total_support_obligation
@api.depends(
'combined_net_income', 'basic_support_obligation',
'adjusted_support_obligation', 'net_payment_amount', 'payment_direction'
)
def _compute_summary(self):
direction_map = {
'petitioner_pays': 'Petitioner → Respondent',
'respondent_pays': 'Respondent → Petitioner',
'no_payment': 'No net payment',
}
for rec in self:
direction = direction_map.get(rec.payment_direction or '', '')
rec.calculation_summary = (
f'Combined income: ${rec.combined_net_income:,.2f}/mo | '
f'Basic obligation: ${rec.basic_support_obligation:,.2f} | '
f'Adjusted: ${rec.adjusted_support_obligation:,.2f} | '
f'Net payment: ${rec.net_payment_amount:,.2f}{direction}'
)