# pyrefly: ignore [missing-import] from odoo import api, fields, models # pyrefly: ignore [missing-import] from odoo.exceptions import ValidationError class EducationHostelFeeStructure(models.Model): _name = 'education.hostel.fee.structure' _description = 'Hostel Fee Structure' _order = 'hostel_id, room_type' hostel_id = fields.Many2one( 'education.hostel', string='Hostel', required=True, ondelete='restrict', index=True, ) room_type = fields.Selection([ ('single', 'Single'), ('double', 'Double'), ('triple', 'Triple'), ('four', 'Four Sharing'), ('dormitory', 'Dormitory'), ('custom', 'Custom'), ], string='Room Type', required=True) amount = fields.Float(string='Amount', required=True, digits=(10, 2)) billing_period = fields.Selection([ ('monthly', 'Monthly'), ('quarterly', 'Quarterly'), ('semester', 'Per Semester'), ('yearly', 'Yearly'), ], string='Billing Period', required=True, default='monthly') academic_year_id = fields.Many2one( 'education.academic.year', string='Academic Year', ondelete='restrict', index=True, ) active = fields.Boolean(default=True) company_id = fields.Many2one( 'res.company', string='Company', required=True, default=lambda self: self.env.company, index=True, ) _sql_constraints = [ ( 'uniq_fee_structure', 'unique(hostel_id, room_type, billing_period, academic_year_id)', 'A fee structure with this combination (hostel, room type, billing period, academic year) already exists.', ), ] @api.constrains('amount') def _check_amount(self): for rec in self: if rec.amount <= 0: raise ValidationError('Fee amount must be greater than zero.') class EducationHostelFee(models.Model): """Hostel fee record per student per billing cycle. This model is intentionally kept internal (not integrated with accounting). An extension point `action_integrate_accounting()` is provided for future accounting integration (e.g., creating account.move entries). """ _name = 'education.hostel.fee' _description = 'Hostel Fee' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'due_date desc, id desc' name = fields.Char( string='Reference', required=True, copy=False, readonly=True, default=lambda self: self.env['ir.sequence'].next_by_code('education.hostel.fee'), index=True, ) student_id = fields.Many2one( 'education.student', string='Student', required=True, ondelete='restrict', index=True, tracking=True, ) member_id = fields.Many2one( 'education.hostel.member', string='Hostel Allocation', ondelete='set null', index=True, ) hostel_id = fields.Many2one( 'education.hostel', string='Hostel', required=True, ondelete='restrict', index=True, ) fee_structure_id = fields.Many2one( 'education.hostel.fee.structure', string='Fee Structure', ondelete='set null', ) amount = fields.Float(string='Amount', required=True, digits=(10, 2)) due_date = fields.Date(string='Due Date', tracking=True) paid_date = fields.Date(string='Paid Date', tracking=True) state = fields.Selection([ ('draft', 'Draft'), ('due', 'Due'), ('paid', 'Paid'), ('overdue', 'Overdue'), ('cancelled', 'Cancelled'), ], string='Status', default='draft', required=True, tracking=True, index=True) notes = fields.Text(string='Notes') company_id = fields.Many2one( 'res.company', string='Company', required=True, default=lambda self: self.env.company, index=True, ) @api.constrains('amount') def _check_amount(self): for rec in self: if rec.amount <= 0: raise ValidationError('Fee amount must be greater than zero.') @api.constrains('paid_date', 'due_date') def _check_dates(self): for rec in self: if rec.paid_date and rec.due_date and rec.paid_date < rec.due_date: pass # Paying before due date is allowed if rec.paid_date and rec.state not in ('paid',): pass # allowed — state may lag until action # ── Workflow ────────────────────────────────────────────────────────────── def action_set_due(self): for rec in self: if rec.state == 'draft': rec.state = 'due' def action_mark_paid(self): for rec in self: if rec.state not in ('due', 'overdue'): raise ValidationError("Only due or overdue fees can be marked as paid.") rec.state = 'paid' rec.paid_date = fields.Date.today() rec.message_post(body=f"Fee marked as paid on {rec.paid_date}.") def action_set_overdue(self): """Called by a scheduled action or cron to mark overdue fees.""" today = fields.Date.today() overdue = self.search([ ('state', '=', 'due'), ('due_date', '<', today), ]) overdue.write({'state': 'overdue'}) def action_cancel(self): self.write({'state': 'cancelled'}) def action_integrate_accounting(self): """Extension point for future accounting integration. When accounting integration is required: 1. Add 'account' to module depends 2. Override this method to create account.move entries 3. Map education.hostel.fee -> account.move.line """ raise NotImplementedError( "Accounting integration is not yet implemented. " "Override action_integrate_accounting() in a custom module that depends on 'account'." ) @api.model_create_multi def create(self, vals_list): for vals in vals_list: if not vals.get('name') or vals.get('name') == '/': vals['name'] = ( self.env['ir.sequence'].next_by_code('education.hostel.fee') or '/' ) return super().create(vals_list)