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