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>
427 lines
17 KiB
Python
427 lines
17 KiB
Python
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}'
|
||
)
|