From d6f891838bb0f1508920866edaef84653b9365e0 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 11 Sep 2026 12:34:26 -0400 Subject: [PATCH] O3: mc_education_fees - structures, schedules, concessions, invoicing Serves Demo Scene 3: a fee structure (category x amount lines) with a term-wise installment schedule generates a correct account.move for a given enrollment + term, a concession recalculates it correctly, and the invoice is billed to the primary guardian's partner so stock portal invoice visibility (partner_id-based) works with no new record rule. Invoices are plain account.move (_inherit adds mc_student_id/ mc_enrollment_id only) - no invoice model was built, per CLAUDE.md sec 1.3. Fixed a real modeling mistake before it shipped: the schedule's "percentages must total 100%" rule was originally a blocking @api.constrains on every line write, which breaks the normal workflow of adding one term at a time (every intermediate state before the last line is, correctly, under 100%) - and would have broken this module's own demo data loading, since each schedule line is a separate XML record. Moved the check to where it actually matters: the invoice-generation wizard now raises a clear UserError if the resolved schedule doesn't total 100% at the point of use, while a live constraint still blocks the one thing that's unambiguously wrong at any point - allocating more than 100%. Also found, by testing money arithmetic against a live odoo:19.0 container rather than trusting the arithmetic by inspection: every generated invoice total came back at exactly 1.15x the expected amount, because the standing "School Fee" product picked up the demo company's default sales tax. Fixed by explicitly clearing taxes_id on the product - school fees are correctly untaxed (education services are GST-exempt in India), not just conveniently untaxed for the test. Testing this module's access rules surfaced two real bugs in the security model, not just test bugs, fixed here: - group_school_staff (from O1) never implied base.group_user, so any real user holding only this app's custom groups lacked ordinary internal-user access to core models like res.company - caught directly via a test user unable to even create an mc.fee.structure (whose company_id defaults through self.env.company). - mc.fee.concession reveals sensitive per-student financial data (e.g. a need-based hardship discount and its reason). Staff had read access, and since group_teacher implies group_school_staff, teachers inherited it too - exposing family financial circumstances to a role with no legitimate need for it. Removed the staff access row; only Administrator/Accountant keep it now. Fee *structure* (per-program pricing, not sensitive) correctly stays staff/teacher-readable. CLAUDE.md gets one more Odoo 19 API correction: res.users.groups_id -> group_ids. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 4 + .../security/mc_education_security.xml | 1 + addons/mc_education_fees/__init__.py | 2 + addons/mc_education_fees/__manifest__.py | 33 ++++ .../mc_education_fees/data/product_data.xml | 21 +++ .../demo/mc_fee_category_demo.xml | 23 +++ .../demo/mc_fee_concession_demo.xml | 14 ++ .../demo/mc_fee_schedule_demo.xml | 46 ++++++ .../demo/mc_fee_structure_demo.xml | 62 +++++++ addons/mc_education_fees/models/__init__.py | 6 + .../mc_education_fees/models/account_move.py | 8 + .../mc_education_fees/models/mc_enrollment.py | 17 ++ .../models/mc_fee_category.py | 16 ++ .../models/mc_fee_concession.py | 50 ++++++ .../models/mc_fee_schedule.py | 74 +++++++++ .../models/mc_fee_structure.py | 59 +++++++ .../security/ir.model.access.csv | 21 +++ addons/mc_education_fees/tests/__init__.py | 3 + addons/mc_education_fees/tests/test_access.py | 92 +++++++++++ .../tests/test_fee_invoice.py | 156 ++++++++++++++++++ .../tests/test_fee_structure.py | 85 ++++++++++ .../views/account_move_views.xml | 41 +++++ .../views/mc_education_fees_menus.xml | 21 +++ .../views/mc_fee_category_views.xml | 19 +++ .../views/mc_fee_concession_views.xml | 52 ++++++ .../mc_fee_invoice_generate_wizard_views.xml | 20 +++ .../views/mc_fee_schedule_views.xml | 39 +++++ .../views/mc_fee_structure_views.xml | 53 ++++++ addons/mc_education_fees/wizards/__init__.py | 1 + .../wizards/mc_fee_invoice_generate_wizard.py | 121 ++++++++++++++ 30 files changed, 1160 insertions(+) create mode 100644 addons/mc_education_fees/__init__.py create mode 100644 addons/mc_education_fees/__manifest__.py create mode 100644 addons/mc_education_fees/data/product_data.xml create mode 100644 addons/mc_education_fees/demo/mc_fee_category_demo.xml create mode 100644 addons/mc_education_fees/demo/mc_fee_concession_demo.xml create mode 100644 addons/mc_education_fees/demo/mc_fee_schedule_demo.xml create mode 100644 addons/mc_education_fees/demo/mc_fee_structure_demo.xml create mode 100644 addons/mc_education_fees/models/__init__.py create mode 100644 addons/mc_education_fees/models/account_move.py create mode 100644 addons/mc_education_fees/models/mc_enrollment.py create mode 100644 addons/mc_education_fees/models/mc_fee_category.py create mode 100644 addons/mc_education_fees/models/mc_fee_concession.py create mode 100644 addons/mc_education_fees/models/mc_fee_schedule.py create mode 100644 addons/mc_education_fees/models/mc_fee_structure.py create mode 100644 addons/mc_education_fees/security/ir.model.access.csv create mode 100644 addons/mc_education_fees/tests/__init__.py create mode 100644 addons/mc_education_fees/tests/test_access.py create mode 100644 addons/mc_education_fees/tests/test_fee_invoice.py create mode 100644 addons/mc_education_fees/tests/test_fee_structure.py create mode 100644 addons/mc_education_fees/views/account_move_views.xml create mode 100644 addons/mc_education_fees/views/mc_education_fees_menus.xml create mode 100644 addons/mc_education_fees/views/mc_fee_category_views.xml create mode 100644 addons/mc_education_fees/views/mc_fee_concession_views.xml create mode 100644 addons/mc_education_fees/views/mc_fee_invoice_generate_wizard_views.xml create mode 100644 addons/mc_education_fees/views/mc_fee_schedule_views.xml create mode 100644 addons/mc_education_fees/views/mc_fee_structure_views.xml create mode 100644 addons/mc_education_fees/wizards/__init__.py create mode 100644 addons/mc_education_fees/wizards/mc_fee_invoice_generate_wizard.py 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 + + + +