Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions fleet_traffic_infractions_account/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
24 changes: 24 additions & 0 deletions fleet_traffic_infractions_account/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Fleet Traffic Infractions - Accounting",
"version": "18.0.1.0.0",
"category": "Fleet",
"summary": "Handle invoicing and billing for fleet traffic infractions.",
"author": "Raimundo Pereira da Silva Junior, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/fleet",
"license": "AGPL-3",
"depends": [
"account",
"fleet",
"fleet_traffic_infractions",
],
"data": [
"security/ir.model.access.csv",
"views/res_config_settings_views.xml",
"views/fleet_traffic_infraction_views.xml",
"views/fleet_traffic_infraction_type_views.xml",
"views/fleet_traffic_infraction_invoicing_term_views.xml",
],
"installable": True,
}
10 changes: 10 additions & 0 deletions fleet_traffic_infractions_account/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from . import fleet_traffic_infraction_type
from . import fleet_traffic_infraction_invoicing_term_line
from . import fleet_traffic_infraction_invoicing_term
from . import fleet_traffic_infractions
from . import res_company
from . import res_config_settings
from . import res_partner
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import _, api, fields, models
from odoo.exceptions import ValidationError


class FleetTrafficInfractionInvoicingTerm(models.Model):
_name = "fleet.traffic.infraction.invoicing.term"
_description = "Fleet Infraction Invoicing Term (Rule)"
_order = "sequence, id"

name = fields.Char(required=True, help="A descriptive name for this invoicing rule.")
sequence = fields.Integer(
default=10,
help="The priority of the rule. Lower numbers are checked first. "
"The first rule that matches the driver will be applied.",
)
active = fields.Boolean(default=True)
driver_domain = fields.Char(
string="Applies To Drivers",
default="[]",
help="Use the domain builder to define which drivers this rule applies to. "
"Leave empty to create a 'catch-all' rule.",
)
action = fields.Selection(
[
("invoice_driver", "Invoice Driver"),
("company_pays", "Company Pays (No Driver Invoice)"),
],
required=True,
default="invoice_driver",
help="The action to take when this rule is matched.",
)

expense_line_ids = fields.One2many(
"fleet.traffic.infraction.invoicing.term.line",
"invoicing_term_id",
string="Additional Expenses",
help="Add lines for administrative fees or other charges to be added to the driver's invoice.",
)

discount_type = fields.Selection(
[
("none", "No Discount"),
("percentage", "Percentage"),
("fixed", "Fixed Amount"),
],
string="Discount Type",
default="none",
)
discount_value = fields.Float("Discount Value")
discount_product_id = fields.Many2one(
"product.product",
string="Discount Product",
domain="[('type', '=', 'service')]",
help="A service product used to represent the discount on the invoice.",
)

@api.constrains("discount_type", "discount_value", "discount_product_id")
def _check_discount(self):
for term in self:
if term.discount_type != "none":
if not term.discount_product_id:
raise ValidationError(
_("You must select a Discount Product for a discount policy.")
)
if term.discount_value <= 0:
raise ValidationError(
_("Discount value must be greater than zero.")
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import fields, models


class FleetTrafficInfractionInvoicingTermLine(models.Model):
_name = "fleet.traffic.infraction.invoicing.term.line"
_description = "Fleet Infraction Invoicing Term Line (Expenses)"

invoicing_term_id = fields.Many2one(
"fleet.traffic.infraction.invoicing.term",
string="Invoicing Term",
required=True,
ondelete="cascade",
)
product_id = fields.Many2one(
"product.product",
string="Product",
required=True,
domain="[('type', '=', 'service')]",
)
calculation_method = fields.Selection(
[
("fixed", "Fixed Amount"),
("percentage_fine", "Percentage of Fine Amount"),
],
string="Calculation",
required=True,
default="fixed",
)
amount = fields.Float(
string="Amount / Percentage",
help="The fixed amount or the percentage (e.g., 10 for 10%).",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import fields, models


class FleetTrafficInfractionType(models.Model):
_inherit = "fleet.traffic.infraction.type"

product_id = fields.Many2one(
"product.product",
string="Fine Product",
domain="[('type', '=', 'service')]",
help="Product used for invoicing this type of infraction. "
"Its expense account will be used for journal entries.",
)
181 changes: 181 additions & 0 deletions fleet_traffic_infractions_account/models/fleet_traffic_infractions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools.safe_eval import safe_eval


class FleetTrafficInfractions(models.Model):
_inherit = "fleet.traffic.infractions"

state = fields.Selection(
selection_add=[
("invoice", "To Invoice Driver"),
("bill", "To Create Agency Bill"),
("processed", "Processed"),
],
ondelete={
"invoice": "set default",
"bill": "set default",
"processed": "set default",
},
)

driver_invoice_id = fields.Many2one(
"account.move", "Driver Invoice", readonly=True, copy=False
)
agency_move_id = fields.Many2one(
"account.move", "Agency Accounting Entry", readonly=True, copy=False
)
# This field is for display/informational purposes on the form
applied_invoicing_term_id = fields.Many2one(
"fleet.traffic.infraction.invoicing.term",
string="Applied Invoicing Rule",
compute="_compute_applied_invoicing_term_id",
store=True,
)

@api.depends("driver_id")
def _compute_applied_invoicing_term_id(self):
"""Find and set the first matching invoicing term for the driver."""
for infraction in self:
infraction.applied_invoicing_term_id = infraction._get_invoicing_term()

def _get_invoicing_term(self):
"""
Find the first invoicing term that matches the driver of the infraction.
Terms are evaluated in ascending order of their sequence.
"""
self.ensure_one()
if not self.driver_id:
return self.env["fleet.traffic.infraction.invoicing.term"]

# Search for all active terms, ordered by sequence.
# The lowest sequence number has the highest priority.
terms = self.env["fleet.traffic.infraction.invoicing.term"].search(
[("active", "=", True)], order="sequence asc"
)

for term in terms:
try:
domain = safe_eval(term.driver_domain or "[]")
# Check if the current driver matches the domain of the term
if self.driver_id.filtered_domain(domain):
return term # Return the first matching term
except Exception:
# Ignore terms with invalid domains
continue

return self.env["fleet.traffic.infraction.invoicing.term"]

def create_driver_invoice(self):
for infraction in self:
if not infraction.driver_id:
raise UserError(_("A driver must be assigned to create an invoice."))

term = infraction.applied_invoicing_term_id
if not term:
raise UserError(
_("No applicable Invoicing Rule found for driver %s.")
% infraction.driver_id.name
)

if term.action == "company_pays":
infraction.message_post(
body=_(
"Driver invoice skipped as per Invoicing Rule '%s'. "
"The company will pay the fine."
)
% term.name
)
infraction.write({"state": "bill"})
continue

if term.action == "invoice_driver":
invoice_line_vals = infraction._prepare_invoice_lines_from_term(term)
invoice = self.env["account.move"].create(
{
"partner_id": infraction.driver_id.id,
"move_type": "out_invoice",
"invoice_date": fields.Date.today(),
"invoice_origin": infraction.name,
"invoice_line_ids": invoice_line_vals,
}
)
infraction.write({"driver_invoice_id": invoice.id, "state": "bill"})
return True

def create_agency_entry(self):
for infraction in self:
if not infraction.issuing_agency_id:
raise UserError(_("An issuing agency must be set to create a bill."))
if infraction.company_id.infraction_bill_method == "vendor_bill":
infraction._create_agency_vendor_bill()
else:
infraction._create_agency_journal_entry()
return True

def _create_agency_vendor_bill(self):
self.ensure_one()
product = self._get_fine_product()
bill = self.env["account.move"].create(
{
"partner_id": self.issuing_agency_id.id,
"move_type": "in_invoice",
"invoice_date": fields.Date.today(),
"invoice_date_due": self.due_date,
"invoice_origin": self.name,
"invoice_line_ids": [(0, 0, self._prepare_invoice_line(product))],
}
)
self.write({"agency_move_id": bill.id, "state": "processed"})

def _create_agency_journal_entry(self):
self.ensure_one()
product = self._get_fine_product()
debit_account = product.product_tmpl_id.get_product_accounts()["expense"]
if not debit_account:
raise UserError(
_("No expense account is configured for product '%s'.") % product.name
)
credit_account = self.issuing_agency_id.property_account_payable_id
journal = self.env["account.journal"].search(
[("type", "=", "purchase"), ("company_id", "=", self.company_id.id)],
limit=1,
)
if not journal:
raise UserError(_("No purchase journal found for this company."))
move_lines = [
(
0,
0,
{
"name": self.name,
"account_id": debit_account.id,
"debit": self.fine_amount,
"credit": 0.0,
},
),
(
0,
0,
{
"name": self.name,
"partner_id": self.issuing_agency_id.id,
"account_id": credit_account.id,
"debit": 0.0,
"credit": self.fine_amount,
},
),
]
entry = self.env["account.move"].create(
{
"journal_id": journal.id,
"move_type": "entry",
"date": fields.Date.today(),
"ref": self.name,
"line_ids": move_lines,
}
)
self.write({"agency_move_id": entry.id, "state": "processed"})
22 changes: 22 additions & 0 deletions fleet_traffic_infractions_account/models/res_company.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import fields, models


class ResCompany(models.Model):
_inherit = "res.company"

infraction_bill_method = fields.Selection(
[
("vendor_bill", "Create Vendor Bill"),
("journal_entry", "Create Miscellaneous Journal Entry"),
],
string="Infraction Agency Billing Method",
default="vendor_bill",
help="Choose the default method to record fines from issuing agencies.\n"
"- Vendor Bill: Treats the fine as a standard purchase, affecting "
"procurement reports.\n"
"- Journal Entry: Treats the fine as a direct expense, bypassing "
"the procurement process.",
)
13 changes: 13 additions & 0 deletions fleet_traffic_infractions_account/models/res_config_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import fields, models


class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"

infraction_bill_method = fields.Selection(
related="company_id.infraction_bill_method",
readonly=False,
)
Loading