360 lines
14 KiB
Python
360 lines
14 KiB
Python
import datetime
|
|
from odoo import fields
|
|
from odoo.tests.common import TransactionCase
|
|
from odoo.exceptions import ValidationError, AccessError
|
|
|
|
|
|
class TestStudentFinance(TransactionCase):
|
|
|
|
def setUp(self):
|
|
super(TestStudentFinance, self).setUp()
|
|
|
|
# Base settings
|
|
self.company = self.env.ref('base.main_company')
|
|
|
|
# Setup master data
|
|
self.campus = self.env['education.campus'].create({
|
|
'name': 'Finance Test Campus',
|
|
'code': 'FTC',
|
|
})
|
|
self.department = self.env['education.department'].create({
|
|
'name': 'Finance Test Dept',
|
|
'code': 'FTD',
|
|
'campus_id': self.campus.id,
|
|
})
|
|
self.level = self.env['education.academic.level'].create({
|
|
'name': 'Finance Test Level',
|
|
'code': 'FTL',
|
|
})
|
|
self.program = self.env['education.program'].create({
|
|
'name': 'Finance Test Program',
|
|
'code': 'FTP',
|
|
'department_id': self.department.id,
|
|
'level_id': self.level.id,
|
|
})
|
|
self.year = self.env['education.academic.year'].create({
|
|
'name': 'Finance Test Year',
|
|
'code': 'FTY26',
|
|
'start_date': '2026-06-01',
|
|
'end_date': '2027-05-31',
|
|
})
|
|
self.term = self.env['education.academic.term'].create({
|
|
'name': 'Finance Test Term 1',
|
|
'code': 'FTT1',
|
|
'academic_year_id': self.year.id,
|
|
'start_date': '2026-06-01',
|
|
'end_date': '2026-12-31',
|
|
})
|
|
self.batch = self.env['education.batch'].create({
|
|
'name': 'Finance Test Batch 2026',
|
|
'code': 'FTB26',
|
|
})
|
|
self.semester = self.env['education.semester'].create({
|
|
'name': 'Finance Test Semester 1',
|
|
'code': 'FTS1',
|
|
})
|
|
self.section = self.env['education.section'].create({
|
|
'name': 'Finance Test Section A',
|
|
'code': 'FTSA',
|
|
})
|
|
|
|
# Create student
|
|
self.student = self.env['education.student'].create({
|
|
'first_name': 'John',
|
|
'last_name': 'Doe',
|
|
'campus_id': self.campus.id,
|
|
'department_id': self.department.id,
|
|
'program_id': self.program.id,
|
|
'level_id': self.level.id,
|
|
'academic_year_id': self.year.id,
|
|
'academic_term_id': self.term.id,
|
|
'batch_id': self.batch.id,
|
|
'semester_id': self.semester.id,
|
|
'section_id': self.section.id,
|
|
})
|
|
|
|
# Create Fee Config
|
|
self.category = self.env['education.fee.category'].create({
|
|
'name': 'Finance Test Tuition Category',
|
|
'code': 'FTUT',
|
|
})
|
|
self.fee_type = self.env['education.fee.type'].create({
|
|
'name': 'Finance Test Tuition Fee',
|
|
'category_id': self.category.id,
|
|
'amount': 1000.0,
|
|
})
|
|
self.fee_type_lab = self.env['education.fee.type'].create({
|
|
'name': 'Finance Test Lab Fee',
|
|
'category_id': self.category.id,
|
|
'amount': 500.0,
|
|
})
|
|
self.fee_structure = self.env['education.fee.structure'].create({
|
|
'name': 'Finance Test Year 1 Structure',
|
|
'academic_year_id': self.year.id,
|
|
'level_id': self.level.id,
|
|
})
|
|
self.fee_line = self.env['education.fee.structure.line'].create({
|
|
'fee_structure_id': self.fee_structure.id,
|
|
'fee_type_id': self.fee_type.id,
|
|
'amount': 1000.0,
|
|
})
|
|
self.fee_line_lab = self.env['education.fee.structure.line'].create({
|
|
'fee_structure_id': self.fee_structure.id,
|
|
'fee_type_id': self.fee_type_lab.id,
|
|
'amount': 500.0,
|
|
})
|
|
|
|
def test_fee_assignment_and_ledger(self):
|
|
"""Test assigning fees to a student creates ledger debits and updates metrics"""
|
|
assignment = self.env['education.fee.assignment'].create({
|
|
'student_id': self.student.id,
|
|
'academic_year_id': self.year.id,
|
|
'semester_id': self.semester.id,
|
|
'fee_structure_id': self.fee_structure.id,
|
|
})
|
|
|
|
# Verify initial metrics
|
|
self.assertEqual(self.student.outstanding_amount, 0.0)
|
|
|
|
# Confirm Assignment
|
|
assignment.action_confirm()
|
|
self.student.invalidate_recordset()
|
|
|
|
# Ledger entry verification
|
|
ledger_lines = self.env['education.student.ledger'].search([('student_id', '=', self.student.id)], order='date asc, id asc')
|
|
# We have 2 lines from the structure
|
|
self.assertEqual(len(ledger_lines), 2)
|
|
self.assertEqual(ledger_lines[0].debit, 1000.0)
|
|
self.assertEqual(ledger_lines[1].debit, 500.0)
|
|
|
|
# Verify running balance
|
|
self.assertEqual(ledger_lines[0].balance, 1000.0)
|
|
self.assertEqual(ledger_lines[1].balance, 1500.0)
|
|
|
|
# Financial metrics check
|
|
self.assertEqual(self.student.total_fees, 1500.0)
|
|
self.assertEqual(self.student.outstanding_amount, 1500.0)
|
|
self.assertEqual(self.student.payment_status, 'overdue')
|
|
|
|
def test_partial_and_multiple_payments(self):
|
|
"""Test registering partial and multiple payments updates outstanding dues and running balance"""
|
|
assignment = self.env['education.fee.assignment'].create({
|
|
'student_id': self.student.id,
|
|
'academic_year_id': self.year.id,
|
|
'semester_id': self.semester.id,
|
|
'fee_structure_id': self.fee_structure.id,
|
|
})
|
|
assignment.action_confirm()
|
|
|
|
# First payment: partial (500.0)
|
|
payment1 = self.env['education.student.payment'].create({
|
|
'student_id': self.student.id,
|
|
'amount': 500.0,
|
|
'payment_method': 'cash',
|
|
'receipt_number': 'TEST-PARTIAL-1',
|
|
})
|
|
payment1.action_confirm()
|
|
self.student.invalidate_recordset()
|
|
|
|
self.assertEqual(self.student.total_paid, 500.0)
|
|
self.assertEqual(self.student.outstanding_amount, 1000.0)
|
|
self.assertEqual(self.student.payment_status, 'partial')
|
|
|
|
# Verify running balances
|
|
ledger_lines = self.env['education.student.ledger'].search([('student_id', '=', self.student.id)], order='date asc, id asc')
|
|
# 2 debit lines + 1 credit line = 3 lines total
|
|
self.assertEqual(len(ledger_lines), 3)
|
|
self.assertEqual(ledger_lines[2].balance, 1000.0)
|
|
|
|
# Second payment: full remaining (1000.0)
|
|
payment2 = self.env['education.student.payment'].create({
|
|
'student_id': self.student.id,
|
|
'amount': 1000.0,
|
|
'payment_method': 'online',
|
|
'receipt_number': 'TEST-PARTIAL-2',
|
|
})
|
|
payment2.action_confirm()
|
|
self.student.invalidate_recordset()
|
|
|
|
self.assertEqual(self.student.total_paid, 1500.0)
|
|
self.assertEqual(self.student.outstanding_amount, 0.0)
|
|
self.assertEqual(self.student.payment_status, 'fully_paid')
|
|
|
|
ledger_lines_all = self.env['education.student.ledger'].search([('student_id', '=', self.student.id)], order='date asc, id asc')
|
|
self.assertEqual(len(ledger_lines_all), 4)
|
|
self.assertEqual(ledger_lines_all[3].balance, 0.0)
|
|
|
|
def test_overpayment_validation(self):
|
|
"""Test that paying more than outstanding raises validation error"""
|
|
assignment = self.env['education.fee.assignment'].create({
|
|
'student_id': self.student.id,
|
|
'academic_year_id': self.year.id,
|
|
'semester_id': self.semester.id,
|
|
'fee_structure_id': self.fee_structure.id,
|
|
})
|
|
assignment.action_confirm()
|
|
self.student.invalidate_recordset()
|
|
|
|
payment = self.env['education.student.payment'].create({
|
|
'student_id': self.student.id,
|
|
'amount': 2000.0,
|
|
'payment_method': 'cash',
|
|
'receipt_number': 'TEST-OVERPAY',
|
|
})
|
|
|
|
with self.assertRaises(ValidationError):
|
|
payment.action_confirm()
|
|
|
|
def test_scholarships_and_discounts(self):
|
|
"""Test applying discounts and scholarships reduces dues and updates ledger running balance"""
|
|
assignment = self.env['education.fee.assignment'].create({
|
|
'student_id': self.student.id,
|
|
'academic_year_id': self.year.id,
|
|
'semester_id': self.semester.id,
|
|
'fee_structure_id': self.fee_structure.id,
|
|
})
|
|
assignment.action_confirm()
|
|
|
|
# Apply Discount
|
|
discount = self.env['education.student.discount'].create({
|
|
'student_id': self.student.id,
|
|
'type': 'fixed',
|
|
'amount': 200.0,
|
|
'reason': 'Merit Discount',
|
|
})
|
|
discount.action_confirm()
|
|
self.student.invalidate_recordset()
|
|
self.assertEqual(self.student.outstanding_amount, 1300.0)
|
|
|
|
# Apply Scholarship
|
|
scholarship = self.env['education.student.scholarship'].create({
|
|
'student_id': self.student.id,
|
|
'scholarship_name': 'Sponsor Grant',
|
|
'type': 'percentage',
|
|
'amount': 10.0, # 10% of 1300 = 130
|
|
})
|
|
scholarship.action_confirm()
|
|
self.student.invalidate_recordset()
|
|
self.assertEqual(self.student.outstanding_amount, 1170.0)
|
|
|
|
def test_refunds(self):
|
|
"""Test refund creates outstanding ledger entries to increase the running balance"""
|
|
assignment = self.env['education.fee.assignment'].create({
|
|
'student_id': self.student.id,
|
|
'academic_year_id': self.year.id,
|
|
'semester_id': self.semester.id,
|
|
'fee_structure_id': self.fee_structure.id,
|
|
})
|
|
assignment.action_confirm()
|
|
|
|
payment = self.env['education.student.payment'].create({
|
|
'student_id': self.student.id,
|
|
'amount': 1000.0,
|
|
'payment_method': 'cash',
|
|
'receipt_number': 'TEST-REFUND-PAY',
|
|
})
|
|
payment.action_confirm()
|
|
self.student.invalidate_recordset()
|
|
self.assertEqual(self.student.outstanding_amount, 500.0)
|
|
|
|
# Try to refund more than paid
|
|
with self.assertRaises(ValidationError):
|
|
self.env['education.student.refund'].create({
|
|
'payment_id': payment.id,
|
|
'amount': 1200.0,
|
|
'reason': 'Accidental Over-Refund',
|
|
})
|
|
|
|
# Valid refund of 500
|
|
refund_pass = self.env['education.student.refund'].create({
|
|
'payment_id': payment.id,
|
|
'amount': 500.0,
|
|
'reason': 'Course Withdrawal Partial',
|
|
})
|
|
refund_pass.action_approve()
|
|
self.student.invalidate_recordset()
|
|
|
|
# Outstanding should increase by 500 (from 500 to 1000)
|
|
self.assertEqual(self.student.outstanding_amount, 1000.0)
|
|
|
|
def test_fee_structure_and_invoice_lifecycle(self):
|
|
"""Test fee structure total amount, invoice auto-numbering, due date, and payment status"""
|
|
today = fields.Date.today()
|
|
# Verify fee structure total amount
|
|
self.assertEqual(self.fee_structure.total_amount, 1500.0)
|
|
|
|
# Create fee assignment (invoice)
|
|
assignment = self.env['education.fee.assignment'].create({
|
|
'student_id': self.student.id,
|
|
'academic_year_id': self.year.id,
|
|
'semester_id': self.semester.id,
|
|
'fee_structure_id': self.fee_structure.id,
|
|
'assignment_date': today,
|
|
'due_date': today + datetime.timedelta(days=15),
|
|
})
|
|
|
|
self.assertIsNotNone(assignment.name)
|
|
self.assertEqual(assignment.amount, 1500.0)
|
|
self.assertEqual(assignment.amount_paid, 0.0)
|
|
self.assertEqual(assignment.amount_due, 1500.0)
|
|
self.assertEqual(assignment.payment_status, 'unpaid')
|
|
|
|
# Confirm invoice
|
|
assignment.action_confirm()
|
|
self.assertEqual(assignment.state, 'confirmed')
|
|
self.assertEqual(assignment.payment_status, 'unpaid')
|
|
|
|
# Prevent duplicate assignment for same student, year, semester, and structure
|
|
with self.assertRaises(ValidationError):
|
|
self.env['education.fee.assignment'].create({
|
|
'student_id': self.student.id,
|
|
'academic_year_id': self.year.id,
|
|
'semester_id': self.semester.id,
|
|
'fee_structure_id': self.fee_structure.id,
|
|
})
|
|
|
|
# Register partial payment linked to assignment
|
|
payment = self.env['education.student.payment'].create({
|
|
'student_id': self.student.id,
|
|
'fee_assignment_id': assignment.id,
|
|
'amount': 600.0,
|
|
'payment_method': 'card',
|
|
'receipt_number': 'REC-TEST-PART-600',
|
|
})
|
|
payment.action_confirm()
|
|
|
|
assignment.invalidate_recordset()
|
|
self.assertEqual(assignment.amount_paid, 600.0)
|
|
self.assertEqual(assignment.amount_due, 900.0)
|
|
self.assertEqual(assignment.payment_status, 'partial')
|
|
|
|
# Simulate overdue state by moving due_date to yesterday
|
|
assignment.due_date = today - datetime.timedelta(days=1)
|
|
assignment.invalidate_recordset()
|
|
self.assertEqual(assignment.payment_status, 'overdue')
|
|
|
|
# Check student metrics
|
|
self.student.invalidate_recordset()
|
|
self.assertEqual(self.student.outstanding_amount, 900.0)
|
|
self.assertEqual(self.student.overdue_amount, 900.0)
|
|
self.assertEqual(self.student.total_paid, 600.0)
|
|
|
|
# Pay remaining balance
|
|
payment2 = self.env['education.student.payment'].create({
|
|
'student_id': self.student.id,
|
|
'fee_assignment_id': assignment.id,
|
|
'amount': 900.0,
|
|
'payment_method': 'bank',
|
|
'receipt_number': 'REC-TEST-PART-900',
|
|
})
|
|
payment2.action_confirm()
|
|
|
|
assignment.invalidate_recordset()
|
|
self.assertEqual(assignment.amount_paid, 1500.0)
|
|
self.assertEqual(assignment.amount_due, 0.0)
|
|
self.assertEqual(assignment.payment_status, 'paid')
|
|
|
|
self.student.invalidate_recordset()
|
|
self.assertEqual(self.student.outstanding_amount, 0.0)
|
|
self.assertEqual(self.student.payment_status, 'fully_paid')
|