diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py
index 1e1ccb1c..02428851 100644
--- a/fusion_claims/__manifest__.py
+++ b/fusion_claims/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
- 'version': '19.0.8.0.2',
+ 'version': '19.0.8.0.3',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
@@ -122,6 +122,10 @@
'wizard/mod_awaiting_funding_wizard_views.xml',
'wizard/mod_funding_approved_wizard_views.xml',
'wizard/mod_pca_received_wizard_views.xml',
+ 'wizard/mod_submission_path_wizard_views.xml',
+ 'wizard/mod_funding_denied_wizard_views.xml',
+ 'wizard/mod_resubmit_wizard_views.xml',
+ 'wizard/mod_submission_confirmed_wizard_views.xml',
'wizard/odsp_sa_mobility_wizard_views.xml',
'wizard/odsp_discretionary_wizard_views.xml',
'wizard/odsp_submit_to_odsp_wizard_views.xml',
diff --git a/fusion_claims/data/ir_cron_data.xml b/fusion_claims/data/ir_cron_data.xml
index cb5c8580..9d32d66e 100644
--- a/fusion_claims/data/ir_cron_data.xml
+++ b/fusion_claims/data/ir_cron_data.xml
@@ -136,6 +136,22 @@
+
+
+ Fusion Claims: MOD Handoff Follow-up
+
+ code
+ model._cron_mod_handoff_followup()
+ 1
+ days
+ True
+
+
+
+
+
+
+ Upload the latest revision of the MOD-issued blank forms here.
+ They are auto-attached to outgoing emails so every case uses the
+ current version. Re-upload whenever March of Dimes publishes a
+ new revision.
+
+
diff --git a/fusion_claims/wizard/__init__.py b/fusion_claims/wizard/__init__.py
index 32095431..2bcaa70c 100644
--- a/fusion_claims/wizard/__init__.py
+++ b/fusion_claims/wizard/__init__.py
@@ -23,6 +23,10 @@ from . import send_to_mod_wizard
from . import mod_awaiting_funding_wizard
from . import mod_funding_approved_wizard
from . import mod_pca_received_wizard
+from . import mod_submission_path_wizard
+from . import mod_funding_denied_wizard
+from . import mod_resubmit_wizard
+from . import mod_submission_confirmed_wizard
from . import odsp_sa_mobility_wizard
from . import odsp_discretionary_wizard
from . import odsp_pre_approved_wizard
diff --git a/fusion_claims/wizard/mod_funding_denied_wizard.py b/fusion_claims/wizard/mod_funding_denied_wizard.py
new file mode 100644
index 00000000..1604dee4
--- /dev/null
+++ b/fusion_claims/wizard/mod_funding_denied_wizard.py
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+"""
+MOD Funding Denied Wizard — captures the denial reason and sets the order
+status. Previously action_mod_funding_denied was a bare write with no reason
+capture; this wizard replaces that flow.
+"""
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError
+from markupsafe import Markup
+
+
+class ModFundingDeniedWizard(models.TransientModel):
+ _name = 'fusion_claims.mod.funding.denied.wizard'
+ _description = 'MOD - Funding Denied Reason Capture'
+
+ sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
+ denial_date = fields.Date(
+ string='Denial Date',
+ required=True,
+ default=fields.Date.context_today,
+ )
+ denial_reason_category = fields.Selection(
+ selection=[
+ ('income_too_high', 'Client income exceeds MOD threshold'),
+ ('residency', 'Residency requirement not met'),
+ ('project_scope', 'Project scope not eligible'),
+ ('missing_docs', 'Missing documentation'),
+ ('funding_depleted', 'MOD funding depleted for this period'),
+ ('other', 'Other'),
+ ],
+ string='Denial Category',
+ required=True,
+ )
+ denial_reason = fields.Text(
+ string='Denial Details',
+ required=True,
+ help='MOD\'s stated reason for denying funding. Captured for the '
+ 'audit trail and used when generating the client denial email.',
+ )
+
+ @api.model
+ def default_get(self, fields_list):
+ res = super().default_get(fields_list)
+ if self.env.context.get('active_id'):
+ order = self.env['sale.order'].browse(self.env.context['active_id'])
+ res['sale_order_id'] = order.id
+ return res
+
+ def action_confirm(self):
+ self.ensure_one()
+ order = self.sale_order_id
+ if order.x_fc_mod_status not in ('awaiting_funding', 'quote_submitted', 'handoff_to_client'):
+ raise UserError(
+ _("Funding can only be denied from awaiting_funding, "
+ "quote_submitted or handoff_to_client. Current: %s") % order.x_fc_mod_status
+ )
+
+ labels = dict(self._fields['denial_reason_category'].selection)
+ category_label = labels.get(self.denial_reason_category, self.denial_reason_category)
+ full_reason = f'[{category_label}] {self.denial_reason}'
+
+ order.write({
+ 'x_fc_mod_status': 'funding_denied',
+ 'x_fc_mod_funding_denial_reason': full_reason,
+ })
+
+ body = (
+ f'
'
+ f'
Funding Denied by March of Dimes'
+ f'
'
+ f'- Date: {self.denial_date.strftime("%B %d, %Y")}
'
+ f'- Category: {category_label}
'
+ f'- Details: {self.denial_reason}
'
+ f'
'
+ f'
'
+ )
+ order.message_post(
+ body=Markup(body),
+ message_type='notification',
+ subtype_xmlid='mail.mt_note',
+ )
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/fusion_claims/wizard/mod_funding_denied_wizard_views.xml b/fusion_claims/wizard/mod_funding_denied_wizard_views.xml
new file mode 100644
index 00000000..a224317e
--- /dev/null
+++ b/fusion_claims/wizard/mod_funding_denied_wizard_views.xml
@@ -0,0 +1,40 @@
+
+
+
+ fusion_claims.mod.funding.denied.wizard.form
+ fusion_claims.mod.funding.denied.wizard
+
+
+
+
+
+
+ Funding Denied by MOD
+ fusion_claims.mod.funding.denied.wizard
+ form
+ new
+
+
diff --git a/fusion_claims/wizard/mod_resubmit_wizard.py b/fusion_claims/wizard/mod_resubmit_wizard.py
new file mode 100644
index 00000000..59f6d55c
--- /dev/null
+++ b/fusion_claims/wizard/mod_resubmit_wizard.py
@@ -0,0 +1,87 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+"""
+MOD Resubmit Wizard — lets the office revise an order that MOD has denied,
+and kick it back into the workflow at `processing_drawings` so the specialist
+can update drawings/proposal/quotation before it is re-submitted to MOD.
+"""
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError
+from markupsafe import Markup
+
+
+class ModResubmitWizard(models.TransientModel):
+ _name = 'fusion_claims.mod.resubmit.wizard'
+ _description = 'MOD - Resubmit After Denial'
+
+ sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
+ revision_notes = fields.Text(
+ string='Revision Notes',
+ required=True,
+ help='Describe what is being revised for the resubmission '
+ '(scope changes, updated pricing, additional documentation, etc).',
+ )
+ clear_old_documents = fields.Boolean(
+ string='Clear old drawings / proposal / quotation',
+ default=False,
+ help='Tick this if the new submission needs entirely new drawings and '
+ 'proposal. The previous documents are preserved in chatter before '
+ 'being cleared.',
+ )
+
+ @api.model
+ def default_get(self, fields_list):
+ res = super().default_get(fields_list)
+ if self.env.context.get('active_id'):
+ order = self.env['sale.order'].browse(self.env.context['active_id'])
+ res['sale_order_id'] = order.id
+ return res
+
+ def action_confirm(self):
+ self.ensure_one()
+ order = self.sale_order_id
+ if order.x_fc_mod_status != 'funding_denied':
+ raise UserError(_("Only denied orders can be resubmitted via this wizard."))
+
+ if self.clear_old_documents:
+ # Preserve the old docs in chatter before clearing them.
+ preserved = []
+ if order.x_fc_mod_drawing:
+ preserved.append('Drawing')
+ if order.x_fc_mod_proposal_doc:
+ preserved.append('Proposal')
+ if preserved:
+ order.message_post(
+ body=Markup(
+ f'
'
+ f'Previous documents cleared for resubmission: '
+ f'{", ".join(preserved)}
'
+ f'See prior chatter for the originals.'
+ f'
'
+ ),
+ message_type='notification',
+ subtype_xmlid='mail.mt_note',
+ )
+ order.with_context(skip_status_emails=True).write({
+ 'x_fc_mod_drawing': False,
+ 'x_fc_mod_drawing_filename': False,
+ 'x_fc_mod_proposal_doc': False,
+ 'x_fc_mod_proposal_doc_filename': False,
+ })
+
+ order.with_context(skip_status_emails=True).write({
+ 'x_fc_mod_status': 'processing_drawings',
+ })
+ body = (
+ f'
'
+ f'
Resubmitting to March of Dimes after denial'
+ f'
Revision notes: {self.revision_notes}
'
+ f'
'
+ )
+ order.message_post(
+ body=Markup(body),
+ message_type='notification',
+ subtype_xmlid='mail.mt_note',
+ )
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/fusion_claims/wizard/mod_resubmit_wizard_views.xml b/fusion_claims/wizard/mod_resubmit_wizard_views.xml
new file mode 100644
index 00000000..ec7bd201
--- /dev/null
+++ b/fusion_claims/wizard/mod_resubmit_wizard_views.xml
@@ -0,0 +1,39 @@
+
+
+
+ fusion_claims.mod.resubmit.wizard.form
+ fusion_claims.mod.resubmit.wizard
+
+
+
+
+
+
+ Resubmit to MOD
+ fusion_claims.mod.resubmit.wizard
+ form
+ new
+
+
diff --git a/fusion_claims/wizard/mod_submission_confirmed_wizard.py b/fusion_claims/wizard/mod_submission_confirmed_wizard.py
new file mode 100644
index 00000000..575f388a
--- /dev/null
+++ b/fusion_claims/wizard/mod_submission_confirmed_wizard.py
@@ -0,0 +1,92 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+"""
+MOD Submission Confirmed Wizard — office confirms that a client or authorizer
+has submitted the application to MOD, captures the actual submission date,
+and advances the order from handoff_to_client to awaiting_funding.
+"""
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError
+from markupsafe import Markup
+
+
+class ModSubmissionConfirmedWizard(models.TransientModel):
+ _name = 'fusion_claims.mod.submission.confirmed.wizard'
+ _description = 'MOD - Confirm Application Submitted'
+
+ sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
+ submitted_by_label = fields.Char(string='Submitted By', readonly=True)
+ application_submitted_date = fields.Date(
+ string='Actual Submission Date',
+ required=True,
+ default=fields.Date.context_today,
+ help='Date the client or authorizer told us they actually submitted '
+ 'the application to March of Dimes.',
+ )
+ confirmation_source = fields.Selection(
+ selection=[
+ ('phone_call', 'Phone call with client'),
+ ('email', 'Email from client'),
+ ('client_portal', 'Client used our portal'),
+ ('authorizer', 'Confirmed by authorizer (OT)'),
+ ('other', 'Other'),
+ ],
+ string='How was this confirmed?',
+ required=True,
+ default='phone_call',
+ )
+ confirmation_notes = fields.Text(
+ string='Notes from confirmation',
+ help='What did the client say? Confirmation number from MOD if given?',
+ )
+
+ @api.model
+ def default_get(self, fields_list):
+ res = super().default_get(fields_list)
+ if self.env.context.get('active_id'):
+ order = self.env['sale.order'].browse(self.env.context['active_id'])
+ res['sale_order_id'] = order.id
+ path_labels = {
+ 'internal': 'We (internal)',
+ 'client': 'Client',
+ 'authorizer': 'Authorizer (OT)',
+ }
+ res['submitted_by_label'] = path_labels.get(
+ order.x_fc_mod_submitted_by, 'Unknown')
+ return res
+
+ def action_confirm(self):
+ self.ensure_one()
+ order = self.sale_order_id
+ if order.x_fc_mod_status != 'handoff_to_client':
+ raise UserError(
+ _("This wizard is only for orders that have been handed off "
+ "to the client or authorizer for submission. Current status: %s")
+ % order.x_fc_mod_status
+ )
+
+ order.write({
+ 'x_fc_mod_status': 'awaiting_funding',
+ 'x_fc_mod_application_submitted_date': self.application_submitted_date,
+ })
+
+ source_labels = dict(self._fields['confirmation_source'].selection)
+ body = (
+ f'
'
+ f'
Application Submission Confirmed'
+ f'
'
+ f'- Submitted By: {self.submitted_by_label}
'
+ f'- Submission Date: {self.application_submitted_date.strftime("%B %d, %Y")}
'
+ f'- Confirmed Via: {source_labels[self.confirmation_source]}
'
+ f'
'
+ )
+ if self.confirmation_notes:
+ body += f'
Notes: {self.confirmation_notes}
'
+ body += '
'
+ order.message_post(
+ body=Markup(body),
+ message_type='notification',
+ subtype_xmlid='mail.mt_note',
+ )
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/fusion_claims/wizard/mod_submission_confirmed_wizard_views.xml b/fusion_claims/wizard/mod_submission_confirmed_wizard_views.xml
new file mode 100644
index 00000000..40b9e133
--- /dev/null
+++ b/fusion_claims/wizard/mod_submission_confirmed_wizard_views.xml
@@ -0,0 +1,41 @@
+
+
+
+ fusion_claims.mod.submission.confirmed.wizard.form
+ fusion_claims.mod.submission.confirmed.wizard
+
+
+
+
+
+
+ Confirm MOD Submission
+ fusion_claims.mod.submission.confirmed.wizard
+ form
+ new
+
+
diff --git a/fusion_claims/wizard/mod_submission_path_wizard.py b/fusion_claims/wizard/mod_submission_path_wizard.py
new file mode 100644
index 00000000..a16814c2
--- /dev/null
+++ b/fusion_claims/wizard/mod_submission_path_wizard.py
@@ -0,0 +1,76 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+"""
+MOD Submission Path Wizard — asks the office which party is submitting the
+March of Dimes application. Sets x_fc_mod_submitted_by on the order and,
+when the path is 'internal', auto-triggers the VOD request email to the
+authorizer the first time it is selected.
+"""
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError
+from markupsafe import Markup
+
+
+class ModSubmissionPathWizard(models.TransientModel):
+ _name = 'fusion_claims.mod.submission.path.wizard'
+ _description = 'MOD - Set Submission Path'
+
+ sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
+ submitted_by = fields.Selection(
+ selection=[
+ ('internal', 'We submit on client\'s behalf'),
+ ('client', 'Client submits themselves'),
+ ('authorizer', 'Authorizer (OT) submits'),
+ ],
+ string='Who is submitting to March of Dimes?',
+ required=True,
+ )
+ notes = fields.Text(string='Notes')
+
+ @api.model
+ def default_get(self, fields_list):
+ res = super().default_get(fields_list)
+ if self.env.context.get('active_id'):
+ order = self.env['sale.order'].browse(self.env.context['active_id'])
+ res['sale_order_id'] = order.id
+ if order.x_fc_mod_submitted_by:
+ res['submitted_by'] = order.x_fc_mod_submitted_by
+ return res
+
+ def action_confirm(self):
+ self.ensure_one()
+ order = self.sale_order_id
+ previous = order.x_fc_mod_submitted_by
+ vals = {'x_fc_mod_submitted_by': self.submitted_by}
+ order.with_context(skip_status_emails=True).write(vals)
+
+ labels = dict(self._fields['submitted_by'].selection)
+ body = (
+ f'
'
+ f'MOD submission path set: {labels[self.submitted_by]}'
+ )
+ if self.notes:
+ body += f'
{self.notes}'
+ body += '
'
+ order.message_post(
+ body=Markup(body),
+ message_type='notification',
+ subtype_xmlid='mail.mt_note',
+ )
+
+ # First-time internal path → auto-trigger the VOD request email so
+ # the authorizer knows to fill and send the VOD form back.
+ if (
+ self.submitted_by == 'internal'
+ and previous != 'internal'
+ and not order.x_fc_mod_vod_requested_date
+ ):
+ try:
+ order._send_mod_vod_request_email()
+ except Exception:
+ # Don't block the wizard if the email fails — office can
+ # retry via the manual "Request VOD" button.
+ pass
+
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/fusion_claims/wizard/mod_submission_path_wizard_views.xml b/fusion_claims/wizard/mod_submission_path_wizard_views.xml
new file mode 100644
index 00000000..c56414a3
--- /dev/null
+++ b/fusion_claims/wizard/mod_submission_path_wizard_views.xml
@@ -0,0 +1,39 @@
+
+
+
+ fusion_claims.mod.submission.path.wizard.form
+ fusion_claims.mod.submission.path.wizard
+
+
+
+
+
+
+ Set MOD Submission Path
+ fusion_claims.mod.submission.path.wizard
+ form
+ new
+
+