diff --git a/CLAUDE.md b/CLAUDE.md index 5b1b727..6627b1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,6 +109,10 @@ that being true, not staged. `privilege_id`, and the privilege carries `category_id` (still an `ir.module.category`). Create one `res.groups.privilege` per module category and point every group at it. See `addons/mc_education_base/security/mc_education_security.xml` for the pattern. + - **Correction (found building O3):** `res.users.groups_id` is gone too — the field is now + `group_ids`. Same `[(6, 0, [group_ids...])]` Many2many-write syntax otherwise. Matters for + every test that creates a user in a specific group (see + `addons/mc_education_fees/tests/test_access.py`), and will matter again for O7's portal users. - `_name`, `_description` and `_order` on every model. `_rec_name` where the display field is not `name`. - Computed fields declare `@api.depends` accurately and are `store=True` only when they need to be diff --git a/addons/mc_education_base/security/mc_education_security.xml b/addons/mc_education_base/security/mc_education_security.xml index 2a6a5b8..306d994 100644 --- a/addons/mc_education_base/security/mc_education_security.xml +++ b/addons/mc_education_base/security/mc_education_security.xml @@ -17,6 +17,7 @@ School Staff + Front office and general staff: read access to academic structure and student records. diff --git a/addons/mc_education_fees/__init__.py b/addons/mc_education_fees/__init__.py new file mode 100644 index 0000000..aee8895 --- /dev/null +++ b/addons/mc_education_fees/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/addons/mc_education_fees/__manifest__.py b/addons/mc_education_fees/__manifest__.py new file mode 100644 index 0000000..020f7c7 --- /dev/null +++ b/addons/mc_education_fees/__manifest__.py @@ -0,0 +1,33 @@ +{ + "name": "School ERP - Fees", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Fee structures, installment schedules, concessions, and invoicing on stock account.move.", + "author": "Metatroncube Software Solutions LLP", + "license": "Other proprietary", + # payment_demo is the stock built-in test/dummy provider - the "test + # payment gateway" the demo script pays through (shared/DEMO_SCRIPT.md + # Scene 3). Not a deviation from "Depends: mc_education_base, account, + # payment" so much as filling in which stock provider a demo actually + # needs; see CLAUDE.md sec 5 O3 note. + "depends": ["mc_education_base", "account", "payment", "payment_demo"], + "data": [ + "security/ir.model.access.csv", + "data/product_data.xml", + "views/mc_fee_category_views.xml", + "views/mc_fee_structure_views.xml", + "views/mc_fee_schedule_views.xml", + "views/mc_fee_concession_views.xml", + "views/mc_fee_invoice_generate_wizard_views.xml", + "views/account_move_views.xml", + "views/mc_education_fees_menus.xml", + ], + "demo": [ + "demo/mc_fee_category_demo.xml", + "demo/mc_fee_structure_demo.xml", + "demo/mc_fee_schedule_demo.xml", + "demo/mc_fee_concession_demo.xml", + ], + "installable": True, + "application": False, +} diff --git a/addons/mc_education_fees/data/product_data.xml b/addons/mc_education_fees/data/product_data.xml new file mode 100644 index 0000000..08229a0 --- /dev/null +++ b/addons/mc_education_fees/data/product_data.xml @@ -0,0 +1,21 @@ + + + + + School Fee + service + False + True + + + + diff --git a/addons/mc_education_fees/demo/mc_fee_category_demo.xml b/addons/mc_education_fees/demo/mc_fee_category_demo.xml new file mode 100644 index 0000000..11ff4db --- /dev/null +++ b/addons/mc_education_fees/demo/mc_fee_category_demo.xml @@ -0,0 +1,23 @@ + + + + Tuition + 10 + + + Lab + 20 + + + Library + 30 + + + Transport + 40 + + + Exam + 50 + + diff --git a/addons/mc_education_fees/demo/mc_fee_concession_demo.xml b/addons/mc_education_fees/demo/mc_fee_concession_demo.xml new file mode 100644 index 0000000..c15ef44 --- /dev/null +++ b/addons/mc_education_fees/demo/mc_fee_concession_demo.xml @@ -0,0 +1,14 @@ + + + + + + sibling + percent + 15 + Sibling of Aditya Krishnan, enrolled Grade 8-A + + + diff --git a/addons/mc_education_fees/demo/mc_fee_schedule_demo.xml b/addons/mc_education_fees/demo/mc_fee_schedule_demo.xml new file mode 100644 index 0000000..88d91d8 --- /dev/null +++ b/addons/mc_education_fees/demo/mc_fee_schedule_demo.xml @@ -0,0 +1,46 @@ + + + + + + + + + 2026-06-15 + 40 + + + + + 2026-10-15 + 30 + + + + + 2027-01-15 + 30 + + + + + + + + + 2026-06-15 + 40 + + + + + 2026-10-15 + 30 + + + + + 2027-01-15 + 30 + + diff --git a/addons/mc_education_fees/demo/mc_fee_structure_demo.xml b/addons/mc_education_fees/demo/mc_fee_structure_demo.xml new file mode 100644 index 0000000..f70a556 --- /dev/null +++ b/addons/mc_education_fees/demo/mc_fee_structure_demo.xml @@ -0,0 +1,62 @@ + + + + + + + + + + 40000 + + + + + 5000 + + + + + 2000 + + + + + 8000 + + + + + 3000 + + + + + + + + + + 30000 + + + + + 3000 + + + + + 1500 + + + + + 6000 + + + + + 2000 + + diff --git a/addons/mc_education_fees/models/__init__.py b/addons/mc_education_fees/models/__init__.py new file mode 100644 index 0000000..ae6870c --- /dev/null +++ b/addons/mc_education_fees/models/__init__.py @@ -0,0 +1,6 @@ +from . import mc_fee_category +from . import mc_fee_structure +from . import mc_fee_schedule +from . import mc_fee_concession +from . import account_move +from . import mc_enrollment diff --git a/addons/mc_education_fees/models/account_move.py b/addons/mc_education_fees/models/account_move.py new file mode 100644 index 0000000..2d33663 --- /dev/null +++ b/addons/mc_education_fees/models/account_move.py @@ -0,0 +1,8 @@ +from odoo import fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + mc_student_id = fields.Many2one("mc.student", string="Student", ondelete="restrict", index=True) + mc_enrollment_id = fields.Many2one("mc.enrollment", string="Enrollment", ondelete="restrict") diff --git a/addons/mc_education_fees/models/mc_enrollment.py b/addons/mc_education_fees/models/mc_enrollment.py new file mode 100644 index 0000000..5cf8502 --- /dev/null +++ b/addons/mc_education_fees/models/mc_enrollment.py @@ -0,0 +1,17 @@ +from odoo import fields, models + + +class McEnrollment(models.Model): + _inherit = "mc.enrollment" + + invoice_ids = fields.One2many("account.move", "mc_enrollment_id", string="Invoices") + + def action_open_fee_invoice_wizard(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "res_model": "mc.fee.invoice.generate.wizard", + "view_mode": "form", + "target": "new", + "context": {"default_enrollment_id": self.id}, + } diff --git a/addons/mc_education_fees/models/mc_fee_category.py b/addons/mc_education_fees/models/mc_fee_category.py new file mode 100644 index 0000000..4cb2924 --- /dev/null +++ b/addons/mc_education_fees/models/mc_fee_category.py @@ -0,0 +1,16 @@ +from odoo import fields, models + + +class McFeeCategory(models.Model): + _name = "mc.fee.category" + _description = "Fee Category" + _order = "sequence, name" + _rec_name = "name" + + name = fields.Char(string="Name", required=True, help="e.g. Tuition, Lab, Library, Transport, Exam.") + sequence = fields.Integer(string="Sequence", default=10) + + _name_uniq = models.Constraint( + "unique(name)", + "A fee category with this name already exists.", + ) diff --git a/addons/mc_education_fees/models/mc_fee_concession.py b/addons/mc_education_fees/models/mc_fee_concession.py new file mode 100644 index 0000000..db5b837 --- /dev/null +++ b/addons/mc_education_fees/models/mc_fee_concession.py @@ -0,0 +1,50 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class McFeeConcession(models.Model): + _name = "mc.fee.concession" + _inherit = ["mail.thread"] + _description = "Fee Concession" + _order = "student_id" + _rec_name = "display_name" + + student_id = fields.Many2one("mc.student", string="Student", required=True, ondelete="cascade") + concession_type = fields.Selection( + [ + ("sibling", "Sibling"), + ("merit", "Merit"), + ("staff", "Staff"), + ("need_based", "Need-based"), + ], + string="Type", required=True, tracking=True, + ) + computation = fields.Selection( + [("percent", "Percentage"), ("fixed", "Fixed Amount")], + string="Computed As", required=True, default="percent", tracking=True, + ) + value = fields.Float(string="Value", required=True, tracking=True, + help="A percentage (0-100) if computed as Percentage, otherwise a fixed amount.") + reason = fields.Char(string="Reason", required=True) + approver_id = fields.Many2one("res.users", string="Approved By", required=True, tracking=True) + active = fields.Boolean(string="Active", default=True) + + @api.depends("student_id.name", "concession_type", "value", "computation") + def _compute_display_name(self): + for concession in self: + value_label = "%.0f%%" % concession.value if concession.computation == "percent" else str(concession.value) + concession.display_name = "%s - %s (%s)" % ( + concession.student_id.name or "?", + dict(concession._fields["concession_type"].selection).get(concession.concession_type, "?"), + value_label, + ) + + @api.constrains("computation", "value") + def _check_percent_range(self): + for concession in self: + if concession.computation == "percent" and not (0 < concession.value <= 100): + raise ValidationError( + "A percentage concession's value must be between 0 and 100." + ) + if concession.value <= 0: + raise ValidationError("A concession's value must be greater than zero.") diff --git a/addons/mc_education_fees/models/mc_fee_schedule.py b/addons/mc_education_fees/models/mc_fee_schedule.py new file mode 100644 index 0000000..c7cecab --- /dev/null +++ b/addons/mc_education_fees/models/mc_fee_schedule.py @@ -0,0 +1,74 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class McFeeSchedule(models.Model): + _name = "mc.fee.schedule" + _description = "Fee Installment Schedule" + _order = "structure_id" + _rec_name = "display_name" + + structure_id = fields.Many2one( + "mc.fee.structure", string="Fee Structure", required=True, ondelete="cascade", + ) + line_ids = fields.One2many("mc.fee.schedule.line", "schedule_id", string="Installments") + total_percentage = fields.Float( + string="Total %", compute="_compute_total_percentage", store=True, + help="Must reach exactly 100% before this schedule can be used to generate an " + "invoice - checked at that point, not while you are still building it up " + "term by term.", + ) + + _structure_uniq = models.Constraint( + "unique(structure_id)", + "This fee structure already has an installment schedule.", + ) + + @api.depends("structure_id.display_name") + def _compute_display_name(self): + for schedule in self: + schedule.display_name = "%s installments" % (schedule.structure_id.display_name or "?") + + @api.depends("line_ids.percentage") + def _compute_total_percentage(self): + for schedule in self: + schedule.total_percentage = sum(schedule.line_ids.mapped("percentage")) + + +class McFeeScheduleLine(models.Model): + _name = "mc.fee.schedule.line" + _description = "Fee Installment" + _order = "due_date" + + schedule_id = fields.Many2one( + "mc.fee.schedule", string="Schedule", required=True, ondelete="cascade", + ) + term_id = fields.Many2one("mc.academic.term", string="Term", required=True, ondelete="restrict") + due_date = fields.Date(string="Due Date", required=True) + percentage = fields.Float(string="Percentage", required=True) + + _percentage_range = models.Constraint( + "check(percentage > 0 and percentage <= 100)", + "An installment percentage must be between 0 and 100.", + ) + _schedule_term_uniq = models.Constraint( + "unique(schedule_id, term_id)", + "This schedule already has an installment for this term.", + ) + + @api.constrains("percentage", "schedule_id") + def _check_schedule_not_over_100(self): + # Only guards against clearly-wrong data (allocating more than the + # whole fee) at write time. Reaching exactly 100% is expected to + # take several saves as terms are added one at a time - and is + # enforced instead at the point it actually matters: when the + # invoice-generation wizard resolves a schedule to use (see + # wizards/mc_fee_invoice_generate_wizard.py). + for line in self: + total = sum(line.schedule_id.line_ids.mapped("percentage")) + if total > 100.01: + raise ValidationError( + "The installments for '%s' add up to more than 100%% (%.2f%%)." % ( + line.schedule_id.display_name, total, + ) + ) diff --git a/addons/mc_education_fees/models/mc_fee_structure.py b/addons/mc_education_fees/models/mc_fee_structure.py new file mode 100644 index 0000000..39d9501 --- /dev/null +++ b/addons/mc_education_fees/models/mc_fee_structure.py @@ -0,0 +1,59 @@ +from odoo import api, fields, models + + +class McFeeStructure(models.Model): + _name = "mc.fee.structure" + _inherit = ["mail.thread"] + _description = "Fee Structure" + _order = "year_id desc, program_id" + _rec_name = "display_name" + + program_id = fields.Many2one("mc.program", string="Program", required=True, ondelete="restrict") + year_id = fields.Many2one("mc.academic.year", string="Academic Year", required=True, ondelete="restrict") + company_id = fields.Many2one( + "res.company", string="Company", required=True, + default=lambda self: self.env.company, + ) + currency_id = fields.Many2one(related="company_id.currency_id", string="Currency", store=True, readonly=True) + line_ids = fields.One2many("mc.fee.structure.line", "structure_id", string="Fee Lines") + total_amount = fields.Monetary( + string="Total", compute="_compute_total_amount", store=True, currency_field="currency_id", + ) + schedule_ids = fields.One2many("mc.fee.schedule", "structure_id", string="Installment Schedules") + + _program_year_company_uniq = models.Constraint( + "unique(program_id, year_id, company_id)", + "A fee structure already exists for this program and academic year.", + ) + + @api.depends("line_ids.amount") + def _compute_total_amount(self): + for structure in self: + structure.total_amount = sum(structure.line_ids.mapped("amount")) + + @api.depends("program_id.display_label", "year_id.name") + def _compute_display_name(self): + for structure in self: + structure.display_name = "%s - %s" % ( + structure.program_id.display_label or "?", structure.year_id.name or "?", + ) + + +class McFeeStructureLine(models.Model): + _name = "mc.fee.structure.line" + _description = "Fee Structure Line" + _order = "category_id" + + structure_id = fields.Many2one( + "mc.fee.structure", string="Fee Structure", required=True, ondelete="cascade", + ) + category_id = fields.Many2one("mc.fee.category", string="Category", required=True, ondelete="restrict") + amount = fields.Monetary(string="Amount", required=True, currency_field="currency_id") + currency_id = fields.Many2one( + related="structure_id.currency_id", string="Currency", readonly=True, + ) + + _structure_category_uniq = models.Constraint( + "unique(structure_id, category_id)", + "This fee structure already has a line for this category.", + ) diff --git a/addons/mc_education_fees/security/ir.model.access.csv b/addons/mc_education_fees/security/ir.model.access.csv new file mode 100644 index 0000000..78ff563 --- /dev/null +++ b/addons/mc_education_fees/security/ir.model.access.csv @@ -0,0 +1,21 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mc_fee_category_administrator,mc.fee.category.administrator,model_mc_fee_category,mc_education_base.group_school_administrator,1,1,1,1 +access_mc_fee_category_accountant,mc.fee.category.accountant,model_mc_fee_category,mc_education_base.group_accountant,1,1,1,1 +access_mc_fee_category_staff,mc.fee.category.staff,model_mc_fee_category,mc_education_base.group_school_staff,1,0,0,0 +access_mc_fee_structure_administrator,mc.fee.structure.administrator,model_mc_fee_structure,mc_education_base.group_school_administrator,1,1,1,1 +access_mc_fee_structure_accountant,mc.fee.structure.accountant,model_mc_fee_structure,mc_education_base.group_accountant,1,1,1,1 +access_mc_fee_structure_staff,mc.fee.structure.staff,model_mc_fee_structure,mc_education_base.group_school_staff,1,0,0,0 +access_mc_fee_structure_line_administrator,mc.fee.structure.line.administrator,model_mc_fee_structure_line,mc_education_base.group_school_administrator,1,1,1,1 +access_mc_fee_structure_line_accountant,mc.fee.structure.line.accountant,model_mc_fee_structure_line,mc_education_base.group_accountant,1,1,1,1 +access_mc_fee_structure_line_staff,mc.fee.structure.line.staff,model_mc_fee_structure_line,mc_education_base.group_school_staff,1,0,0,0 +access_mc_fee_schedule_administrator,mc.fee.schedule.administrator,model_mc_fee_schedule,mc_education_base.group_school_administrator,1,1,1,1 +access_mc_fee_schedule_accountant,mc.fee.schedule.accountant,model_mc_fee_schedule,mc_education_base.group_accountant,1,1,1,1 +access_mc_fee_schedule_staff,mc.fee.schedule.staff,model_mc_fee_schedule,mc_education_base.group_school_staff,1,0,0,0 +access_mc_fee_schedule_line_administrator,mc.fee.schedule.line.administrator,model_mc_fee_schedule_line,mc_education_base.group_school_administrator,1,1,1,1 +access_mc_fee_schedule_line_accountant,mc.fee.schedule.line.accountant,model_mc_fee_schedule_line,mc_education_base.group_accountant,1,1,1,1 +access_mc_fee_schedule_line_staff,mc.fee.schedule.line.staff,model_mc_fee_schedule_line,mc_education_base.group_school_staff,1,0,0,0 +access_mc_fee_concession_administrator,mc.fee.concession.administrator,model_mc_fee_concession,mc_education_base.group_school_administrator,1,1,1,1 +access_mc_fee_concession_accountant,mc.fee.concession.accountant,model_mc_fee_concession,mc_education_base.group_accountant,1,1,1,1 +access_mc_fee_invoice_generate_wizard_administrator,mc.fee.invoice.generate.wizard.administrator,model_mc_fee_invoice_generate_wizard,mc_education_base.group_school_administrator,1,1,1,1 +access_mc_fee_invoice_generate_wizard_accountant,mc.fee.invoice.generate.wizard.accountant,model_mc_fee_invoice_generate_wizard,mc_education_base.group_accountant,1,1,1,1 +access_mc_fee_invoice_generate_wizard_staff,mc.fee.invoice.generate.wizard.staff,model_mc_fee_invoice_generate_wizard,mc_education_base.group_school_staff,1,1,1,1 diff --git a/addons/mc_education_fees/tests/__init__.py b/addons/mc_education_fees/tests/__init__.py new file mode 100644 index 0000000..1b9d2e9 --- /dev/null +++ b/addons/mc_education_fees/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_fee_structure +from . import test_fee_invoice +from . import test_access diff --git a/addons/mc_education_fees/tests/test_access.py b/addons/mc_education_fees/tests/test_access.py new file mode 100644 index 0000000..1620440 --- /dev/null +++ b/addons/mc_education_fees/tests/test_access.py @@ -0,0 +1,92 @@ +from odoo.exceptions import AccessError +from odoo.tests.common import TransactionCase + + +class TestFeeAccess(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.program = cls.env["mc.program"].create({ + "name": "TEST ACCESS Program", "code": "TEST-ACC-P1", "sequence_no": 1, + "display_label": "Test Grade", + }) + cls.year = cls.env["mc.academic.year"].create({ + "name": "TEST-ACCESS-2026-27", + "date_start": "2026-06-01", "date_end": "2027-04-30", + }) + + def make_user(login, groups): + return cls.env["res.users"].create({ + "name": login, "login": login, "email": f"{login}@example.com", + "group_ids": [(6, 0, groups)], + }) + + base_group = cls.env.ref("mc_education_base.group_school_staff").id + cls.accountant = make_user("test_fees_accountant", [ + cls.env.ref("mc_education_base.group_accountant").id, base_group, + ]) + cls.staff = make_user("test_fees_staff", [base_group]) + cls.teacher = make_user("test_fees_teacher", [ + cls.env.ref("mc_education_base.group_teacher").id, base_group, + ]) + + def test_accountant_can_create_fee_structure(self): + structure = self.env["mc.fee.structure"].with_user(self.accountant).create({ + "program_id": self.program.id, "year_id": self.year.id, + }) + self.assertTrue(structure) + + def test_staff_cannot_create_fee_structure(self): + with self.assertRaises(AccessError): + self.env["mc.fee.structure"].with_user(self.staff).create({ + "program_id": self.program.id, "year_id": self.year.id, + }) + + def test_staff_can_read_fee_structure(self): + structure = self.env["mc.fee.structure"].create({ + "program_id": self.program.id, "year_id": self.year.id, + }) + # Should not raise: staff has read access. + structure.with_user(self.staff).read(["program_id"]) + + def test_teacher_can_read_fee_structure(self): + # Fee structure is per-program pricing, not per-student data - it + # is not sensitive, and teacher inherits staff's read access to it + # deliberately (group_teacher implies group_school_staff). + structure = self.env["mc.fee.structure"].create({ + "program_id": self.program.id, "year_id": self.year.id, + }) + # Should not raise. + structure.with_user(self.teacher).read(["program_id"]) + + def test_teacher_cannot_read_concession(self): + # Unlike fee structure, a concession reveals sensitive per-student + # financial/personal information (e.g. a need-based hardship + # discount and its reason). Neither staff nor teacher should be + # able to read it, even though teacher implies staff elsewhere. + student_partner = self.env["res.partner"].create({"name": "Access Test Student"}) + student = self.env["mc.student"].create({ + "partner_id": student_partner.id, "name": "Access Test Student", + }) + concession = self.env["mc.fee.concession"].create({ + "student_id": student.id, "concession_type": "need_based", + "computation": "percent", "value": 10, + "reason": "x", "approver_id": self.env.uid, + }) + with self.assertRaises(AccessError): + concession.with_user(self.teacher).read(["value"]) + with self.assertRaises(AccessError): + concession.with_user(self.staff).read(["value"]) + + def test_teacher_cannot_create_concession(self): + student_partner = self.env["res.partner"].create({"name": "Access Test Student"}) + student = self.env["mc.student"].create({ + "partner_id": student_partner.id, "name": "Access Test Student", + }) + with self.assertRaises(AccessError): + self.env["mc.fee.concession"].with_user(self.teacher).create({ + "student_id": student.id, "concession_type": "merit", + "computation": "percent", "value": 10, + "reason": "x", "approver_id": self.teacher.id, + }) diff --git a/addons/mc_education_fees/tests/test_fee_invoice.py b/addons/mc_education_fees/tests/test_fee_invoice.py new file mode 100644 index 0000000..79f6542 --- /dev/null +++ b/addons/mc_education_fees/tests/test_fee_invoice.py @@ -0,0 +1,156 @@ +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + + +class TestFeeInvoice(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.year = cls.env["mc.academic.year"].create({ + "name": "TEST-INV-2026-27", + "date_start": "2026-06-01", "date_end": "2027-04-30", + }) + cls.term1 = cls.env["mc.academic.term"].create({ + "name": "TEST INV Term 1", "year_id": cls.year.id, + "date_start": "2026-06-01", "date_end": "2026-09-30", + }) + cls.term2 = cls.env["mc.academic.term"].create({ + "name": "TEST INV Term 2", "year_id": cls.year.id, + "date_start": "2026-10-01", "date_end": "2026-12-31", + }) + cls.program = cls.env["mc.program"].create({ + "name": "TEST INV Program", "code": "TEST-INV-P1", "sequence_no": 1, + "display_label": "Test Grade", + }) + cls.batch = cls.env["mc.batch"].create({ + "name": "TEST INV Batch", "program_id": cls.program.id, "year_id": cls.year.id, + }) + cls.category_tuition = cls.env["mc.fee.category"].create({"name": "TEST INV Tuition"}) + cls.category_lab = cls.env["mc.fee.category"].create({"name": "TEST INV Lab"}) + cls.structure = cls.env["mc.fee.structure"].create({ + "program_id": cls.program.id, "year_id": cls.year.id, + "line_ids": [ + (0, 0, {"category_id": cls.category_tuition.id, "amount": 1000}), + (0, 0, {"category_id": cls.category_lab.id, "amount": 500}), + ], + }) + # Total 1500. Term 1 gets 40%, Term 2 gets 60%, so the Term 2 + # invoice generated in tests should be 900 before any concession. + cls.schedule = cls.env["mc.fee.schedule"].create({"structure_id": cls.structure.id}) + cls.env["mc.fee.schedule.line"].create({ + "schedule_id": cls.schedule.id, "term_id": cls.term1.id, + "due_date": "2026-06-15", "percentage": 40, + }) + cls.env["mc.fee.schedule.line"].create({ + "schedule_id": cls.schedule.id, "term_id": cls.term2.id, + "due_date": "2026-10-15", "percentage": 60, + }) + + student_partner = cls.env["res.partner"].create({"name": "Fee Test Student"}) + cls.student = cls.env["mc.student"].create({ + "partner_id": student_partner.id, "name": "Fee Test Student", + }) + guardian_partner = cls.env["res.partner"].create({"name": "Fee Test Guardian"}) + cls.guardian = cls.env["mc.guardian"].create({ + "partner_id": guardian_partner.id, "name": "Fee Test Guardian", + }) + cls.env["mc.student.guardian"].create({ + "student_id": cls.student.id, "guardian_id": cls.guardian.id, + "relationship": "father", "is_primary": True, + }) + cls.enrollment = cls.env["mc.enrollment"].create({ + "student_id": cls.student.id, "program_id": cls.program.id, + "batch_id": cls.batch.id, "year_id": cls.year.id, "state": "active", + }) + + def _generate(self, term): + wizard = self.env["mc.fee.invoice.generate.wizard"].create({ + "enrollment_id": self.enrollment.id, "term_id": term.id, + }) + action = wizard.action_generate() + return self.env["account.move"].browse(action["res_id"]) + + def test_invoice_amount_scaled_by_term_percentage(self): + invoice = self._generate(self.term2) + self.assertEqual(invoice.amount_total, 900) # 60% of 1500 + self.assertEqual(invoice.mc_student_id, self.student) + self.assertEqual(invoice.mc_enrollment_id, self.enrollment) + # Bills the guardian, not the child, so stock portal invoice + # visibility (partner_id-based) works with no extra record rule. + self.assertEqual(invoice.partner_id, self.guardian.partner_id) + + def test_invoice_has_one_line_per_category(self): + invoice = self._generate(self.term1) + self.assertEqual(len(invoice.invoice_line_ids), 2) + self.assertEqual(invoice.amount_total, 600) # 40% of 1500 + + def test_percent_concession_recalculates_invoice(self): + self.env["mc.fee.concession"].create({ + "student_id": self.student.id, "concession_type": "sibling", + "computation": "percent", "value": 15, + "reason": "Test sibling concession", "approver_id": self.env.uid, + }) + invoice = self._generate(self.term2) + # 900 gross, 15% off = 765. + self.assertEqual(invoice.amount_total, 765) + concession_lines = invoice.invoice_line_ids.filtered(lambda l: l.price_subtotal < 0) + self.assertEqual(len(concession_lines), 1) + self.assertEqual(concession_lines.price_subtotal, -135) + self.assertIn("Test sibling concession", concession_lines.name) + + def test_fixed_concession_recalculates_invoice(self): + self.env["mc.fee.concession"].create({ + "student_id": self.student.id, "concession_type": "need_based", + "computation": "fixed", "value": 200, + "reason": "Test fixed concession", "approver_id": self.env.uid, + }) + invoice = self._generate(self.term2) + self.assertEqual(invoice.amount_total, 700) # 900 - 200 + + def test_inactive_concession_not_applied(self): + concession = self.env["mc.fee.concession"].create({ + "student_id": self.student.id, "concession_type": "merit", + "computation": "percent", "value": 50, + "reason": "Test inactive concession", "approver_id": self.env.uid, + }) + concession.active = False + invoice = self._generate(self.term2) + self.assertEqual(invoice.amount_total, 900) + + def test_generating_without_matching_schedule_line_raises(self): + other_term = self.env["mc.academic.term"].create({ + "name": "TEST INV Term 3", "year_id": self.year.id, + "date_start": "2027-01-01", "date_end": "2027-04-30", + }) + with self.assertRaises(UserError): + self._generate(other_term) + + def test_generating_with_incomplete_schedule_raises(self): + # Total is only 40% for this structure - must be caught at + # generation time, not silently under-billed. + program2 = self.env["mc.program"].create({ + "name": "TEST INV Program 2", "code": "TEST-INV-P2", "sequence_no": 2, + "display_label": "Test Grade 2", + }) + batch2 = self.env["mc.batch"].create({ + "name": "TEST INV Batch 2", "program_id": program2.id, "year_id": self.year.id, + }) + structure2 = self.env["mc.fee.structure"].create({ + "program_id": program2.id, "year_id": self.year.id, + "line_ids": [(0, 0, {"category_id": self.category_tuition.id, "amount": 1000})], + }) + schedule2 = self.env["mc.fee.schedule"].create({"structure_id": structure2.id}) + self.env["mc.fee.schedule.line"].create({ + "schedule_id": schedule2.id, "term_id": self.term1.id, + "due_date": "2026-06-15", "percentage": 40, + }) + enrollment2 = self.env["mc.enrollment"].create({ + "student_id": self.student.id, "program_id": program2.id, + "batch_id": batch2.id, "year_id": self.year.id, "state": "draft", + }) + wizard = self.env["mc.fee.invoice.generate.wizard"].create({ + "enrollment_id": enrollment2.id, "term_id": self.term1.id, + }) + with self.assertRaises(UserError): + wizard.action_generate() diff --git a/addons/mc_education_fees/tests/test_fee_structure.py b/addons/mc_education_fees/tests/test_fee_structure.py new file mode 100644 index 0000000..78dd004 --- /dev/null +++ b/addons/mc_education_fees/tests/test_fee_structure.py @@ -0,0 +1,85 @@ +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + +# Names are deliberately distinct from demo data (see ../demo/) - these +# tests must pass whether or not demo data is loaded. + + +class TestFeeStructure(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.year = cls.env["mc.academic.year"].create({ + "name": "TEST-FEES-2026-27", + "date_start": "2026-06-01", + "date_end": "2027-04-30", + }) + cls.term1 = cls.env["mc.academic.term"].create({ + "name": "TEST Term 1", "year_id": cls.year.id, + "date_start": "2026-06-01", "date_end": "2026-09-30", + }) + cls.term2 = cls.env["mc.academic.term"].create({ + "name": "TEST Term 2", "year_id": cls.year.id, + "date_start": "2026-10-01", "date_end": "2026-12-31", + }) + cls.program = cls.env["mc.program"].create({ + "name": "TEST Program", "code": "TEST-FEES-P1", "sequence_no": 1, + "display_label": "Test Grade", + }) + cls.category_tuition = cls.env["mc.fee.category"].create({"name": "TEST Tuition"}) + cls.category_lab = cls.env["mc.fee.category"].create({"name": "TEST Lab"}) + + def test_total_amount_computed_from_lines(self): + structure = self.env["mc.fee.structure"].create({ + "program_id": self.program.id, + "year_id": self.year.id, + "line_ids": [ + (0, 0, {"category_id": self.category_tuition.id, "amount": 40000}), + (0, 0, {"category_id": self.category_lab.id, "amount": 5000}), + ], + }) + self.assertEqual(structure.total_amount, 45000) + + def test_duplicate_structure_for_same_program_year_rejected(self): + self.env["mc.fee.structure"].create({ + "program_id": self.program.id, "year_id": self.year.id, + }) + with self.assertRaises(Exception): + with self.cr.savepoint(): + self.env["mc.fee.structure"].create({ + "program_id": self.program.id, "year_id": self.year.id, + }) + + def test_schedule_can_be_built_up_incrementally(self): + # Adding one term at a time is the normal workflow, and the total + # is under 100% after every step but the last - must not raise. + structure = self.env["mc.fee.structure"].create({ + "program_id": self.program.id, "year_id": self.year.id, + }) + schedule = self.env["mc.fee.schedule"].create({"structure_id": structure.id}) + self.env["mc.fee.schedule.line"].create({ + "schedule_id": schedule.id, "term_id": self.term1.id, + "due_date": "2026-06-15", "percentage": 40, + }) + self.assertEqual(schedule.total_percentage, 40) + self.env["mc.fee.schedule.line"].create({ + "schedule_id": schedule.id, "term_id": self.term2.id, + "due_date": "2026-10-15", "percentage": 60, + }) + self.assertEqual(schedule.total_percentage, 100) + + def test_schedule_over_100_percent_rejected(self): + structure = self.env["mc.fee.structure"].create({ + "program_id": self.program.id, "year_id": self.year.id, + }) + schedule = self.env["mc.fee.schedule"].create({"structure_id": structure.id}) + self.env["mc.fee.schedule.line"].create({ + "schedule_id": schedule.id, "term_id": self.term1.id, + "due_date": "2026-06-15", "percentage": 70, + }) + with self.assertRaises(ValidationError): + self.env["mc.fee.schedule.line"].create({ + "schedule_id": schedule.id, "term_id": self.term2.id, + "due_date": "2026-10-15", "percentage": 40, + }) diff --git a/addons/mc_education_fees/views/account_move_views.xml b/addons/mc_education_fees/views/account_move_views.xml new file mode 100644 index 0000000..ceea529 --- /dev/null +++ b/addons/mc_education_fees/views/account_move_views.xml @@ -0,0 +1,41 @@ + + + + account.move.form.inherit.mc.education.fees + account.move + + + + + + + + + + + mc.enrollment.form.inherit.mc.education.fees + mc.enrollment + + + +