metatroncubeswdev d6f891838b 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>
2026-09-11 12:34:26 -04:00

65 lines
3.2 KiB
XML

<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_school" model="ir.module.category">
<field name="name">School Management</field>
<field name="sequence">20</field>
</record>
<!-- Odoo 19: res.groups no longer has category_id directly; groups are
grouped under a res.groups.privilege, which carries the category. -->
<record id="privilege_school" model="res.groups.privilege">
<field name="name">School Management</field>
<field name="category_id" ref="module_category_school"/>
<field name="sequence">20</field>
</record>
<!-- Internal (backend) roles -->
<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>
<record id="group_teacher" model="res.groups">
<field name="name">Teacher</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="implied_ids" eval="[(4, ref('group_school_staff'))]"/>
<field name="comment">Teaching staff: their own batches only, enforced by record rules in the modules that need it (attendance, exam).</field>
</record>
<record id="group_accountant" model="res.groups">
<field name="name">Accountant</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="implied_ids" eval="[(4, ref('group_school_staff'))]"/>
<field name="comment">Fees, invoices and payments.</field>
</record>
<record id="group_school_administrator" model="res.groups">
<field name="name">School Administrator</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="implied_ids" eval="[(4, ref('group_school_staff'))]"/>
<field name="comment">Full read/write on the academic structure: years, terms, programs, batches.</field>
<!-- Same convention core Odoo modules use (see hr.group_hr_manager):
the database's own Administrator account should not need a
manual group assignment just to see this module's menus after
install. -->
<field name="user_ids" eval="[(4, ref('base.user_admin'))]"/>
</record>
<!-- Portal roles -->
<record id="group_guardian" model="res.groups">
<field name="name">Guardian</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="implied_ids" eval="[(4, ref('base.group_portal'))]"/>
<field name="comment">Portal access is scoped by mc.student.guardian links, never by holding this group alone. See CLAUDE.md sec 3.</field>
</record>
<record id="group_student" model="res.groups">
<field name="name">Student</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="implied_ids" eval="[(4, ref('base.group_portal'))]"/>
<field name="comment">Portal access to the student's own record only, enforced by record rules.</field>
</record>
</odoo>