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:
17
activeblue_familylaw/models/__init__.py
Normal file
17
activeblue_familylaw/models/__init__.py
Normal 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
|
||||
78
activeblue_familylaw/models/fl_ai_engine.py
Normal file
78
activeblue_familylaw/models/fl_ai_engine.py
Normal 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())
|
||||
79
activeblue_familylaw/models/fl_analysis.py
Normal file
79
activeblue_familylaw/models/fl_analysis.py
Normal 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')
|
||||
47
activeblue_familylaw/models/fl_argument.py
Normal file
47
activeblue_familylaw/models/fl_argument.py
Normal 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
|
||||
)
|
||||
952
activeblue_familylaw/models/fl_case.py
Normal file
952
activeblue_familylaw/models/fl_case.py
Normal 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},
|
||||
}
|
||||
95
activeblue_familylaw/models/fl_caselaw.py
Normal file
95
activeblue_familylaw/models/fl_caselaw.py
Normal 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 = ''
|
||||
166
activeblue_familylaw/models/fl_child.py
Normal file
166
activeblue_familylaw/models/fl_child.py
Normal 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
|
||||
248
activeblue_familylaw/models/fl_deadline.py
Normal file
248
activeblue_familylaw/models/fl_deadline.py
Normal 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',
|
||||
)
|
||||
55
activeblue_familylaw/models/fl_deposition.py
Normal file
55
activeblue_familylaw/models/fl_deposition.py
Normal 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')
|
||||
70
activeblue_familylaw/models/fl_discovery.py
Normal file
70
activeblue_familylaw/models/fl_discovery.py
Normal 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
|
||||
52
activeblue_familylaw/models/fl_document.py
Normal file
52
activeblue_familylaw/models/fl_document.py
Normal 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')
|
||||
27
activeblue_familylaw/models/fl_expense_case.py
Normal file
27
activeblue_familylaw/models/fl_expense_case.py
Normal 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')
|
||||
162
activeblue_familylaw/models/fl_fee_waiver.py
Normal file
162
activeblue_familylaw/models/fl_fee_waiver.py
Normal 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'
|
||||
47
activeblue_familylaw/models/fl_hearing.py
Normal file
47
activeblue_familylaw/models/fl_hearing.py
Normal 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')
|
||||
165
activeblue_familylaw/models/fl_income_withholding.py
Normal file
165
activeblue_familylaw/models/fl_income_withholding.py
Normal 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'
|
||||
318
activeblue_familylaw/models/fl_party.py
Normal file
318
activeblue_familylaw/models/fl_party.py
Normal 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'
|
||||
)
|
||||
56
activeblue_familylaw/models/fl_statute.py
Normal file
56
activeblue_familylaw/models/fl_statute.py
Normal 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.'),
|
||||
]
|
||||
426
activeblue_familylaw/models/fl_support.py
Normal file
426
activeblue_familylaw/models/fl_support.py
Normal 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}'
|
||||
)
|
||||
Reference in New Issue
Block a user