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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/mc_education_fees/views/mc_education_fees_menus.xml b/addons/mc_education_fees/views/mc_education_fees_menus.xml
new file mode 100644
index 0000000..8bf66d5
--- /dev/null
+++ b/addons/mc_education_fees/views/mc_education_fees_menus.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/mc_education_fees/views/mc_fee_category_views.xml b/addons/mc_education_fees/views/mc_fee_category_views.xml
new file mode 100644
index 0000000..52a71db
--- /dev/null
+++ b/addons/mc_education_fees/views/mc_fee_category_views.xml
@@ -0,0 +1,19 @@
+
+
+
+ mc.fee.category.list
+ mc.fee.category
+
+
+
+
+
+
+
+
+
+ Fee Categories
+ mc.fee.category
+ list
+
+
diff --git a/addons/mc_education_fees/views/mc_fee_concession_views.xml b/addons/mc_education_fees/views/mc_fee_concession_views.xml
new file mode 100644
index 0000000..1cba279
--- /dev/null
+++ b/addons/mc_education_fees/views/mc_fee_concession_views.xml
@@ -0,0 +1,52 @@
+
+
+
+ mc.fee.concession.list
+ mc.fee.concession
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mc.fee.concession.form
+ mc.fee.concession
+
+
+
+
+
+
+ Fee Concessions
+ mc.fee.concession
+ list,form
+ {'active_test': False}
+
+
diff --git a/addons/mc_education_fees/views/mc_fee_invoice_generate_wizard_views.xml b/addons/mc_education_fees/views/mc_fee_invoice_generate_wizard_views.xml
new file mode 100644
index 0000000..7f6ca87
--- /dev/null
+++ b/addons/mc_education_fees/views/mc_fee_invoice_generate_wizard_views.xml
@@ -0,0 +1,20 @@
+
+
+
+ mc.fee.invoice.generate.wizard.form
+ mc.fee.invoice.generate.wizard
+
+
+
+
+
diff --git a/addons/mc_education_fees/views/mc_fee_schedule_views.xml b/addons/mc_education_fees/views/mc_fee_schedule_views.xml
new file mode 100644
index 0000000..ae36a99
--- /dev/null
+++ b/addons/mc_education_fees/views/mc_fee_schedule_views.xml
@@ -0,0 +1,39 @@
+
+
+
+ mc.fee.schedule.list
+ mc.fee.schedule
+
+
+
+
+
+
+
+
+ mc.fee.schedule.form
+ mc.fee.schedule
+
+
+
+
+
+
+ Fee Schedules
+ mc.fee.schedule
+ list,form
+
+
diff --git a/addons/mc_education_fees/views/mc_fee_structure_views.xml b/addons/mc_education_fees/views/mc_fee_structure_views.xml
new file mode 100644
index 0000000..b7f4f4d
--- /dev/null
+++ b/addons/mc_education_fees/views/mc_fee_structure_views.xml
@@ -0,0 +1,53 @@
+
+
+
+ mc.fee.structure.list
+ mc.fee.structure
+
+
+
+
+
+
+
+
+
+
+ mc.fee.structure.form
+ mc.fee.structure
+
+
+
+
+
+
+ Fee Structures
+ mc.fee.structure
+ list,form
+
+
diff --git a/addons/mc_education_fees/wizards/__init__.py b/addons/mc_education_fees/wizards/__init__.py
new file mode 100644
index 0000000..c2def0d
--- /dev/null
+++ b/addons/mc_education_fees/wizards/__init__.py
@@ -0,0 +1 @@
+from . import mc_fee_invoice_generate_wizard
diff --git a/addons/mc_education_fees/wizards/mc_fee_invoice_generate_wizard.py b/addons/mc_education_fees/wizards/mc_fee_invoice_generate_wizard.py
new file mode 100644
index 0000000..3bf8eb4
--- /dev/null
+++ b/addons/mc_education_fees/wizards/mc_fee_invoice_generate_wizard.py
@@ -0,0 +1,121 @@
+from odoo import fields, models
+from odoo.exceptions import UserError
+
+
+class McFeeInvoiceGenerateWizard(models.TransientModel):
+ _name = "mc.fee.invoice.generate.wizard"
+ _description = "Generate Fee Invoice"
+
+ enrollment_id = fields.Many2one("mc.enrollment", string="Enrollment", required=True)
+ year_id = fields.Many2one(related="enrollment_id.year_id", string="Academic Year", readonly=True)
+ term_id = fields.Many2one(
+ "mc.academic.term", string="Term", required=True,
+ domain="[('year_id', '=', year_id)]",
+ )
+
+ def _get_structure(self):
+ enrollment = self.enrollment_id
+ structure = self.env["mc.fee.structure"].search([
+ ("program_id", "=", enrollment.program_id.id),
+ ("year_id", "=", enrollment.year_id.id),
+ ], limit=1)
+ if not structure:
+ raise UserError(
+ "No fee structure exists for %s in %s." % (
+ enrollment.program_id.display_label, enrollment.year_id.name,
+ )
+ )
+ return structure
+
+ def _get_term_percentage(self, structure):
+ schedule = structure.schedule_ids[:1]
+ if not schedule:
+ raise UserError("The fee structure for %s has no installment schedule." % structure.display_name)
+ if abs(schedule.total_percentage - 100.0) > 0.01:
+ raise UserError(
+ "The installment schedule for %s does not add up to 100%% yet "
+ "(currently %.2f%%). Fix the schedule before generating invoices "
+ "from it." % (structure.display_name, schedule.total_percentage)
+ )
+ schedule_line = schedule.line_ids.filtered(lambda l: l.term_id == self.term_id)
+ if not schedule_line:
+ raise UserError(
+ "The installment schedule for %s has no installment for %s." % (
+ structure.display_name, self.term_id.name,
+ )
+ )
+ return schedule_line.percentage
+
+ def _get_concession_lines(self, positive_total, product):
+ concessions = self.env["mc.fee.concession"].search([
+ ("student_id", "=", self.enrollment_id.student_id.id),
+ ("active", "=", True),
+ ])
+ lines = []
+ for concession in concessions:
+ if concession.computation == "percent":
+ amount = positive_total * concession.value / 100.0
+ else:
+ # A fixed concession applies in full on each invoice it
+ # appears on rather than being pro-rated by term - a flat
+ # rupee/dollar discount ("staff child gets 5000 off every
+ # term"), not an annual amount split across installments.
+ amount = concession.value
+ type_label = dict(concession._fields["concession_type"].selection).get(concession.concession_type)
+ lines.append((0, 0, {
+ "product_id": product.id,
+ "name": "Concession: %s (%s)" % (concession.reason, type_label),
+ "quantity": 1,
+ "price_unit": -amount,
+ }))
+ return lines
+
+ def action_generate(self):
+ self.ensure_one()
+ enrollment = self.enrollment_id
+ student = enrollment.student_id
+ structure = self._get_structure()
+ percentage = self._get_term_percentage(structure)
+
+ # Standing service product all fee invoice lines post through, so
+ # income account / tax resolution is stock accounting behavior,
+ # not something this module reimplements.
+ product = self.env.ref("mc_education_fees.product_school_fee")
+
+ positive_lines = []
+ positive_total = 0.0
+ for line in structure.line_ids:
+ amount = line.amount * percentage / 100.0
+ positive_total += amount
+ positive_lines.append((0, 0, {
+ "product_id": product.id,
+ "name": "%s - %s" % (line.category_id.name, self.term_id.name),
+ "quantity": 1,
+ "price_unit": amount,
+ }))
+
+ concession_lines = self._get_concession_lines(positive_total, product)
+
+ # Invoices bill the primary guardian, not the child - matches how
+ # a school actually gets paid, and lets stock portal invoice
+ # visibility (partner_id-based) work with zero extra record rules.
+ primary_link = self.env["mc.student.guardian"].search([
+ ("student_id", "=", student.id), ("is_primary", "=", True),
+ ], limit=1)
+ partner = primary_link.guardian_id.partner_id if primary_link else student.partner_id
+
+ move = self.env["account.move"].create({
+ "move_type": "out_invoice",
+ "partner_id": partner.id,
+ "mc_student_id": student.id,
+ "mc_enrollment_id": enrollment.id,
+ "invoice_date": fields.Date.context_today(self),
+ "invoice_line_ids": positive_lines + concession_lines,
+ })
+ return {
+ "type": "ir.actions.act_window",
+ "res_model": "account.move",
+ "res_id": move.id,
+ "view_mode": "form",
+ "target": "current",
+ }