407 lines
16 KiB
Python
407 lines
16 KiB
Python
from datetime import date
|
|
from calendar import monthrange
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import ValidationError
|
|
|
|
|
|
class DanceEnrollment(models.Model):
|
|
_inherit = "dance.enrollment"
|
|
|
|
invoice_count = fields.Integer(compute="_compute_invoice_count")
|
|
last_invoice_id = fields.Many2one("account.move", compute="_compute_invoice_count")
|
|
discount_type = fields.Selection(
|
|
[
|
|
("none", "No Discount"),
|
|
("sibling", "Sibling"),
|
|
("scholarship", "Scholarship"),
|
|
("custom", "Custom"),
|
|
],
|
|
default="none",
|
|
required=True,
|
|
)
|
|
discount_percent = fields.Float(string="Discount %")
|
|
apply_prorata = fields.Boolean(string="Apply Pro-rata", default=True)
|
|
waiver_state = fields.Selection(
|
|
[
|
|
("none", "No Waiver"),
|
|
("requested", "Requested"),
|
|
("approved", "Approved"),
|
|
("rejected", "Rejected"),
|
|
],
|
|
default="none",
|
|
required=True,
|
|
)
|
|
waiver_amount = fields.Monetary(currency_field="currency_id")
|
|
waiver_reason = fields.Text()
|
|
waiver_requested_by_id = fields.Many2one(
|
|
"res.users",
|
|
string="Waiver Requested By",
|
|
readonly=True,
|
|
)
|
|
waiver_requested_date = fields.Datetime(string="Waiver Requested On", readonly=True)
|
|
waiver_approved_by_id = fields.Many2one(
|
|
"res.users",
|
|
string="Waiver Approved By",
|
|
readonly=True,
|
|
)
|
|
waiver_approved_date = fields.Datetime(string="Waiver Approved On", readonly=True)
|
|
waiver_rejected_by_id = fields.Many2one(
|
|
"res.users",
|
|
string="Waiver Rejected By",
|
|
readonly=True,
|
|
)
|
|
waiver_rejected_date = fields.Datetime(string="Waiver Rejected On", readonly=True)
|
|
currency_id = fields.Many2one(
|
|
"res.currency",
|
|
related="batch_id.fee_structure_id.currency_id",
|
|
readonly=True,
|
|
)
|
|
base_fee_amount = fields.Monetary(
|
|
compute="_compute_fee_preview",
|
|
currency_field="currency_id",
|
|
)
|
|
discount_amount = fields.Monetary(
|
|
compute="_compute_fee_preview",
|
|
currency_field="currency_id",
|
|
)
|
|
prorata_amount = fields.Monetary(
|
|
compute="_compute_fee_preview",
|
|
currency_field="currency_id",
|
|
)
|
|
approved_waiver_amount = fields.Monetary(
|
|
compute="_compute_fee_preview",
|
|
currency_field="currency_id",
|
|
)
|
|
final_fee_amount = fields.Monetary(
|
|
compute="_compute_fee_preview",
|
|
currency_field="currency_id",
|
|
)
|
|
|
|
@api.depends(
|
|
"batch_id.fee_structure_id.amount",
|
|
"discount_percent",
|
|
"apply_prorata",
|
|
"enrollment_date",
|
|
"waiver_state",
|
|
"waiver_amount",
|
|
)
|
|
def _compute_fee_preview(self):
|
|
for enrollment in self:
|
|
if not enrollment.batch_id.fee_structure_id:
|
|
enrollment.base_fee_amount = 0.0
|
|
enrollment.discount_amount = 0.0
|
|
enrollment.prorata_amount = 0.0
|
|
enrollment.approved_waiver_amount = 0.0
|
|
enrollment.final_fee_amount = 0.0
|
|
continue
|
|
amounts = enrollment._get_invoice_amounts()
|
|
enrollment.base_fee_amount = amounts["base_amount"]
|
|
enrollment.discount_amount = amounts["discount_amount"]
|
|
enrollment.prorata_amount = amounts["prorata_amount"]
|
|
enrollment.approved_waiver_amount = amounts["waiver_amount"]
|
|
enrollment.final_fee_amount = amounts["final_amount"]
|
|
|
|
@api.onchange("discount_type")
|
|
def _onchange_discount_type(self):
|
|
defaults = {
|
|
"none": 0.0,
|
|
"sibling": 10.0,
|
|
"scholarship": 50.0,
|
|
}
|
|
if self.discount_type in defaults:
|
|
self.discount_percent = defaults[self.discount_type]
|
|
|
|
@api.constrains("discount_percent", "waiver_amount")
|
|
def _check_discount_and_waiver_amounts(self):
|
|
for enrollment in self:
|
|
if enrollment.discount_percent < 0 or enrollment.discount_percent > 100:
|
|
raise ValidationError("Discount percentage must be between 0 and 100.")
|
|
if enrollment.waiver_amount < 0:
|
|
raise ValidationError("Waiver amount cannot be negative.")
|
|
|
|
@api.constrains("discount_type", "student_id")
|
|
def _check_sibling_discount(self):
|
|
for enrollment in self:
|
|
if (
|
|
enrollment.discount_type == "sibling"
|
|
and enrollment.student_id
|
|
and not enrollment.student_id.sibling_ids
|
|
):
|
|
raise ValidationError(
|
|
"Sibling discount can only be used for students with linked siblings."
|
|
)
|
|
|
|
def _compute_invoice_count(self):
|
|
grouped = self.env["account.move"].read_group(
|
|
[
|
|
("dance_enrollment_id", "in", self.ids),
|
|
("move_type", "=", "out_invoice"),
|
|
],
|
|
["dance_enrollment_id"],
|
|
["dance_enrollment_id"],
|
|
)
|
|
count_map = {
|
|
record["dance_enrollment_id"][0]: record["dance_enrollment_id_count"]
|
|
for record in grouped
|
|
if record["dance_enrollment_id"]
|
|
}
|
|
latest_invoice_map = {}
|
|
invoices = self.env["account.move"].search(
|
|
[
|
|
("dance_enrollment_id", "in", self.ids),
|
|
("move_type", "=", "out_invoice"),
|
|
],
|
|
order="invoice_date desc, id desc",
|
|
)
|
|
for invoice in invoices:
|
|
latest_invoice_map.setdefault(invoice.dance_enrollment_id.id, invoice.id)
|
|
for enrollment in self:
|
|
enrollment.invoice_count = count_map.get(enrollment.id, 0)
|
|
enrollment.last_invoice_id = latest_invoice_map.get(enrollment.id)
|
|
|
|
def action_view_invoices(self):
|
|
self.ensure_one()
|
|
return {
|
|
"name": "Invoices",
|
|
"type": "ir.actions.act_window",
|
|
"res_model": "account.move",
|
|
"view_mode": "tree,form",
|
|
"domain": [
|
|
("dance_enrollment_id", "=", self.id),
|
|
("move_type", "=", "out_invoice"),
|
|
],
|
|
"context": {
|
|
"default_move_type": "out_invoice",
|
|
"default_partner_id": self.student_id.id,
|
|
"default_dance_batch_id": self.batch_id.id,
|
|
"default_dance_student_id": self.student_id.id,
|
|
"default_dance_enrollment_id": self.id,
|
|
},
|
|
}
|
|
|
|
def action_create_fee_invoice(self):
|
|
self.ensure_one()
|
|
if self.status != "active":
|
|
raise ValidationError("Only active enrollments can generate invoices.")
|
|
if not self.batch_id.fee_structure_id:
|
|
raise ValidationError("This batch does not have a fee structure.")
|
|
self._check_sibling_discount_before_invoice()
|
|
if self.waiver_state == "requested":
|
|
raise ValidationError("Approve or reject the fee waiver before creating an invoice.")
|
|
|
|
period_start, period_end = self._get_invoice_period()
|
|
existing = self.env["account.move"].search(
|
|
[
|
|
("move_type", "=", "out_invoice"),
|
|
("dance_enrollment_id", "=", self.id),
|
|
("dance_period_start", "=", period_start),
|
|
("dance_period_end", "=", period_end),
|
|
("state", "!=", "cancel"),
|
|
],
|
|
limit=1,
|
|
)
|
|
if existing:
|
|
raise ValidationError("An invoice already exists for this enrollment and period.")
|
|
|
|
fee_structure = self.batch_id.fee_structure_id
|
|
amounts = self._get_invoice_amounts(period_start, period_end)
|
|
income_account = self._get_income_account()
|
|
sales_journal = self._get_sales_journal()
|
|
invoice = self.env["account.move"].create(
|
|
{
|
|
"move_type": "out_invoice",
|
|
"journal_id": sales_journal.id,
|
|
"partner_id": self.student_id.id,
|
|
"invoice_date": fields.Date.today(),
|
|
"dance_student_id": self.student_id.id,
|
|
"dance_batch_id": self.batch_id.id,
|
|
"dance_enrollment_id": self.id,
|
|
"dance_fee_structure_id": fee_structure.id,
|
|
"dance_period_start": period_start,
|
|
"dance_period_end": period_end,
|
|
"invoice_line_ids": self._get_invoice_line_values(amounts, income_account),
|
|
}
|
|
)
|
|
return {
|
|
"name": "Invoice",
|
|
"type": "ir.actions.act_window",
|
|
"res_model": "account.move",
|
|
"res_id": invoice.id,
|
|
"view_mode": "form",
|
|
}
|
|
|
|
def action_request_fee_waiver(self):
|
|
self.write(
|
|
{
|
|
"waiver_state": "requested",
|
|
"waiver_requested_by_id": self.env.user.id,
|
|
"waiver_requested_date": fields.Datetime.now(),
|
|
"waiver_approved_by_id": False,
|
|
"waiver_approved_date": False,
|
|
"waiver_rejected_by_id": False,
|
|
"waiver_rejected_date": False,
|
|
}
|
|
)
|
|
return True
|
|
|
|
def action_approve_fee_waiver(self):
|
|
self._check_fee_waiver_approval_access()
|
|
for enrollment in self:
|
|
if not enrollment.waiver_amount:
|
|
raise ValidationError("Set a waiver amount before approving.")
|
|
self.write(
|
|
{
|
|
"waiver_state": "approved",
|
|
"waiver_approved_by_id": self.env.user.id,
|
|
"waiver_approved_date": fields.Datetime.now(),
|
|
"waiver_rejected_by_id": False,
|
|
"waiver_rejected_date": False,
|
|
}
|
|
)
|
|
return True
|
|
|
|
def action_reject_fee_waiver(self):
|
|
self._check_fee_waiver_approval_access()
|
|
self.write(
|
|
{
|
|
"waiver_state": "rejected",
|
|
"waiver_rejected_by_id": self.env.user.id,
|
|
"waiver_rejected_date": fields.Datetime.now(),
|
|
"waiver_approved_by_id": False,
|
|
"waiver_approved_date": False,
|
|
}
|
|
)
|
|
return True
|
|
|
|
def _check_fee_waiver_approval_access(self):
|
|
if not (
|
|
self.env.user.has_group("dance_fees.group_dance_fee_owner")
|
|
or self.env.user.has_group("account.group_account_manager")
|
|
):
|
|
raise ValidationError("Only a fee owner or accounting manager can approve waivers.")
|
|
|
|
def _check_sibling_discount_before_invoice(self):
|
|
self.ensure_one()
|
|
if self.discount_type == "sibling" and not self.student_id.sibling_ids:
|
|
raise ValidationError(
|
|
"Sibling discount can only be used for students with linked siblings."
|
|
)
|
|
|
|
def _get_invoice_period(self):
|
|
self.ensure_one()
|
|
today = fields.Date.context_today(self)
|
|
fee_structure = self.batch_id.fee_structure_id
|
|
if fee_structure.duration == "quarterly":
|
|
quarter = ((today.month - 1) // 3) * 3 + 1
|
|
period_start = date(today.year, quarter, 1)
|
|
period_end = date(today.year, quarter + 2, monthrange(today.year, quarter + 2)[1])
|
|
return period_start, period_end
|
|
period_start = date(today.year, today.month, 1)
|
|
period_end = date(today.year, today.month, monthrange(today.year, today.month)[1])
|
|
return period_start, period_end
|
|
|
|
def _get_invoice_amounts(self, period_start=False, period_end=False):
|
|
self.ensure_one()
|
|
if not period_start or not period_end:
|
|
period_start, period_end = self._get_invoice_period()
|
|
fee_structure = self.batch_id.fee_structure_id
|
|
currency = fee_structure.currency_id or self.env.company.currency_id
|
|
base_amount = fee_structure.amount
|
|
discount_amount = currency.round(base_amount * (self.discount_percent / 100.0))
|
|
after_discount = max(base_amount - discount_amount, 0.0)
|
|
prorata_amount = 0.0
|
|
prorata_factor = 1.0
|
|
if (
|
|
self.apply_prorata
|
|
and self.enrollment_date
|
|
and period_start < self.enrollment_date <= period_end
|
|
):
|
|
total_days = (period_end - period_start).days + 1
|
|
remaining_days = (period_end - self.enrollment_date).days + 1
|
|
prorata_factor = remaining_days / total_days if total_days else 1.0
|
|
prorated_total = currency.round(after_discount * prorata_factor)
|
|
prorata_amount = currency.round(after_discount - prorated_total)
|
|
after_discount = prorated_total
|
|
waiver_amount = 0.0
|
|
if self.waiver_state == "approved":
|
|
waiver_amount = min(self.waiver_amount, after_discount)
|
|
final_amount = currency.round(max(after_discount - waiver_amount, 0.0))
|
|
return {
|
|
"base_amount": currency.round(base_amount),
|
|
"discount_amount": discount_amount,
|
|
"prorata_amount": prorata_amount,
|
|
"waiver_amount": currency.round(waiver_amount),
|
|
"final_amount": final_amount,
|
|
"prorata_factor": prorata_factor,
|
|
}
|
|
|
|
def _get_invoice_line_name(self, amounts):
|
|
self.ensure_one()
|
|
fee_structure = self.batch_id.fee_structure_id
|
|
parts = ["%s - %s fee" % (self.batch_id.name, fee_structure.name)]
|
|
if self.discount_amount:
|
|
parts.append("%s%% %s discount" % (self.discount_percent, self.discount_type))
|
|
if amounts["prorata_amount"]:
|
|
parts.append("pro-rata applied")
|
|
if amounts["waiver_amount"]:
|
|
parts.append("approved waiver applied")
|
|
return " | ".join(parts)
|
|
|
|
def _get_invoice_line_values(self, amounts, income_account):
|
|
self.ensure_one()
|
|
fee_structure = self.batch_id.fee_structure_id
|
|
tax_commands = [(6, 0, fee_structure.tax_ids.ids)] if fee_structure.tax_ids else False
|
|
|
|
def line_values(name, price_unit):
|
|
values = {
|
|
"name": name,
|
|
"quantity": 1,
|
|
"price_unit": price_unit,
|
|
"account_id": income_account.id,
|
|
}
|
|
if tax_commands:
|
|
values["tax_ids"] = tax_commands
|
|
return (0, 0, values)
|
|
|
|
lines = [
|
|
line_values(
|
|
"%s - %s fee" % (self.batch_id.name, fee_structure.name),
|
|
amounts["base_amount"],
|
|
)
|
|
]
|
|
if amounts["discount_amount"]:
|
|
lines.append(
|
|
line_values(
|
|
"%s%% %s discount" % (self.discount_percent, self.discount_type),
|
|
-amounts["discount_amount"],
|
|
)
|
|
)
|
|
if amounts["prorata_amount"]:
|
|
lines.append(line_values("Pro-rata adjustment", -amounts["prorata_amount"]))
|
|
if amounts["waiver_amount"]:
|
|
lines.append(line_values("Approved fee waiver", -amounts["waiver_amount"]))
|
|
return lines
|
|
|
|
def _get_sales_journal(self):
|
|
journal = self.env["account.journal"].search(
|
|
[("type", "=", "sale"), ("company_id", "=", self.env.company.id)],
|
|
limit=1,
|
|
)
|
|
if not journal:
|
|
raise ValidationError("Create a sales journal before generating invoices.")
|
|
return journal
|
|
|
|
def _get_income_account(self):
|
|
account = self.env["account.account"].search(
|
|
[
|
|
("account_type", "=", "income"),
|
|
("deprecated", "=", False),
|
|
("company_id", "=", self.env.company.id),
|
|
],
|
|
limit=1,
|
|
)
|
|
if not account:
|
|
raise ValidationError("Create an income account before generating invoices.")
|
|
return account
|