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:
parent
508bf27417
commit
d6f891838b
@ -109,6 +109,10 @@ that being true, not staged.
|
|||||||
`privilege_id`, and the privilege carries `category_id` (still an `ir.module.category`).
|
`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
|
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.
|
`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`, `_description` and `_order` on every model. `_rec_name` where the display field is not
|
||||||
`name`.
|
`name`.
|
||||||
- Computed fields declare `@api.depends` accurately and are `store=True` only when they need to be
|
- Computed fields declare `@api.depends` accurately and are `store=True` only when they need to be
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
<record id="group_school_staff" model="res.groups">
|
<record id="group_school_staff" model="res.groups">
|
||||||
<field name="name">School Staff</field>
|
<field name="name">School Staff</field>
|
||||||
<field name="privilege_id" ref="privilege_school"/>
|
<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>
|
<field name="comment">Front office and general staff: read access to academic structure and student records.</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
|||||||
2
addons/mc_education_fees/__init__.py
Normal file
2
addons/mc_education_fees/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from . import models
|
||||||
|
from . import wizards
|
||||||
33
addons/mc_education_fees/__manifest__.py
Normal file
33
addons/mc_education_fees/__manifest__.py
Normal 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,
|
||||||
|
}
|
||||||
21
addons/mc_education_fees/data/product_data.xml
Normal file
21
addons/mc_education_fees/data/product_data.xml
Normal 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>
|
||||||
23
addons/mc_education_fees/demo/mc_fee_category_demo.xml
Normal file
23
addons/mc_education_fees/demo/mc_fee_category_demo.xml
Normal 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>
|
||||||
14
addons/mc_education_fees/demo/mc_fee_concession_demo.xml
Normal file
14
addons/mc_education_fees/demo/mc_fee_concession_demo.xml
Normal 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>
|
||||||
46
addons/mc_education_fees/demo/mc_fee_schedule_demo.xml
Normal file
46
addons/mc_education_fees/demo/mc_fee_schedule_demo.xml
Normal 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>
|
||||||
62
addons/mc_education_fees/demo/mc_fee_structure_demo.xml
Normal file
62
addons/mc_education_fees/demo/mc_fee_structure_demo.xml
Normal 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>
|
||||||
6
addons/mc_education_fees/models/__init__.py
Normal file
6
addons/mc_education_fees/models/__init__.py
Normal 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
|
||||||
8
addons/mc_education_fees/models/account_move.py
Normal file
8
addons/mc_education_fees/models/account_move.py
Normal 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")
|
||||||
17
addons/mc_education_fees/models/mc_enrollment.py
Normal file
17
addons/mc_education_fees/models/mc_enrollment.py
Normal 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},
|
||||||
|
}
|
||||||
16
addons/mc_education_fees/models/mc_fee_category.py
Normal file
16
addons/mc_education_fees/models/mc_fee_category.py
Normal 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.",
|
||||||
|
)
|
||||||
50
addons/mc_education_fees/models/mc_fee_concession.py
Normal file
50
addons/mc_education_fees/models/mc_fee_concession.py
Normal 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.")
|
||||||
74
addons/mc_education_fees/models/mc_fee_schedule.py
Normal file
74
addons/mc_education_fees/models/mc_fee_schedule.py
Normal 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,
|
||||||
|
)
|
||||||
|
)
|
||||||
59
addons/mc_education_fees/models/mc_fee_structure.py
Normal file
59
addons/mc_education_fees/models/mc_fee_structure.py
Normal 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.",
|
||||||
|
)
|
||||||
21
addons/mc_education_fees/security/ir.model.access.csv
Normal file
21
addons/mc_education_fees/security/ir.model.access.csv
Normal 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
|
||||||
|
3
addons/mc_education_fees/tests/__init__.py
Normal file
3
addons/mc_education_fees/tests/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from . import test_fee_structure
|
||||||
|
from . import test_fee_invoice
|
||||||
|
from . import test_access
|
||||||
92
addons/mc_education_fees/tests/test_access.py
Normal file
92
addons/mc_education_fees/tests/test_access.py
Normal 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,
|
||||||
|
})
|
||||||
156
addons/mc_education_fees/tests/test_fee_invoice.py
Normal file
156
addons/mc_education_fees/tests/test_fee_invoice.py
Normal 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()
|
||||||
85
addons/mc_education_fees/tests/test_fee_structure.py
Normal file
85
addons/mc_education_fees/tests/test_fee_structure.py
Normal 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,
|
||||||
|
})
|
||||||
41
addons/mc_education_fees/views/account_move_views.xml
Normal file
41
addons/mc_education_fees/views/account_move_views.xml
Normal 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>
|
||||||
21
addons/mc_education_fees/views/mc_education_fees_menus.xml
Normal file
21
addons/mc_education_fees/views/mc_education_fees_menus.xml
Normal 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>
|
||||||
19
addons/mc_education_fees/views/mc_fee_category_views.xml
Normal file
19
addons/mc_education_fees/views/mc_fee_category_views.xml
Normal 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>
|
||||||
52
addons/mc_education_fees/views/mc_fee_concession_views.xml
Normal file
52
addons/mc_education_fees/views/mc_fee_concession_views.xml
Normal 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>
|
||||||
@ -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>
|
||||||
39
addons/mc_education_fees/views/mc_fee_schedule_views.xml
Normal file
39
addons/mc_education_fees/views/mc_fee_schedule_views.xml
Normal 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>
|
||||||
53
addons/mc_education_fees/views/mc_fee_structure_views.xml
Normal file
53
addons/mc_education_fees/views/mc_fee_structure_views.xml
Normal 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>
|
||||||
1
addons/mc_education_fees/wizards/__init__.py
Normal file
1
addons/mc_education_fees/wizards/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import mc_fee_invoice_generate_wizard
|
||||||
@ -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",
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user