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 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-09-11 12:34:26 -04:00
parent 508bf27417
commit d6f891838b
30 changed files with 1160 additions and 0 deletions

View File

@ -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

View File

@ -17,6 +17,7 @@
<record id="group_school_staff" model="res.groups">
<field name="name">School Staff</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Front office and general staff: read access to academic structure and student records.</field>
</record>

View File

@ -0,0 +1,2 @@
from . import models
from . import wizards

View File

@ -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,
}

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Standing accounting vehicle for every fee invoice line this module
creates. One product, differentiated by line description (the fee
category name), not a product catalog - accounts/taxes resolve
through stock product accounting, this module does not pick
income accounts itself. invoice_policy is a `sale` module field,
not present here since this module only depends on `account`. -->
<record id="product_school_fee" model="product.product">
<field name="name">School Fee</field>
<field name="type">service</field>
<field name="purchase_ok">False</field>
<field name="sale_ok">True</field>
<!-- Education services are GST-exempt in India, and school fees
generally are not taxed the way retail goods/services are.
Explicit empty override, not an absence: without this the
product picks up the company's default sales tax, silently
taxing every fee invoice at whatever rate that happens to be. -->
<field name="taxes_id" eval="[(6, 0, [])]"/>
</record>
</odoo>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="demo_fee_category_tuition" model="mc.fee.category">
<field name="name">Tuition</field>
<field name="sequence">10</field>
</record>
<record id="demo_fee_category_lab" model="mc.fee.category">
<field name="name">Lab</field>
<field name="sequence">20</field>
</record>
<record id="demo_fee_category_library" model="mc.fee.category">
<field name="name">Library</field>
<field name="sequence">30</field>
</record>
<record id="demo_fee_category_transport" model="mc.fee.category">
<field name="name">Transport</field>
<field name="sequence">40</field>
</record>
<record id="demo_fee_category_exam" model="mc.fee.category">
<field name="name">Exam</field>
<field name="sequence">50</field>
</record>
</odoo>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Matches shared/DEMO_SCRIPT.md Scene 3 exactly: a 15% sibling
concession applied to Ananya, whose brother Aditya is also
enrolled. -->
<record id="demo_concession_ananya_sibling" model="mc.fee.concession">
<field name="student_id" ref="mc_education_base.demo_student_ananya_krishnan"/>
<field name="concession_type">sibling</field>
<field name="computation">percent</field>
<field name="value">15</field>
<field name="reason">Sibling of Aditya Krishnan, enrolled Grade 8-A</field>
<field name="approver_id" ref="base.user_admin"/>
</record>
</odoo>

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="demo_fee_schedule_grade8" model="mc.fee.schedule">
<field name="structure_id" ref="demo_fee_structure_grade8"/>
</record>
<record id="demo_fee_schedule_grade8_t1" model="mc.fee.schedule.line">
<field name="schedule_id" ref="demo_fee_schedule_grade8"/>
<field name="term_id" ref="mc_education_base.demo_term_2026_27_t1"/>
<field name="due_date">2026-06-15</field>
<field name="percentage">40</field>
</record>
<record id="demo_fee_schedule_grade8_t2" model="mc.fee.schedule.line">
<field name="schedule_id" ref="demo_fee_schedule_grade8"/>
<field name="term_id" ref="mc_education_base.demo_term_2026_27_t2"/>
<field name="due_date">2026-10-15</field>
<field name="percentage">30</field>
</record>
<record id="demo_fee_schedule_grade8_t3" model="mc.fee.schedule.line">
<field name="schedule_id" ref="demo_fee_schedule_grade8"/>
<field name="term_id" ref="mc_education_base.demo_term_2026_27_t3"/>
<field name="due_date">2027-01-15</field>
<field name="percentage">30</field>
</record>
<record id="demo_fee_schedule_grade5" model="mc.fee.schedule">
<field name="structure_id" ref="demo_fee_structure_grade5"/>
</record>
<record id="demo_fee_schedule_grade5_t1" model="mc.fee.schedule.line">
<field name="schedule_id" ref="demo_fee_schedule_grade5"/>
<field name="term_id" ref="mc_education_base.demo_term_2026_27_t1"/>
<field name="due_date">2026-06-15</field>
<field name="percentage">40</field>
</record>
<record id="demo_fee_schedule_grade5_t2" model="mc.fee.schedule.line">
<field name="schedule_id" ref="demo_fee_schedule_grade5"/>
<field name="term_id" ref="mc_education_base.demo_term_2026_27_t2"/>
<field name="due_date">2026-10-15</field>
<field name="percentage">30</field>
</record>
<record id="demo_fee_schedule_grade5_t3" model="mc.fee.schedule.line">
<field name="schedule_id" ref="demo_fee_schedule_grade5"/>
<field name="term_id" ref="mc_education_base.demo_term_2026_27_t3"/>
<field name="due_date">2027-01-15</field>
<field name="percentage">30</field>
</record>
</odoo>

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="demo_fee_structure_grade8" model="mc.fee.structure">
<field name="program_id" ref="mc_education_base.demo_program_grade8"/>
<field name="year_id" ref="mc_education_base.demo_year_2026_27"/>
</record>
<record id="demo_fee_structure_grade8_line_tuition" model="mc.fee.structure.line">
<field name="structure_id" ref="demo_fee_structure_grade8"/>
<field name="category_id" ref="demo_fee_category_tuition"/>
<field name="amount">40000</field>
</record>
<record id="demo_fee_structure_grade8_line_lab" model="mc.fee.structure.line">
<field name="structure_id" ref="demo_fee_structure_grade8"/>
<field name="category_id" ref="demo_fee_category_lab"/>
<field name="amount">5000</field>
</record>
<record id="demo_fee_structure_grade8_line_library" model="mc.fee.structure.line">
<field name="structure_id" ref="demo_fee_structure_grade8"/>
<field name="category_id" ref="demo_fee_category_library"/>
<field name="amount">2000</field>
</record>
<record id="demo_fee_structure_grade8_line_transport" model="mc.fee.structure.line">
<field name="structure_id" ref="demo_fee_structure_grade8"/>
<field name="category_id" ref="demo_fee_category_transport"/>
<field name="amount">8000</field>
</record>
<record id="demo_fee_structure_grade8_line_exam" model="mc.fee.structure.line">
<field name="structure_id" ref="demo_fee_structure_grade8"/>
<field name="category_id" ref="demo_fee_category_exam"/>
<field name="amount">3000</field>
</record>
<record id="demo_fee_structure_grade5" model="mc.fee.structure">
<field name="program_id" ref="mc_education_base.demo_program_grade5"/>
<field name="year_id" ref="mc_education_base.demo_year_2026_27"/>
</record>
<record id="demo_fee_structure_grade5_line_tuition" model="mc.fee.structure.line">
<field name="structure_id" ref="demo_fee_structure_grade5"/>
<field name="category_id" ref="demo_fee_category_tuition"/>
<field name="amount">30000</field>
</record>
<record id="demo_fee_structure_grade5_line_lab" model="mc.fee.structure.line">
<field name="structure_id" ref="demo_fee_structure_grade5"/>
<field name="category_id" ref="demo_fee_category_lab"/>
<field name="amount">3000</field>
</record>
<record id="demo_fee_structure_grade5_line_library" model="mc.fee.structure.line">
<field name="structure_id" ref="demo_fee_structure_grade5"/>
<field name="category_id" ref="demo_fee_category_library"/>
<field name="amount">1500</field>
</record>
<record id="demo_fee_structure_grade5_line_transport" model="mc.fee.structure.line">
<field name="structure_id" ref="demo_fee_structure_grade5"/>
<field name="category_id" ref="demo_fee_category_transport"/>
<field name="amount">6000</field>
</record>
<record id="demo_fee_structure_grade5_line_exam" model="mc.fee.structure.line">
<field name="structure_id" ref="demo_fee_structure_grade5"/>
<field name="category_id" ref="demo_fee_category_exam"/>
<field name="amount">2000</field>
</record>
</odoo>

View File

@ -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

View File

@ -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")

View File

@ -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},
}

View File

@ -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.",
)

View File

@ -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.")

View File

@ -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,
)
)

View File

@ -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.",
)

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mc_fee_category_administrator mc.fee.category.administrator model_mc_fee_category mc_education_base.group_school_administrator 1 1 1 1
3 access_mc_fee_category_accountant mc.fee.category.accountant model_mc_fee_category mc_education_base.group_accountant 1 1 1 1
4 access_mc_fee_category_staff mc.fee.category.staff model_mc_fee_category mc_education_base.group_school_staff 1 0 0 0
5 access_mc_fee_structure_administrator mc.fee.structure.administrator model_mc_fee_structure mc_education_base.group_school_administrator 1 1 1 1
6 access_mc_fee_structure_accountant mc.fee.structure.accountant model_mc_fee_structure mc_education_base.group_accountant 1 1 1 1
7 access_mc_fee_structure_staff mc.fee.structure.staff model_mc_fee_structure mc_education_base.group_school_staff 1 0 0 0
8 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
9 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
10 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
11 access_mc_fee_schedule_administrator mc.fee.schedule.administrator model_mc_fee_schedule mc_education_base.group_school_administrator 1 1 1 1
12 access_mc_fee_schedule_accountant mc.fee.schedule.accountant model_mc_fee_schedule mc_education_base.group_accountant 1 1 1 1
13 access_mc_fee_schedule_staff mc.fee.schedule.staff model_mc_fee_schedule mc_education_base.group_school_staff 1 0 0 0
14 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
15 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
16 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
17 access_mc_fee_concession_administrator mc.fee.concession.administrator model_mc_fee_concession mc_education_base.group_school_administrator 1 1 1 1
18 access_mc_fee_concession_accountant mc.fee.concession.accountant model_mc_fee_concession mc_education_base.group_accountant 1 1 1 1
19 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
20 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
21 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

View File

@ -0,0 +1,3 @@
from . import test_fee_structure
from . import test_fee_invoice
from . import test_access

View File

@ -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,
})

View File

@ -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()

View File

@ -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,
})

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_move_form_inherit_mc_education_fees" model="ir.ui.view">
<field name="name">account.move.form.inherit.mc.education.fees</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='invoice_date']" position="after">
<field name="mc_student_id" readonly="1" invisible="not mc_student_id"/>
<field name="mc_enrollment_id" readonly="1" invisible="not mc_enrollment_id"/>
</xpath>
</field>
</record>
<record id="view_mc_enrollment_form_inherit_mc_education_fees" model="ir.ui.view">
<field name="name">mc.enrollment.form.inherit.mc.education.fees</field>
<field name="model">mc.enrollment</field>
<field name="inherit_id" ref="mc_education_base.view_mc_enrollment_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_open_fee_invoice_wizard" string="Generate Fee Invoice"
type="object" class="oe_highlight"/>
</xpath>
<xpath expr="//sheet" position="inside">
<notebook>
<page string="Invoices">
<field name="invoice_ids" readonly="1">
<list>
<field name="name"/>
<field name="invoice_date"/>
<field name="amount_total"/>
<field name="amount_residual"/>
<field name="payment_state" widget="badge"/>
</list>
</field>
</page>
</notebook>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_school_fees" name="Fees"
parent="mc_education_base.menu_school_root" sequence="15"/>
<menuitem id="menu_mc_fee_structure" name="Fee Structures"
parent="menu_school_fees"
action="action_mc_fee_structure" sequence="10"/>
<menuitem id="menu_mc_fee_schedule" name="Installment Schedules"
parent="menu_school_fees"
action="action_mc_fee_schedule" sequence="20"/>
<menuitem id="menu_mc_fee_concession" name="Concessions"
parent="menu_school_fees"
action="action_mc_fee_concession" sequence="30"/>
<menuitem id="menu_mc_fee_category" name="Fee Categories"
parent="mc_education_base.menu_school_configuration"
action="action_mc_fee_category" sequence="70"/>
</odoo>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_fee_category_list" model="ir.ui.view">
<field name="name">mc.fee.category.list</field>
<field name="model">mc.fee.category</field>
<field name="arch" type="xml">
<list string="Fee Categories" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
</list>
</field>
</record>
<record id="action_mc_fee_category" model="ir.actions.act_window">
<field name="name">Fee Categories</field>
<field name="res_model">mc.fee.category</field>
<field name="view_mode">list</field>
</record>
</odoo>

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_fee_concession_list" model="ir.ui.view">
<field name="name">mc.fee.concession.list</field>
<field name="model">mc.fee.concession</field>
<field name="arch" type="xml">
<list string="Fee Concessions">
<field name="student_id"/>
<field name="concession_type"/>
<field name="computation"/>
<field name="value"/>
<field name="reason"/>
<field name="approver_id"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_mc_fee_concession_form" model="ir.ui.view">
<field name="name">mc.fee.concession.form</field>
<field name="model">mc.fee.concession</field>
<field name="arch" type="xml">
<form string="Fee Concession">
<sheet>
<group>
<group>
<field name="student_id"/>
<field name="concession_type"/>
</group>
<group>
<field name="computation"/>
<field name="value"/>
<field name="approver_id"/>
<field name="active"/>
</group>
</group>
<group>
<field name="reason"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_mc_fee_concession" model="ir.actions.act_window">
<field name="name">Fee Concessions</field>
<field name="res_model">mc.fee.concession</field>
<field name="view_mode">list,form</field>
<field name="context">{'active_test': False}</field>
</record>
</odoo>

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_fee_invoice_generate_wizard_form" model="ir.ui.view">
<field name="name">mc.fee.invoice.generate.wizard.form</field>
<field name="model">mc.fee.invoice.generate.wizard</field>
<field name="arch" type="xml">
<form string="Generate Fee Invoice">
<group>
<field name="enrollment_id" invisible="1"/>
<field name="year_id" invisible="1"/>
<field name="term_id"/>
</group>
<footer>
<button name="action_generate" string="Generate Invoice" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_fee_schedule_list" model="ir.ui.view">
<field name="name">mc.fee.schedule.list</field>
<field name="model">mc.fee.schedule</field>
<field name="arch" type="xml">
<list string="Fee Schedules">
<field name="structure_id"/>
</list>
</field>
</record>
<record id="view_mc_fee_schedule_form" model="ir.ui.view">
<field name="name">mc.fee.schedule.form</field>
<field name="model">mc.fee.schedule</field>
<field name="arch" type="xml">
<form string="Fee Schedule">
<sheet>
<group>
<field name="structure_id"/>
</group>
<field name="line_ids">
<list editable="bottom">
<field name="term_id"/>
<field name="due_date"/>
<field name="percentage"/>
</list>
</field>
</sheet>
</form>
</field>
</record>
<record id="action_mc_fee_schedule" model="ir.actions.act_window">
<field name="name">Fee Schedules</field>
<field name="res_model">mc.fee.schedule</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mc_fee_structure_list" model="ir.ui.view">
<field name="name">mc.fee.structure.list</field>
<field name="model">mc.fee.structure</field>
<field name="arch" type="xml">
<list string="Fee Structures">
<field name="year_id"/>
<field name="program_id"/>
<field name="total_amount" sum="Total"/>
</list>
</field>
</record>
<record id="view_mc_fee_structure_form" model="ir.ui.view">
<field name="name">mc.fee.structure.form</field>
<field name="model">mc.fee.structure</field>
<field name="arch" type="xml">
<form string="Fee Structure">
<sheet>
<group>
<group>
<field name="program_id"/>
<field name="year_id"/>
</group>
<group>
<field name="total_amount"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
<notebook>
<page string="Fee Lines">
<field name="line_ids">
<list editable="bottom">
<field name="category_id"/>
<field name="amount"/>
<field name="currency_id" column_invisible="1"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_mc_fee_structure" model="ir.actions.act_window">
<field name="name">Fee Structures</field>
<field name="res_model">mc.fee.structure</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@ -0,0 +1 @@
from . import mc_fee_invoice_generate_wizard

View File

@ -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",
}