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

75 lines
3.0 KiB
Python

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