feat(education_finance): integrate student fees and payment tracking

This commit is contained in:
Vidhya 2026-10-14 00:12:25 +05:30
parent bce010fcb8
commit ff2d7903b6
23 changed files with 2667 additions and 45 deletions

View File

@ -97,7 +97,7 @@ def generate_demo_data(env):
if system_group: admin_groups.append(system_group.id)
if admin_groups:
admin_user.sudo().write({
'groups_id': [(4, g) for g in admin_groups],
'group_ids': [(4, g) for g in admin_groups],
'company_id': company.id,
'company_ids': [(6, 0, [company.id])]
})
@ -114,26 +114,25 @@ def generate_demo_data(env):
if lms_faculty_grp: faculty_groups.append(lms_faculty_grp.id)
faculty_user.sudo().write({
'groups_id': [(4, g) for g in faculty_groups],
'group_ids': [(4, g) for g in faculty_groups],
'company_id': company.id,
'company_ids': [(6, 0, [company.id])]
})
if student_user and portal_group:
if student_user and portal_group and not student_user.has_group('base.group_user'):
student_user.sudo().write({
'groups_id': [(4, portal_group.id)],
'group_ids': [(4, portal_group.id)],
'company_id': company.id,
'company_ids': [(6, 0, [company.id])]
})
# 5. Create Student Record
if student_user:
student = env['education.student'].search([('partner_id', '=', student_user.partner_id.id)], limit=1)
student = env['education.student'].search([('portal_user_id', '=', student_user.id)], limit=1)
if not student:
student = env['education.student'].create({
'first_name': 'Demo',
'last_name': 'Student',
'partner_id': student_user.partner_id.id,
'program_id': program.id,
'campus_id': campus.id,
'academic_year_id': year.id,
@ -308,5 +307,78 @@ def generate_demo_data(env):
'company_id': company.id,
})
# 13. Student Fees & Finance
fee_cat = env['education.fee.category'].search([('code', '=', 'DEMO-ACAD')], limit=1)
if not fee_cat:
fee_cat = env['education.fee.category'].create({
'name': 'Academic & Tuition',
'code': 'DEMO-ACAD',
'company_id': company.id,
})
fee_type_tuition = env['education.fee.type'].search([('name', '=', 'Semester Tuition Fee (Demo)')], limit=1)
if not fee_type_tuition:
fee_type_tuition = env['education.fee.type'].create({
'name': 'Semester Tuition Fee (Demo)',
'category_id': fee_cat.id,
'amount': 25000.0,
'company_id': company.id,
})
fee_type_lab = env['education.fee.type'].search([('name', '=', 'Computing Lab & Facilities (Demo)')], limit=1)
if not fee_type_lab:
fee_type_lab = env['education.fee.type'].create({
'name': 'Computing Lab & Facilities (Demo)',
'category_id': fee_cat.id,
'amount': 5000.0,
'company_id': company.id,
})
fee_struct = env['education.fee.structure'].search([('name', '=', 'B.Tech CS - Sem 1 Fee Structure (Demo)')], limit=1)
if not fee_struct:
fee_struct = env['education.fee.structure'].create({
'name': 'B.Tech CS - Sem 1 Fee Structure (Demo)',
'academic_year_id': year.id,
'level_id': level.id,
'program_id': program.id,
'semester_id': semester.id,
'company_id': company.id,
'due_days': 30,
'line_ids': [
(0, 0, {'fee_type_id': fee_type_tuition.id, 'amount': 25000.0}),
(0, 0, {'fee_type_id': fee_type_lab.id, 'amount': 5000.0}),
]
})
if student and fee_struct:
assignment = env['education.fee.assignment'].search([
('student_id', '=', student.id),
('fee_structure_id', '=', fee_struct.id),
], limit=1)
if not assignment:
assignment = env['education.fee.assignment'].create({
'student_id': student.id,
'academic_year_id': year.id,
'semester_id': semester.id,
'fee_structure_id': fee_struct.id,
'assignment_date': today - datetime.timedelta(days=20),
'due_date': today + datetime.timedelta(days=10),
'company_id': company.id,
})
assignment.action_confirm()
# Record a partial payment
payment = env['education.student.payment'].create({
'student_id': student.id,
'fee_assignment_id': assignment.id,
'amount': 10000.0,
'payment_date': today - datetime.timedelta(days=5),
'payment_method': 'online',
'reference_number': 'DEMO-PAY-001',
'remarks': 'Partial Online Fee Payment',
'company_id': company.id,
})
payment.action_confirm()
env.cr.commit()

View File

@ -9,4 +9,13 @@
<field name="number_increment">1</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_education_fee_assignment" model="ir.sequence">
<field name="name">Fee Assignment / Invoice Sequence</field>
<field name="code">education.fee.assignment</field>
<field name="prefix">INV/%(year)s/</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@ -1,4 +1,5 @@
# pyrefly: ignore [missing-import]
import datetime
from odoo import api, fields, models
# pyrefly: ignore [missing-import]
from odoo.exceptions import ValidationError
@ -6,18 +7,55 @@ from odoo.exceptions import ValidationError
class EducationFeeAssignment(models.Model):
_name = 'education.fee.assignment'
_description = 'Student Fee Assignment'
_order = 'assignment_date desc'
_description = 'Student Fee Invoice / Assignment'
_order = 'assignment_date desc, id desc'
name = fields.Char(string='Invoice Number', copy=False, readonly=True, default='/', index=True)
student_id = fields.Many2one('education.student', string='Student', required=True, index=True)
academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True, index=True)
academic_term_id = fields.Many2one('education.academic.term', string='Academic Term', index=True)
semester_id = fields.Many2one('education.semester', string='Semester', required=True, index=True)
fee_structure_id = fields.Many2one('education.fee.structure', string='Fee Structure', required=True, index=True)
assignment_date = fields.Date(string='Assignment Date', required=True, default=fields.Date.context_today)
due_date = fields.Date(
string='Due Date',
required=True,
default=fields.Date.context_today,
index=True
)
state = fields.Selection([
('draft', 'Draft'),
('confirmed', 'Confirmed'),
], string='Status', default='draft', required=True, index=True)
currency_id = fields.Many2one('res.currency', related='fee_structure_id.currency_id', store=True)
line_ids = fields.One2many(related='fee_structure_id.line_ids', string='Fee Lines', readonly=True)
payment_ids = fields.One2many('education.student.payment', 'fee_assignment_id', string='Payments')
amount = fields.Monetary(
string='Total Invoiced Amount',
compute='_compute_amounts',
store=True,
currency_field='currency_id'
)
amount_paid = fields.Monetary(
string='Amount Paid',
compute='_compute_amounts',
store=True,
currency_field='currency_id'
)
amount_due = fields.Monetary(
string='Amount Due',
compute='_compute_amounts',
store=True,
currency_field='currency_id'
)
payment_status = fields.Selection([
('unpaid', 'Unpaid'),
('partial', 'Partially Paid'),
('paid', 'Paid'),
('overdue', 'Overdue'),
], string='Payment Status', compute='_compute_amounts', store=True, index=True)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company',
@ -27,9 +65,60 @@ class EducationFeeAssignment(models.Model):
index=True,
)
_sql_constraints = [
('uniq_assignment', 'unique(student_id, academic_year_id, semester_id, fee_structure_id, company_id)', 'This fee structure is already assigned to this student for this semester.'),
]
@api.onchange('fee_structure_id', 'assignment_date')
def _onchange_fee_structure(self):
if self.fee_structure_id and self.assignment_date:
days = getattr(self.fee_structure_id, 'due_days', 30) or 30
self.due_date = self.assignment_date + datetime.timedelta(days=days)
@api.constrains('student_id', 'academic_year_id', 'semester_id', 'fee_structure_id', 'company_id')
def _check_unique_assignment(self):
for rec in self:
domain = [
('id', '!=', rec.id),
('student_id', '=', rec.student_id.id),
('academic_year_id', '=', rec.academic_year_id.id),
('semester_id', '=', rec.semester_id.id),
('fee_structure_id', '=', rec.fee_structure_id.id),
('company_id', '=', rec.company_id.id),
('active', '=', True),
]
if self.search_count(domain) > 0:
raise ValidationError("This fee structure is already assigned to this student for this semester.")
@api.depends(
'fee_structure_id.line_ids.amount',
'payment_ids.amount',
'payment_ids.status',
'due_date',
'state'
)
def _compute_amounts(self):
today = fields.Date.today()
for rec in self:
total = sum(line.amount for line in rec.fee_structure_id.line_ids) if rec.fee_structure_id else 0.0
rec.amount = total
confirmed_payments = rec.payment_ids.filtered(lambda p: p.status == 'confirmed')
paid = sum(p.amount for p in confirmed_payments)
# Check if student's overall dues are fully cleared and no specific allocation was attached
if paid == 0 and total > 0 and rec.student_id.outstanding_amount == 0 and rec.student_id.total_paid >= total:
paid = total
rec.amount_paid = paid
rec.amount_due = max(0.0, total - paid)
if rec.state != 'confirmed':
rec.payment_status = 'unpaid'
elif total == 0 or rec.amount_due <= 0:
rec.payment_status = 'paid'
elif rec.due_date and rec.due_date < today:
rec.payment_status = 'overdue'
elif paid > 0:
rec.payment_status = 'partial'
else:
rec.payment_status = 'unpaid'
def action_confirm(self):
for rec in self:
@ -37,6 +126,11 @@ class EducationFeeAssignment(models.Model):
continue
if not rec.fee_structure_id.line_ids:
raise ValidationError(f"The selected fee structure '{rec.fee_structure_id.name}' has no fee lines. Please add fee lines to this structure before confirming.")
if not rec.name or rec.name == '/':
seq = self.env['ir.sequence'].next_by_code('education.fee.assignment')
rec.name = seq or f"INV/{rec.id:05d}"
for line in rec.fee_structure_id.line_ids:
self.env['education.student.ledger'].create({
'student_id': rec.student_id.id,
@ -45,8 +139,8 @@ class EducationFeeAssignment(models.Model):
'category_id': line.fee_type_id.category_id.id,
'fee_type_id': line.fee_type_id.id,
'date': rec.assignment_date,
'ref': f"FEE/{rec.id}",
'description': f"{line.fee_type_id.name} (Assigned via {rec.fee_structure_id.name})",
'ref': rec.name,
'description': f"{line.fee_type_id.name} (Invoice: {rec.name}, Structure: {rec.fee_structure_id.name})",
'debit': line.amount,
'credit': 0.0,
'status': 'outstanding',
@ -59,7 +153,16 @@ class EducationFeeAssignment(models.Model):
if rec.state == 'confirmed':
ledger_entries = self.env['education.student.ledger'].search([
('student_id', '=', rec.student_id.id),
('ref', '=', f"FEE/{rec.id}"),
('ref', '=', rec.name),
])
ledger_entries.unlink()
rec.state = 'draft'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == '/':
vals['name'] = self.env['ir.sequence'].next_by_code('education.fee.assignment') or '/'
if not vals.get('due_date') and vals.get('assignment_date'):
vals['due_date'] = fields.Date.from_string(vals['assignment_date']) + datetime.timedelta(days=30)
return super().create(vals_list)

View File

@ -15,6 +15,9 @@ class EducationFeeStructure(models.Model):
program_id = fields.Many2one('education.program', string='Program', index=True)
department_id = fields.Many2one('education.department', string='Department', index=True)
semester_id = fields.Many2one('education.semester', string='Semester', index=True)
academic_term_id = fields.Many2one('education.academic.term', string='Academic Term', index=True)
batch_id = fields.Many2one('education.batch', string='Batch', index=True)
due_days = fields.Integer(string='Due Days from Assignment', default=30)
currency_id = fields.Many2one(
'res.currency',
string='Currency',
@ -22,6 +25,12 @@ class EducationFeeStructure(models.Model):
default=lambda self: self.env.company.currency_id,
)
line_ids = fields.One2many('education.fee.structure.line', 'fee_structure_id', string='Fee Lines', copy=True)
total_amount = fields.Monetary(
string='Total Amount',
compute='_compute_total_amount',
currency_field='currency_id',
store=True,
)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company',
@ -31,6 +40,11 @@ class EducationFeeStructure(models.Model):
index=True,
)
@api.depends('line_ids.amount')
def _compute_total_amount(self):
for rec in self:
rec.total_amount = sum(line.amount for line in rec.line_ids)
class EducationFeeStructureLine(models.Model):
_name = 'education.fee.structure.line'

View File

@ -7,12 +7,15 @@ class EducationStudent(models.Model):
ledger_line_ids = fields.One2many('education.student.ledger', 'student_id', string='Ledger Lines')
payment_ids = fields.One2many('education.student.payment', 'student_id', string='Payments')
fee_assignment_ids = fields.One2many('education.fee.assignment', 'student_id', string='Fee Invoices')
currency_id = fields.Many2one('res.currency', string='Currency', related='company_id.currency_id', readonly=True, store=True)
outstanding_amount = fields.Monetary(string='Outstanding Dues', compute='_compute_finance_metrics', currency_field='currency_id', store=True, index=True)
total_fees = fields.Monetary(string='Total Fees', compute='_compute_finance_metrics', currency_field='currency_id', store=True, index=True)
total_paid = fields.Monetary(string='Total Paid', compute='_compute_finance_metrics', currency_field='currency_id', store=True, index=True)
overdue_amount = fields.Monetary(string='Overdue Amount', compute='_compute_finance_metrics', currency_field='currency_id', store=True, index=True)
next_due_date = fields.Date(string='Next Due Date', compute='_compute_finance_metrics', store=True, index=True)
last_payment_date = fields.Date(string='Last Payment Date', compute='_compute_finance_metrics', store=True, index=True)
last_receipt_number = fields.Char(string='Last Receipt Number', compute='_compute_finance_metrics', store=True, index=True)
@ -23,8 +26,13 @@ class EducationStudent(models.Model):
('fully_paid', 'Fully Paid')
], string='Payment Status', compute='_compute_finance_metrics', default='no_dues', store=True, index=True)
@api.depends('ledger_line_ids.debit', 'ledger_line_ids.credit', 'ledger_line_ids.status', 'payment_ids.amount', 'payment_ids.status', 'payment_ids.payment_date', 'payment_ids.receipt_number')
@api.depends(
'ledger_line_ids.debit', 'ledger_line_ids.credit', 'ledger_line_ids.status',
'payment_ids.amount', 'payment_ids.status', 'payment_ids.payment_date', 'payment_ids.receipt_number',
'fee_assignment_ids.state', 'fee_assignment_ids.amount_due', 'fee_assignment_ids.due_date'
)
def _compute_finance_metrics(self):
today = fields.Date.today()
for rec in self:
active_ledgers = rec.ledger_line_ids.filtered(lambda l: l.status in ('outstanding', 'paid'))
total_debits = sum(line.debit for line in active_ledgers)
@ -33,16 +41,29 @@ class EducationStudent(models.Model):
confirmed_payments = rec.payment_ids.filtered(lambda p: p.status == 'confirmed').sorted(key=lambda p: (p.payment_date or fields.Date.today(), p.id), reverse=True)
rec.total_paid = sum(p.amount for p in confirmed_payments)
rec.outstanding_amount = total_debits - total_credits
rec.outstanding_amount = max(0.0, total_debits - total_credits)
last_payment = confirmed_payments[0] if confirmed_payments else False
rec.last_payment_date = last_payment.payment_date if last_payment else False
rec.last_receipt_number = last_payment.receipt_number if last_payment else False
overdue = 0.0
upcoming_due_dates = []
for a in rec.fee_assignment_ids.filtered(lambda x: x.state == 'confirmed' and x.amount_due > 0):
if a.due_date and a.due_date < today:
overdue += a.amount_due
elif a.due_date and a.due_date >= today:
upcoming_due_dates.append(a.due_date)
rec.overdue_amount = overdue
rec.next_due_date = min(upcoming_due_dates) if upcoming_due_dates else False
if total_debits == 0:
rec.payment_status = 'no_dues'
elif rec.outstanding_amount <= 0:
rec.payment_status = 'fully_paid'
elif overdue > 0:
rec.payment_status = 'overdue'
elif rec.total_paid > 0:
rec.payment_status = 'partial'
else:

View File

@ -12,6 +12,7 @@ class EducationStudentPayment(models.Model):
receipt_number = fields.Char(string='Receipt Number', required=True, copy=False, readonly=True, default='/', index=True)
student_id = fields.Many2one('education.student', string='Student', required=True, index=True)
fee_assignment_id = fields.Many2one('education.fee.assignment', string='Fee Invoice / Assignment', index=True)
payment_date = fields.Date(string='Payment Date', required=True, default=fields.Date.context_today)
payment_method = fields.Selection([
('cash', 'Cash'),

View File

@ -1,7 +1,7 @@
# pyrefly: ignore [missing-import]
import datetime
from odoo import fields
from odoo.tests.common import TransactionCase
# pyrefly: ignore [missing-import]
from odoo.exceptions import ValidationError
from odoo.exceptions import ValidationError, AccessError
class TestStudentFinance(TransactionCase):
@ -276,3 +276,84 @@ class TestStudentFinance(TransactionCase):
# 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')

View File

@ -4,12 +4,18 @@
<field name="name">education.fee.assignment.tree</field>
<field name="model">education.fee.assignment</field>
<field name="arch" type="xml">
<list string="Fee Assignments">
<list string="Fee Invoices &amp; Assignments">
<field name="name"/>
<field name="student_id"/>
<field name="academic_year_id"/>
<field name="semester_id"/>
<field name="fee_structure_id"/>
<field name="assignment_date"/>
<field name="due_date"/>
<field name="amount" sum="Total Invoiced"/>
<field name="amount_paid" sum="Total Paid"/>
<field name="amount_due" sum="Total Due"/>
<field name="payment_status" widget="badge" decoration-success="payment_status == 'paid'" decoration-warning="payment_status == 'partial'" decoration-danger="payment_status == 'overdue'" decoration-info="payment_status == 'unpaid'"/>
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-success="state == 'confirmed'"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
@ -20,25 +26,58 @@
<field name="name">education.fee.assignment.form</field>
<field name="model">education.fee.assignment</field>
<field name="arch" type="xml">
<form string="Fee Assignment">
<form string="Fee Invoice / Assignment">
<header>
<button name="action_confirm" string="Confirm" type="object" class="oe_highlight" invisible="state == 'confirmed'"/>
<button name="action_cancel" string="Cancel" type="object" invisible="state == 'draft'"/>
<button name="action_confirm" string="Confirm &amp; Post to Ledger" type="object" class="oe_highlight" invisible="state == 'confirmed'"/>
<button name="action_cancel" string="Cancel &amp; Reverse Ledger" type="object" invisible="state == 'draft'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed"/>
</header>
<sheet>
<div class="oe_title">
<label for="name" string="Invoice / Ref Number"/>
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<group string="Student &amp; Academic Scope">
<field name="student_id" readonly="state == 'confirmed'"/>
<field name="academic_year_id" readonly="state == 'confirmed'"/>
<field name="academic_term_id" readonly="state == 'confirmed'"/>
<field name="semester_id" readonly="state == 'confirmed'"/>
</group>
<group>
<group string="Schedule &amp; Financials">
<field name="fee_structure_id" readonly="state == 'confirmed'"/>
<field name="assignment_date" readonly="state == 'confirmed'"/>
<field name="due_date" readonly="state == 'confirmed'"/>
<field name="currency_id" invisible="1"/>
<field name="amount"/>
<field name="amount_paid"/>
<field name="amount_due"/>
<field name="payment_status" widget="badge" decoration-success="payment_status == 'paid'" decoration-warning="payment_status == 'partial'" decoration-danger="payment_status == 'overdue'" decoration-info="payment_status == 'unpaid'"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Fee Lines Breakdown" name="lines_page">
<field name="line_ids" readonly="1">
<list string="Fee Lines">
<field name="fee_type_id"/>
<field name="amount"/>
<field name="mandatory"/>
</list>
</field>
</page>
<page string="Payments Received" name="payments_page">
<field name="payment_ids" readonly="1">
<list string="Payments">
<field name="receipt_number"/>
<field name="payment_date"/>
<field name="payment_method"/>
<field name="amount"/>
<field name="status" widget="badge" decoration-success="status == 'confirmed'"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
@ -48,14 +87,20 @@
<field name="name">education.fee.assignment.search</field>
<field name="model">education.fee.assignment</field>
<field name="arch" type="xml">
<search string="Fee Assignments">
<search string="Fee Assignments &amp; Invoices">
<field name="name"/>
<field name="student_id"/>
<field name="fee_structure_id"/>
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
<separator/>
<filter string="Overdue" name="overdue" domain="[('payment_status', '=', 'overdue')]"/>
<filter string="Unpaid" name="unpaid" domain="[('payment_status', 'in', ('unpaid', 'partial'))]"/>
<filter string="Paid" name="paid" domain="[('payment_status', '=', 'paid')]"/>
<group>
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
<filter string="Fee Structure" name="group_structure" context="{'group_by': 'fee_structure_id'}"/>
<filter string="Payment Status" name="group_payment_status" context="{'group_by': 'payment_status'}"/>
<filter string="Status" name="group_status" context="{'group_by': 'state'}"/>
</group>
</search>
@ -63,9 +108,14 @@
</record>
<record id="action_education_fee_assignment" model="ir.actions.act_window">
<field name="name">Fee Assignments</field>
<field name="name">Fee Assignments &amp; Invoices</field>
<field name="res_model">education.fee.assignment</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_education_fee_assignment_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Assign fee structures and generate fee obligations for students.
</p>
</field>
</record>
</odoo>

View File

@ -246,7 +246,16 @@ class MobileStudent(http.Controller):
"today_timetable": self._get_timetable_slots(student, date_filter=today),
"upcoming_exams": exams,
"recent_results": results_data,
"announcements": announcements
"announcements": announcements,
"fees": {
"total_fees": float(student.total_fees),
"total_paid": float(student.total_paid),
"outstanding_balance": float(student.outstanding_amount),
"overdue_amount": float(student.overdue_amount),
"payment_status": student.payment_status or "no_dues",
"next_due_date": str(student.next_due_date) if student.next_due_date else None,
"currency_symbol": student.currency_id.symbol or student.currency_id.name or '₹'
}
}
return json_response(True, data=data)
except Exception as e:
@ -460,3 +469,224 @@ class MobileStudent(http.Controller):
})
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
@http.route('/api/mobile/v1/student/fees/summary', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_fee_summary(self, student, **kwargs):
try:
curr = student.currency_id or student.company_id.currency_id
data = {
"total_fees": float(student.total_fees),
"total_paid": float(student.total_paid),
"outstanding_balance": float(student.outstanding_amount),
"overdue_amount": float(student.overdue_amount),
"payment_status": student.payment_status or "no_dues",
"next_due_date": str(student.next_due_date) if student.next_due_date else None,
"last_payment_date": str(student.last_payment_date) if student.last_payment_date else None,
"last_receipt_number": student.last_receipt_number or None,
"currency": {
"id": curr.id if curr else 1,
"name": curr.name if curr else "INR",
"symbol": curr.symbol if curr else "₹"
}
}
return json_response(True, data=data)
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
@http.route('/api/mobile/v1/student/fees/invoices', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_fee_invoices(self, student, **kwargs):
try:
status_filter = kwargs.get('status')
page = int(kwargs.get('page', 1) or 1)
limit = int(kwargs.get('limit', 20) or 20)
if page < 1:
page = 1
if limit < 1 or limit > 100:
limit = 20
offset = (page - 1) * limit
domain = [
('student_id', '=', student.id),
('active', '=', True),
]
if student.company_id:
domain.append(('company_id', '=', student.company_id.id))
if status_filter in ['unpaid', 'partial', 'paid', 'overdue']:
domain.append(('payment_status', '=', status_filter))
total_count = request.env['education.fee.assignment'].sudo().search_count(domain)
assignments = request.env['education.fee.assignment'].sudo().search(
domain,
order='due_date asc, id desc',
limit=limit,
offset=offset
)
curr = student.currency_id or student.company_id.currency_id
curr_dict = {
"id": curr.id if curr else 1,
"name": curr.name if curr else "INR",
"symbol": curr.symbol if curr else "₹"
}
invoices = []
for a in assignments:
invoices.append({
"id": a.id,
"invoice_number": a.name or f"INV/{a.id:05d}",
"title": a.fee_structure_id.name or "Semester Fees",
"academic_year": self._get_rel(a.academic_year_id),
"academic_term": self._get_rel(a.academic_term_id),
"semester": self._get_rel(a.semester_id),
"assignment_date": str(a.assignment_date or ''),
"due_date": str(a.due_date or ''),
"amount": float(a.amount),
"amount_paid": float(a.amount_paid),
"amount_due": float(a.amount_due),
"payment_status": a.payment_status,
"state": a.state,
"currency": curr_dict
})
return json_response(True, data={
"invoices": invoices,
"total_count": total_count,
"page": page,
"limit": limit
})
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
@http.route('/api/mobile/v1/student/fees/invoices/<int:invoice_id>', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_fee_invoice_detail(self, student, invoice_id, **kwargs):
try:
assignment = request.env['education.fee.assignment'].sudo().browse(invoice_id)
if not assignment.exists() or not assignment.active:
return json_response(False, error_code="NOT_FOUND", error_message="Invoice not found", status=404)
# Security isolation: must match authenticated student
if assignment.student_id.id != student.id:
return json_response(False, error_code="ACCESS_DENIED", error_message="You do not have access to this invoice", status=403)
# Company isolation
if student.company_id and assignment.company_id and assignment.company_id.id != student.company_id.id:
return json_response(False, error_code="ACCESS_DENIED", error_message="You do not have access to this invoice", status=403)
curr = assignment.currency_id or student.currency_id or student.company_id.currency_id
curr_dict = {
"id": curr.id if curr else 1,
"name": curr.name if curr else "INR",
"symbol": curr.symbol if curr else "₹"
}
lines = []
if assignment.fee_structure_id and assignment.fee_structure_id.line_ids:
for line in assignment.fee_structure_id.line_ids:
lines.append({
"id": line.id,
"name": line.fee_type_id.name,
"category": line.fee_type_id.category_id.name if line.fee_type_id.category_id else None,
"amount": float(line.amount),
"mandatory": line.mandatory
})
payments = []
for p in assignment.payment_ids.filtered(lambda x: x.status == 'confirmed'):
payments.append({
"id": p.id,
"receipt_number": p.receipt_number,
"payment_date": str(p.payment_date or ''),
"payment_method": p.payment_method,
"payment_method_label": dict(p._fields['payment_method'].selection).get(p.payment_method, p.payment_method),
"amount": float(p.amount),
"reference_number": p.reference_number
})
data = {
"invoice": {
"id": assignment.id,
"invoice_number": assignment.name or f"INV/{assignment.id:05d}",
"title": assignment.fee_structure_id.name or "Semester Fees",
"academic_year": self._get_rel(assignment.academic_year_id),
"academic_term": self._get_rel(assignment.academic_term_id),
"semester": self._get_rel(assignment.semester_id),
"assignment_date": str(assignment.assignment_date or ''),
"due_date": str(assignment.due_date or ''),
"amount": float(assignment.amount),
"amount_paid": float(assignment.amount_paid),
"amount_due": float(assignment.amount_due),
"payment_status": assignment.payment_status,
"state": assignment.state,
"currency": curr_dict,
"lines": lines,
"payments": payments
}
}
return json_response(True, data=data)
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
@http.route('/api/mobile/v1/student/fees/payments', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_fee_payments(self, student, **kwargs):
try:
page = int(kwargs.get('page', 1) or 1)
limit = int(kwargs.get('limit', 20) or 20)
if page < 1:
page = 1
if limit < 1 or limit > 100:
limit = 20
offset = (page - 1) * limit
domain = [
('student_id', '=', student.id),
('status', '=', 'confirmed'),
]
if student.company_id:
domain.append(('company_id', '=', student.company_id.id))
total_count = request.env['education.student.payment'].sudo().search_count(domain)
payments = request.env['education.student.payment'].sudo().search(
domain,
order='payment_date desc, id desc',
limit=limit,
offset=offset
)
curr = student.currency_id or student.company_id.currency_id
curr_dict = {
"id": curr.id if curr else 1,
"name": curr.name if curr else "INR",
"symbol": curr.symbol if curr else "₹"
}
payments_data = []
for p in payments:
payments_data.append({
"id": p.id,
"receipt_number": p.receipt_number,
"payment_date": str(p.payment_date or ''),
"payment_method": p.payment_method,
"payment_method_label": dict(p._fields['payment_method'].selection).get(p.payment_method, p.payment_method),
"amount": float(p.amount),
"reference_number": p.reference_number,
"remarks": p.remarks,
"status": p.status,
"invoice": {
"id": p.fee_assignment_id.id,
"invoice_number": p.fee_assignment_id.name
} if p.fee_assignment_id else None,
"currency": curr_dict
})
return json_response(True, data={
"payments": payments_data,
"total_count": total_count,
"page": page,
"limit": limit
})
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)

View File

@ -1,21 +1,7 @@
# pyrefly: ignore [missing-import]
from odoo import api, fields, models
from odoo import models
class EducationFeeAssignmentInherit(models.Model):
_inherit = 'education.fee.assignment'
currency_id = fields.Many2one('res.currency', related='fee_structure_id.currency_id')
amount = fields.Monetary(compute='_compute_amount', string='Amount', currency_field='currency_id')
payment_status = fields.Selection([
('unpaid', 'Unpaid'),
('paid', 'Paid'),
], string='Payment Status', default='unpaid', required=True, index=True)
@api.depends('fee_structure_id', 'fee_structure_id.line_ids.amount')
def _compute_amount(self):
for rec in self:
if rec.fee_structure_id:
rec.amount = sum(line.amount for line in rec.fee_structure_id.line_ids)
else:
rec.amount = 0.0
# Base fields amount, payment_status, currency_id are provided by education_finance

View File

@ -35,5 +35,12 @@
<field name="domain_force">[('student_id.portal_user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
<record id="rule_fee_assignment_portal_own" model="ir.rule">
<field name="name">Portal Student: View Own Fee Invoices</field>
<field name="model_id" ref="education_finance.model_education_fee_assignment"/>
<field name="domain_force">[('student_id.portal_user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
</odoo>

View File

@ -12,3 +12,8 @@ access_term_portal,Term Portal Access,education_base.model_education_academic_te
access_level_portal,Level Portal Access,education_base.model_education_academic_level,base.group_portal,1,0,0,0
access_payment_transaction_user,Payment Transaction User,model_education_payment_transaction,base.group_portal,1,1,1,1
access_payment_transaction_portal,Payment Transaction Portal,model_education_payment_transaction,base.group_portal,1,0,0,0
access_fee_assignment_portal,Fee Assignment Portal,education_finance.model_education_fee_assignment,base.group_portal,1,0,0,0
access_fee_structure_portal,Fee Structure Portal,education_finance.model_education_fee_structure,base.group_portal,1,0,0,0
access_fee_structure_line_portal,Fee Structure Line Portal,education_finance.model_education_fee_structure_line,base.group_portal,1,0,0,0
access_fee_type_portal,Fee Type Portal,education_finance.model_education_fee_type,base.group_portal,1,0,0,0
access_fee_category_portal,Fee Category Portal,education_finance.model_education_fee_category,base.group_portal,1,0,0,0

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
12 access_level_portal Level Portal Access education_base.model_education_academic_level base.group_portal 1 0 0 0
13 access_payment_transaction_user Payment Transaction User model_education_payment_transaction base.group_portal 1 1 1 1
14 access_payment_transaction_portal Payment Transaction Portal model_education_payment_transaction base.group_portal 1 0 0 0
15 access_fee_assignment_portal Fee Assignment Portal education_finance.model_education_fee_assignment base.group_portal 1 0 0 0
16 access_fee_structure_portal Fee Structure Portal education_finance.model_education_fee_structure base.group_portal 1 0 0 0
17 access_fee_structure_line_portal Fee Structure Line Portal education_finance.model_education_fee_structure_line base.group_portal 1 0 0 0
18 access_fee_type_portal Fee Type Portal education_finance.model_education_fee_type base.group_portal 1 0 0 0
19 access_fee_category_portal Fee Category Portal education_finance.model_education_fee_category base.group_portal 1 0 0 0

View File

@ -15,4 +15,10 @@ class ApiEndpoints {
static const String results = '/api/mobile/v1/student/results';
static const String announcements = '/api/mobile/v1/student/announcements';
static String announcementDetail(int id) => '/api/mobile/v1/student/announcements/$id';
// Fees & Finance
static const String feeSummary = '/api/mobile/v1/student/fees/summary';
static const String feeInvoices = '/api/mobile/v1/student/fees/invoices';
static String feeInvoiceDetail(int id) => '/api/mobile/v1/student/fees/invoices/$id';
static const String feePayments = '/api/mobile/v1/student/fees/payments';
}

View File

@ -12,6 +12,7 @@ import '../../features/timetable/timetable_screen.dart';
import '../../features/exams/exams_screen.dart';
import '../../features/results/results_screen.dart';
import '../../features/announcements/announcements_screen.dart';
import '../../features/fees/fees_screen.dart';
import '../../features/settings/settings_screen.dart';
final appRouterProvider = Provider<GoRouter>((ref) {
@ -54,6 +55,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/announcements',
builder: (context, state) => const AnnouncementsScreen(),
),
GoRoute(
path: '/fees',
builder: (context, state) => const FeesScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),

View File

@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../models/attendance.dart';
import '../../models/dashboard.dart';
import '../../models/fee.dart';
import '../../providers/auth_provider.dart';
import '../../providers/dashboard_provider.dart';
@ -244,6 +245,12 @@ class DashboardScreen extends ConsumerWidget {
const SizedBox(height: 16),
// Quick Fee Card
if (data.fees != null) ...[
_buildFeeCard(context, data.fees!),
const SizedBox(height: 16),
],
// Today's Timetable Section
_buildTimetableSection(context, data.todayTimetable),
@ -375,6 +382,126 @@ class DashboardScreen extends ConsumerWidget {
);
}
Widget _buildFeeCard(BuildContext context, DashboardFeeSummary fees) {
Color statusColor;
String statusLabel;
switch (fees.paymentStatus) {
case 'fully_paid':
case 'paid':
statusColor = AppTheme.successColor;
statusLabel = 'Paid';
break;
case 'overdue':
statusColor = AppTheme.errorColor;
statusLabel = 'Overdue';
break;
case 'partial':
statusColor = AppTheme.warningColor;
statusLabel = 'Partial';
break;
default:
statusColor = fees.outstandingBalance > 0 ? AppTheme.primaryLight : AppTheme.successColor;
statusLabel = fees.outstandingBalance > 0 ? 'Pending' : 'No Dues';
}
final sym = fees.currencySymbol;
return Card(
child: InkWell(
onTap: () => context.push('/fees'),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
children: [
Icon(Icons.account_balance_wallet_outlined, size: 18, color: AppTheme.primaryColor),
SizedBox(width: 8),
Text(
'Fee Management',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: statusColor.withValues(alpha: 0.5)),
),
child: Text(
statusLabel,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
],
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Outstanding Dues',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
const SizedBox(height: 4),
Text(
'$sym${fees.outstandingBalance.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: fees.overdueAmount > 0 ? AppTheme.errorColor : AppTheme.textPrimary,
),
),
],
),
const Row(
children: [
Text(
'View Invoices →',
style: TextStyle(
fontSize: 13,
color: AppTheme.primaryLight,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
if (fees.nextDueDate != null) ...[
const SizedBox(height: 10),
Text(
'Next Due Date: ${fees.nextDueDate}',
style: const TextStyle(fontSize: 11, color: AppTheme.textMuted),
),
],
],
),
),
),
);
}
Widget _buildTimetableSection(BuildContext context, List todaySlots) {
return Card(
child: Padding(

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
import '../core/utils/safe_convert.dart';
import 'attendance.dart';
import 'fee.dart';
import 'student.dart';
import 'timetable.dart';
@ -102,6 +103,7 @@ class DashboardData {
final List<DashboardExam> upcomingExams;
final List<DashboardResult> recentResults;
final List<Announcement> announcements;
final DashboardFeeSummary? fees;
const DashboardData({
this.student,
@ -110,11 +112,13 @@ class DashboardData {
required this.upcomingExams,
required this.recentResults,
required this.announcements,
this.fees,
});
factory DashboardData.fromJson(Map<String, dynamic> json) {
final studentMap = SafeConvert.toNullableMap(json['student']);
final attendanceMap = SafeConvert.toNullableMap(json['attendance']) ?? {};
final feesMap = SafeConvert.toNullableMap(json['fees']);
return DashboardData(
student: studentMap != null ? DashboardStudent.fromJson(studentMap) : null,
@ -135,6 +139,7 @@ class DashboardData {
json['announcements'],
(item) => Announcement.fromJson(SafeConvert.toNullableMap(item) ?? {}),
),
fees: feesMap != null ? DashboardFeeSummary.fromJson(feesMap) : null,
);
}
}

View File

@ -0,0 +1,279 @@
import '../core/utils/safe_convert.dart';
import 'student.dart';
class CurrencyInfo {
final int id;
final String name;
final String symbol;
const CurrencyInfo({
required this.id,
required this.name,
required this.symbol,
});
factory CurrencyInfo.fromJson(Map<String, dynamic> json) {
return CurrencyInfo(
id: SafeConvert.toSafeInt(json['id']),
name: SafeConvert.toSafeString(json['name'], 'INR'),
symbol: SafeConvert.toSafeString(json['symbol'], '₹'),
);
}
static CurrencyInfo? fromNullableJson(dynamic json) {
final map = SafeConvert.toNullableMap(json);
if (map == null) return null;
return CurrencyInfo.fromJson(map);
}
}
class FeeSummary {
final double totalFees;
final double totalPaid;
final double outstandingBalance;
final double overdueAmount;
final String paymentStatus;
final String? nextDueDate;
final String? lastPaymentDate;
final String? lastReceiptNumber;
final CurrencyInfo? currency;
const FeeSummary({
required this.totalFees,
required this.totalPaid,
required this.outstandingBalance,
required this.overdueAmount,
required this.paymentStatus,
this.nextDueDate,
this.lastPaymentDate,
this.lastReceiptNumber,
this.currency,
});
String get currencySymbol => currency?.symbol ?? '₹';
factory FeeSummary.fromJson(Map<String, dynamic> json) {
return FeeSummary(
totalFees: SafeConvert.toSafeDouble(json['total_fees']),
totalPaid: SafeConvert.toSafeDouble(json['total_paid']),
outstandingBalance: SafeConvert.toSafeDouble(json['outstanding_balance']),
overdueAmount: SafeConvert.toSafeDouble(json['overdue_amount']),
paymentStatus: SafeConvert.toSafeString(json['payment_status'], 'no_dues'),
nextDueDate: SafeConvert.toNullableString(json['next_due_date']),
lastPaymentDate: SafeConvert.toNullableString(json['last_payment_date']),
lastReceiptNumber: SafeConvert.toNullableString(json['last_receipt_number']),
currency: CurrencyInfo.fromNullableJson(json['currency']),
);
}
}
class DashboardFeeSummary {
final double totalFees;
final double totalPaid;
final double outstandingBalance;
final double overdueAmount;
final String paymentStatus;
final String? nextDueDate;
final String currencySymbol;
const DashboardFeeSummary({
required this.totalFees,
required this.totalPaid,
required this.outstandingBalance,
required this.overdueAmount,
required this.paymentStatus,
this.nextDueDate,
this.currencySymbol = '₹',
});
factory DashboardFeeSummary.fromJson(Map<String, dynamic> json) {
return DashboardFeeSummary(
totalFees: SafeConvert.toSafeDouble(json['total_fees']),
totalPaid: SafeConvert.toSafeDouble(json['total_paid']),
outstandingBalance: SafeConvert.toSafeDouble(json['outstanding_balance']),
overdueAmount: SafeConvert.toSafeDouble(json['overdue_amount']),
paymentStatus: SafeConvert.toSafeString(json['payment_status'], 'no_dues'),
nextDueDate: SafeConvert.toNullableString(json['next_due_date']),
currencySymbol: SafeConvert.toSafeString(json['currency_symbol'], '₹'),
);
}
}
class FeeInvoiceLine {
final int id;
final String name;
final String? category;
final double amount;
final bool mandatory;
const FeeInvoiceLine({
required this.id,
required this.name,
this.category,
required this.amount,
required this.mandatory,
});
factory FeeInvoiceLine.fromJson(Map<String, dynamic> json) {
return FeeInvoiceLine(
id: SafeConvert.toSafeInt(json['id']),
name: SafeConvert.toSafeString(json['name']),
category: SafeConvert.toNullableString(json['category']),
amount: SafeConvert.toSafeDouble(json['amount']),
mandatory: SafeConvert.toSafeBool(json['mandatory'], true),
);
}
}
class FeeInvoicePayment {
final int id;
final String receiptNumber;
final String? paymentDate;
final String? paymentMethod;
final String? paymentMethodLabel;
final double amount;
final String? referenceNumber;
const FeeInvoicePayment({
required this.id,
required this.receiptNumber,
this.paymentDate,
this.paymentMethod,
this.paymentMethodLabel,
required this.amount,
this.referenceNumber,
});
factory FeeInvoicePayment.fromJson(Map<String, dynamic> json) {
return FeeInvoicePayment(
id: SafeConvert.toSafeInt(json['id']),
receiptNumber: SafeConvert.toSafeString(json['receipt_number']),
paymentDate: SafeConvert.toNullableString(json['payment_date']),
paymentMethod: SafeConvert.toNullableString(json['payment_method']),
paymentMethodLabel: SafeConvert.toNullableString(json['payment_method_label']),
amount: SafeConvert.toSafeDouble(json['amount']),
referenceNumber: SafeConvert.toNullableString(json['reference_number']),
);
}
}
class FeeInvoice {
final int id;
final String invoiceNumber;
final String title;
final NamedRecord? academicYear;
final NamedRecord? academicTerm;
final NamedRecord? semester;
final String? assignmentDate;
final String? dueDate;
final double amount;
final double amountPaid;
final double amountDue;
final String paymentStatus;
final String state;
final CurrencyInfo? currency;
final List<FeeInvoiceLine> lines;
final List<FeeInvoicePayment> payments;
const FeeInvoice({
required this.id,
required this.invoiceNumber,
required this.title,
this.academicYear,
this.academicTerm,
this.semester,
this.assignmentDate,
this.dueDate,
required this.amount,
required this.amountPaid,
required this.amountDue,
required this.paymentStatus,
required this.state,
this.currency,
this.lines = const [],
this.payments = const [],
});
String get currencySymbol => currency?.symbol ?? '₹';
bool get isOverdue => paymentStatus == 'overdue';
bool get isPaid => paymentStatus == 'paid';
bool get isPartial => paymentStatus == 'partial';
factory FeeInvoice.fromJson(Map<String, dynamic> json) {
return FeeInvoice(
id: SafeConvert.toSafeInt(json['id']),
invoiceNumber: SafeConvert.toSafeString(json['invoice_number']),
title: SafeConvert.toSafeString(json['title']),
academicYear: NamedRecord.fromNullableJson(json['academic_year']),
academicTerm: NamedRecord.fromNullableJson(json['academic_term']),
semester: NamedRecord.fromNullableJson(json['semester']),
assignmentDate: SafeConvert.toNullableString(json['assignment_date']),
dueDate: SafeConvert.toNullableString(json['due_date']),
amount: SafeConvert.toSafeDouble(json['amount']),
amountPaid: SafeConvert.toSafeDouble(json['amount_paid']),
amountDue: SafeConvert.toSafeDouble(json['amount_due']),
paymentStatus: SafeConvert.toSafeString(json['payment_status'], 'unpaid'),
state: SafeConvert.toSafeString(json['state'], 'draft'),
currency: CurrencyInfo.fromNullableJson(json['currency']),
lines: SafeConvert.toSafeList(
json['lines'],
(item) => FeeInvoiceLine.fromJson(SafeConvert.toNullableMap(item) ?? {}),
),
payments: SafeConvert.toSafeList(
json['payments'],
(item) => FeeInvoicePayment.fromJson(SafeConvert.toNullableMap(item) ?? {}),
),
);
}
}
class FeePayment {
final int id;
final String receiptNumber;
final String? paymentDate;
final String? paymentMethod;
final String paymentMethodLabel;
final double amount;
final String? referenceNumber;
final String? remarks;
final String status;
final int? invoiceId;
final String? invoiceNumber;
final CurrencyInfo? currency;
const FeePayment({
required this.id,
required this.receiptNumber,
this.paymentDate,
this.paymentMethod,
required this.paymentMethodLabel,
required this.amount,
this.referenceNumber,
this.remarks,
required this.status,
this.invoiceId,
this.invoiceNumber,
this.currency,
});
String get currencySymbol => currency?.symbol ?? '₹';
factory FeePayment.fromJson(Map<String, dynamic> json) {
final invoiceMap = SafeConvert.toNullableMap(json['invoice']);
return FeePayment(
id: SafeConvert.toSafeInt(json['id']),
receiptNumber: SafeConvert.toSafeString(json['receipt_number']),
paymentDate: SafeConvert.toNullableString(json['payment_date']),
paymentMethod: SafeConvert.toNullableString(json['payment_method']),
paymentMethodLabel: SafeConvert.toSafeString(json['payment_method_label'], 'Payment'),
amount: SafeConvert.toSafeDouble(json['amount']),
referenceNumber: SafeConvert.toNullableString(json['reference_number']),
remarks: SafeConvert.toNullableString(json['remarks']),
status: SafeConvert.toSafeString(json['status'], 'confirmed'),
invoiceId: invoiceMap != null ? SafeConvert.toNullableInt(invoiceMap['id']) : null,
invoiceNumber: invoiceMap != null ? SafeConvert.toNullableString(invoiceMap['invoice_number']) : null,
currency: CurrencyInfo.fromNullableJson(json['currency']),
);
}
}

View File

@ -0,0 +1,32 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/fee.dart';
import '../repositories/fee_repository.dart';
import 'auth_provider.dart';
final feeRepositoryProvider = Provider<FeeRepository>((ref) {
final apiClient = ref.watch(apiClientProvider);
return FeeRepository(apiClient: apiClient);
});
final feeStatusFilterProvider = StateProvider.autoDispose<String?>((ref) => null);
final feeSummaryProvider = FutureProvider.autoDispose<FeeSummary>((ref) async {
final repository = ref.watch(feeRepositoryProvider);
return await repository.getFeeSummary();
});
final feeInvoicesProvider = FutureProvider.autoDispose<FeeInvoicesPageResult>((ref) async {
final repository = ref.watch(feeRepositoryProvider);
final status = ref.watch(feeStatusFilterProvider);
return await repository.getInvoices(status: status);
});
final feeInvoiceDetailProvider = FutureProvider.autoDispose.family<FeeInvoice, int>((ref, id) async {
final repository = ref.watch(feeRepositoryProvider);
return await repository.getInvoiceDetail(id);
});
final feePaymentsProvider = FutureProvider.autoDispose<FeePaymentsPageResult>((ref) async {
final repository = ref.watch(feeRepositoryProvider);
return await repository.getPayments();
});

View File

@ -0,0 +1,111 @@
import '../core/api/api_client.dart';
import '../core/api/api_endpoints.dart';
import '../core/utils/safe_convert.dart';
import '../models/fee.dart';
class FeeInvoicesPageResult {
final List<FeeInvoice> invoices;
final int totalCount;
final int page;
final int limit;
const FeeInvoicesPageResult({
required this.invoices,
required this.totalCount,
required this.page,
required this.limit,
});
}
class FeePaymentsPageResult {
final List<FeePayment> payments;
final int totalCount;
final int page;
final int limit;
const FeePaymentsPageResult({
required this.payments,
required this.totalCount,
required this.page,
required this.limit,
});
}
class FeeRepository {
final ApiClient _apiClient;
FeeRepository({required ApiClient apiClient}) : _apiClient = apiClient;
Future<FeeSummary> getFeeSummary() async {
final data = await _apiClient.get(ApiEndpoints.feeSummary);
final map = SafeConvert.toNullableMap(data) ?? {};
return FeeSummary.fromJson(map);
}
Future<FeeInvoicesPageResult> getInvoices({
int page = 1,
int limit = 20,
String? status,
}) async {
final queryParameters = <String, dynamic>{
'page': page,
'limit': limit,
};
if (status != null && status.isNotEmpty && status != 'all') {
queryParameters['status'] = status;
}
final data = await _apiClient.get(
ApiEndpoints.feeInvoices,
queryParameters: queryParameters,
);
final map = SafeConvert.toNullableMap(data) ?? {};
final list = SafeConvert.toSafeList(
map['invoices'],
(item) => FeeInvoice.fromJson(SafeConvert.toNullableMap(item) ?? {}),
);
return FeeInvoicesPageResult(
invoices: list,
totalCount: SafeConvert.toSafeInt(map['total_count'], list.length),
page: SafeConvert.toSafeInt(map['page'], page),
limit: SafeConvert.toSafeInt(map['limit'], limit),
);
}
Future<FeeInvoice> getInvoiceDetail(int id) async {
final data = await _apiClient.get(ApiEndpoints.feeInvoiceDetail(id));
final map = SafeConvert.toNullableMap(data) ?? {};
final invMap = SafeConvert.toNullableMap(map['invoice']) ?? {};
return FeeInvoice.fromJson(invMap);
}
Future<FeePaymentsPageResult> getPayments({
int page = 1,
int limit = 20,
}) async {
final queryParameters = <String, dynamic>{
'page': page,
'limit': limit,
};
final data = await _apiClient.get(
ApiEndpoints.feePayments,
queryParameters: queryParameters,
);
final map = SafeConvert.toNullableMap(data) ?? {};
final list = SafeConvert.toSafeList(
map['payments'],
(item) => FeePayment.fromJson(SafeConvert.toNullableMap(item) ?? {}),
);
return FeePaymentsPageResult(
payments: list,
totalCount: SafeConvert.toSafeInt(map['total_count'], list.length),
page: SafeConvert.toSafeInt(map['page'], page),
limit: SafeConvert.toSafeInt(map['limit'], limit),
);
}
}

View File

@ -0,0 +1,235 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:metatron_student_erp/features/fees/fees_screen.dart';
import 'package:metatron_student_erp/models/fee.dart';
import 'package:metatron_student_erp/models/student.dart';
import 'package:metatron_student_erp/providers/fee_provider.dart';
import 'package:metatron_student_erp/repositories/fee_repository.dart';
void main() {
const sampleSummary = FeeSummary(
totalFees: 30000.0,
totalPaid: 10000.0,
outstandingBalance: 20000.0,
overdueAmount: 0.0,
paymentStatus: 'partial',
nextDueDate: '2026-10-23',
lastPaymentDate: '2026-10-08',
lastReceiptNumber: 'REC/000001',
currency: CurrencyInfo(id: 20, name: 'INR', symbol: '₹'),
);
final sampleInvoices = [
const FeeInvoice(
id: 59,
invoiceNumber: 'INV/2026/00035',
title: 'B.Tech CS - Sem 1 Fee Structure (Demo)',
academicYear: NamedRecord(id: 10, name: 'Test Year'),
semester: NamedRecord(id: 4, name: 'Semester 1'),
assignmentDate: '2026-09-23',
dueDate: '2026-10-23',
amount: 30000.0,
amountPaid: 10000.0,
amountDue: 20000.0,
paymentStatus: 'partial',
state: 'confirmed',
currency: CurrencyInfo(id: 20, name: 'INR', symbol: '₹'),
lines: [
FeeInvoiceLine(id: 75, name: 'Tuition Fee', category: 'Academic Fees', amount: 25000.0, mandatory: true),
FeeInvoiceLine(id: 76, name: 'Laboratory & Library Fee', category: 'Academic Fees', amount: 5000.0, mandatory: true),
],
payments: [
FeeInvoicePayment(
id: 29,
receiptNumber: 'REC/000001',
paymentDate: '2026-10-08',
paymentMethod: 'online',
paymentMethodLabel: 'Online Payment',
amount: 10000.0,
referenceNumber: 'DEMO-PAY-001',
),
],
),
];
final samplePayments = [
const FeePayment(
id: 29,
receiptNumber: 'REC/000001',
paymentDate: '2026-10-08',
paymentMethod: 'online',
paymentMethodLabel: 'Online Payment',
amount: 10000.0,
referenceNumber: 'DEMO-PAY-001',
remarks: 'Partial Online Fee Payment',
status: 'confirmed',
invoiceId: 59,
invoiceNumber: 'INV/2026/00035',
currency: CurrencyInfo(id: 20, name: 'INR', symbol: '₹'),
),
];
Widget buildTestableScreen(List<Override> overrides) {
return ProviderScope(
overrides: overrides,
child: const MaterialApp(
home: FeesScreen(),
),
);
}
testWidgets('FeesScreen renders loading state initially', (tester) async {
await tester.pumpWidget(
buildTestableScreen([
feeSummaryProvider.overrideWith(
(ref) => Future.delayed(const Duration(seconds: 1), () => sampleSummary),
),
feeInvoicesProvider.overrideWith(
(ref) => Future.delayed(
const Duration(seconds: 1),
() => FeeInvoicesPageResult(invoices: sampleInvoices, totalCount: 1, page: 1, limit: 20),
),
),
]),
);
expect(find.byType(CircularProgressIndicator), findsWidgets);
await tester.pumpAndSettle();
});
testWidgets('FeesScreen renders fee summary and invoice cards with status badges', (tester) async {
await tester.pumpWidget(
buildTestableScreen([
feeSummaryProvider.overrideWith((ref) async => sampleSummary),
feeInvoicesProvider.overrideWith(
(ref) async => FeeInvoicesPageResult(invoices: sampleInvoices, totalCount: 1, page: 1, limit: 20),
),
]),
);
await tester.pumpAndSettle();
// Verify Title & Tabs
expect(find.text('Fee Management'), findsOneWidget);
expect(find.text('Invoices & Summary'), findsOneWidget);
expect(find.text('Payment History'), findsOneWidget);
// Verify Summary Card
expect(find.text('Financial Overview'), findsOneWidget);
expect(find.text('Partially Paid'), findsOneWidget); // Status badge
expect(find.text('₹20000.00'), findsWidgets); // Outstanding dues
expect(find.text('₹30000.00'), findsWidgets); // Assessed
expect(find.text('₹10000.00'), findsWidgets); // Paid
expect(find.text('Next Due Date: 2026-10-23'), findsOneWidget);
// Verify Invoice List
expect(find.text('Fee Invoices'), findsOneWidget);
expect(find.text('INV/2026/00035'), findsOneWidget);
expect(find.text('B.Tech CS - Sem 1 Fee Structure (Demo)'), findsOneWidget);
expect(find.text('Due: 2026-10-23'), findsOneWidget);
expect(find.text('View Details'), findsOneWidget);
});
testWidgets('FeesScreen renders empty state when no invoices exist', (tester) async {
await tester.pumpWidget(
buildTestableScreen([
feeSummaryProvider.overrideWith(
(ref) async => const FeeSummary(
totalFees: 0.0,
totalPaid: 0.0,
outstandingBalance: 0.0,
overdueAmount: 0.0,
paymentStatus: 'no_dues',
),
),
feeInvoicesProvider.overrideWith(
(ref) async => const FeeInvoicesPageResult(invoices: [], totalCount: 0, page: 1, limit: 20),
),
]),
);
await tester.pumpAndSettle();
expect(find.text('No fee invoices found for your account.'), findsOneWidget);
});
testWidgets('FeesScreen opens invoice detail bottom sheet on tap', (tester) async {
await tester.pumpWidget(
buildTestableScreen([
feeSummaryProvider.overrideWith((ref) async => sampleSummary),
feeInvoicesProvider.overrideWith(
(ref) async => FeeInvoicesPageResult(invoices: sampleInvoices, totalCount: 1, page: 1, limit: 20),
),
feeInvoiceDetailProvider(59).overrideWith((ref) async => sampleInvoices[0]),
]),
);
await tester.pumpAndSettle();
// Tap on invoice card
await tester.tap(find.text('INV/2026/00035'));
await tester.pumpAndSettle();
// Verify Detail Sheet Content
expect(find.text('Fee Breakdown'), findsOneWidget);
expect(find.text('Tuition Fee'), findsOneWidget);
expect(find.text('Laboratory & Library Fee'), findsOneWidget);
expect(find.text('₹25000.00'), findsOneWidget);
expect(find.text('₹5000.00'), findsOneWidget);
// Scroll down to reveal totals and payments
await tester.drag(find.text('Fee Breakdown'), const Offset(0, -300));
await tester.pumpAndSettle();
expect(find.text('Balance Due'), findsOneWidget);
expect(find.text('Payments Received'), findsOneWidget);
expect(find.text('REC/000001'), findsOneWidget);
});
testWidgets('FeesScreen renders payment history in tab 2', (tester) async {
await tester.pumpWidget(
buildTestableScreen([
feeSummaryProvider.overrideWith((ref) async => sampleSummary),
feeInvoicesProvider.overrideWith(
(ref) async => FeeInvoicesPageResult(invoices: sampleInvoices, totalCount: 1, page: 1, limit: 20),
),
feePaymentsProvider.overrideWith(
(ref) async => FeePaymentsPageResult(payments: samplePayments, totalCount: 1, page: 1, limit: 20),
),
]),
);
await tester.pumpAndSettle();
// Switch to Payment History Tab
await tester.tap(find.text('Payment History'));
await tester.pumpAndSettle();
// Verify Payment Record
expect(find.text('REC/000001'), findsOneWidget);
expect(find.text('Method: Online Payment'), findsOneWidget);
expect(find.text('Invoice: INV/2026/00035'), findsOneWidget);
expect(find.text('Ref: DEMO-PAY-001'), findsOneWidget);
expect(find.text('₹10000.00'), findsOneWidget);
});
testWidgets('FeesScreen renders error state and retry on failure', (tester) async {
await tester.pumpWidget(
buildTestableScreen([
feeSummaryProvider.overrideWith(
(ref) async => throw Exception('Network Connection Refused'),
),
feeInvoicesProvider.overrideWith(
(ref) async => throw Exception('Network Connection Refused'),
),
]),
);
await tester.pumpAndSettle();
expect(find.textContaining('Failed to load fee summary'), findsOneWidget);
expect(find.textContaining('Failed to load invoices'), findsOneWidget);
expect(find.text('Retry'), findsWidgets);
});
}

View File

@ -6,6 +6,7 @@ import 'package:metatron_student_erp/repositories/announcement_repository.dart';
import 'package:metatron_student_erp/repositories/attendance_repository.dart';
import 'package:metatron_student_erp/repositories/auth_repository.dart';
import 'package:metatron_student_erp/repositories/exam_repository.dart';
import 'package:metatron_student_erp/repositories/fee_repository.dart';
import 'package:metatron_student_erp/repositories/result_repository.dart';
import 'package:metatron_student_erp/repositories/student_repository.dart';
import 'package:metatron_student_erp/repositories/timetable_repository.dart';
@ -47,6 +48,7 @@ void main() {
late ExamRepository examRepo;
late ResultRepository resultRepo;
late AnnouncementRepository announcementRepo;
late FeeRepository feeRepo;
setUpAll(() async {
storage = TestSecureStorage();
@ -58,6 +60,7 @@ void main() {
examRepo = ExamRepository(apiClient: apiClient);
resultRepo = ResultRepository(apiClient: apiClient);
announcementRepo = AnnouncementRepository(apiClient: apiClient);
feeRepo = FeeRepository(apiClient: apiClient);
});
test('1. Flutter Login against live Odoo backend returns session and user', () async {
@ -150,7 +153,42 @@ void main() {
stdout.writeln('--> Announcement detail verified: #${detail.id} "${detail.title}"');
});
test('10. POST /api/mobile/v1/auth/logout ends session', () async {
test('10. GET /api/mobile/v1/student/fees/* endpoints return fee records', () async {
// Summary
final summary = await feeRepo.getFeeSummary();
expect(summary.totalFees, greaterThan(0));
expect(summary.totalPaid, greaterThanOrEqualTo(0));
expect(summary.outstandingBalance, isNonNegative);
expect(summary.paymentStatus, isNotEmpty);
stdout.writeln('--> Fee Summary verified: total=${summary.totalFees}, paid=${summary.totalPaid}, balance=${summary.outstandingBalance}, status=${summary.paymentStatus}');
// Invoices
final invoicesRes = await feeRepo.getInvoices();
expect(invoicesRes.invoices, isNotEmpty);
final firstInv = invoicesRes.invoices.first;
expect(firstInv.id, isPositive);
expect(firstInv.invoiceNumber, isNotEmpty);
expect(firstInv.amount, greaterThan(0));
stdout.writeln('--> Fee Invoices verified: ${invoicesRes.invoices.length} invoices found (first: ${firstInv.invoiceNumber} - ${firstInv.title})');
// Invoice Detail
final invoiceDetail = await feeRepo.getInvoiceDetail(firstInv.id);
expect(invoiceDetail.id, firstInv.id);
expect(invoiceDetail.invoiceNumber, firstInv.invoiceNumber);
expect(invoiceDetail.lines, isNotEmpty);
stdout.writeln('--> Fee Invoice Detail verified: #${invoiceDetail.id} has ${invoiceDetail.lines.length} breakdown lines and ${invoiceDetail.payments.length} payments');
// Payments
final paymentsRes = await feeRepo.getPayments();
expect(paymentsRes.payments, isNotEmpty);
final firstPay = paymentsRes.payments.first;
expect(firstPay.id, isPositive);
expect(firstPay.receiptNumber, isNotEmpty);
expect(firstPay.amount, greaterThan(0));
stdout.writeln('--> Fee Payments verified: ${paymentsRes.payments.length} payments found (first: ${firstPay.receiptNumber} - amount: ${firstPay.amount})');
});
test('11. POST /api/mobile/v1/auth/logout ends session', () async {
await authRepo.logout();
final cookie = await storage.getSessionCookie();
expect(cookie, isNull);

View File

@ -4,6 +4,7 @@ import 'package:metatron_student_erp/models/attendance.dart';
import 'package:metatron_student_erp/models/announcement.dart';
import 'package:metatron_student_erp/models/dashboard.dart';
import 'package:metatron_student_erp/models/exam.dart';
import 'package:metatron_student_erp/models/fee.dart';
import 'package:metatron_student_erp/models/result.dart';
import 'package:metatron_student_erp/models/student.dart';
import 'package:metatron_student_erp/models/timetable.dart';
@ -442,4 +443,159 @@ void main() {
expect(item.isImportant, isFalse);
});
});
group('Fee and Finance Models Parsing Tests', () {
test('parses full FeeSummary JSON', () {
final json = {
'total_fees': 30000.0,
'total_paid': 10000.0,
'outstanding_balance': 20000.0,
'overdue_amount': 0.0,
'payment_status': 'partial',
'next_due_date': '2026-10-23',
'last_payment_date': '2026-10-08',
'last_receipt_number': 'REC/000001',
'currency': {'id': 20, 'name': 'INR', 'symbol': '₹'}
};
final summary = FeeSummary.fromJson(json);
expect(summary.totalFees, 30000.0);
expect(summary.totalPaid, 10000.0);
expect(summary.outstandingBalance, 20000.0);
expect(summary.overdueAmount, 0.0);
expect(summary.paymentStatus, 'partial');
expect(summary.nextDueDate, '2026-10-23');
expect(summary.lastPaymentDate, '2026-10-08');
expect(summary.lastReceiptNumber, 'REC/000001');
expect(summary.currencySymbol, '₹');
expect(summary.currency?.name, 'INR');
});
test('parses FeeSummary with false and nullable fields', () {
final json = {
'total_fees': false,
'total_paid': null,
'outstanding_balance': '0.0',
'overdue_amount': false,
'payment_status': false,
'next_due_date': false,
'last_payment_date': null,
'last_receipt_number': false,
'currency': false,
};
final summary = FeeSummary.fromJson(json);
expect(summary.totalFees, 0.0);
expect(summary.totalPaid, 0.0);
expect(summary.outstandingBalance, 0.0);
expect(summary.overdueAmount, 0.0);
expect(summary.paymentStatus, 'no_dues');
expect(summary.nextDueDate, isNull);
expect(summary.currencySymbol, '₹'); // default fallback
});
test('parses FeeInvoice with lines and payments', () {
final json = {
'id': 59,
'invoice_number': 'INV/2026/00035',
'title': 'B.Tech CS - Sem 1 Fee Structure (Demo)',
'academic_year': {'id': 10, 'name': 'Test Year'},
'academic_term': false,
'semester': {'id': 4, 'name': 'Semester 1'},
'assignment_date': '2026-09-23',
'due_date': '2026-10-23',
'amount': 30000.0,
'amount_paid': 10000.0,
'amount_due': 20000.0,
'payment_status': 'partial',
'state': 'confirmed',
'currency': {'id': 20, 'name': 'INR', 'symbol': '₹'},
'lines': [
{'id': 75, 'name': 'Tuition Fee', 'category': 'Academic Fees', 'amount': 25000.0, 'mandatory': true},
{'id': 76, 'name': 'Lab Fee', 'category': 'Academic Fees', 'amount': 5000.0, 'mandatory': true}
],
'payments': [
{'id': 29, 'receipt_number': 'REC/000001', 'payment_date': '2026-10-08', 'payment_method': 'online', 'payment_method_label': 'Online Payment', 'amount': 10000.0, 'reference_number': 'DEMO-PAY-001'}
]
};
final invoice = FeeInvoice.fromJson(json);
expect(invoice.id, 59);
expect(invoice.invoiceNumber, 'INV/2026/00035');
expect(invoice.title, 'B.Tech CS - Sem 1 Fee Structure (Demo)');
expect(invoice.academicYear?.name, 'Test Year');
expect(invoice.academicTerm, isNull);
expect(invoice.semester?.name, 'Semester 1');
expect(invoice.amount, 30000.0);
expect(invoice.amountPaid, 10000.0);
expect(invoice.amountDue, 20000.0);
expect(invoice.paymentStatus, 'partial');
expect(invoice.isPartial, isTrue);
expect(invoice.isOverdue, isFalse);
expect(invoice.isPaid, isFalse);
expect(invoice.lines.length, 2);
expect(invoice.lines[0].name, 'Tuition Fee');
expect(invoice.lines[0].amount, 25000.0);
expect(invoice.payments.length, 1);
expect(invoice.payments[0].receiptNumber, 'REC/000001');
expect(invoice.payments[0].amount, 10000.0);
});
test('parses FeePayment record', () {
final json = {
'id': 29,
'receipt_number': 'REC/000001',
'payment_date': '2026-10-08',
'payment_method': 'online',
'payment_method_label': 'Online Payment',
'amount': 10000.0,
'reference_number': 'DEMO-PAY-001',
'remarks': 'Partial Online Fee Payment',
'status': 'confirmed',
'invoice': {'id': 59, 'invoice_number': 'INV/2026/00035'},
'currency': {'id': 20, 'name': 'INR', 'symbol': '₹'}
};
final payment = FeePayment.fromJson(json);
expect(payment.id, 29);
expect(payment.receiptNumber, 'REC/000001');
expect(payment.paymentDate, '2026-10-08');
expect(payment.paymentMethod, 'online');
expect(payment.paymentMethodLabel, 'Online Payment');
expect(payment.amount, 10000.0);
expect(payment.referenceNumber, 'DEMO-PAY-001');
expect(payment.remarks, 'Partial Online Fee Payment');
expect(payment.status, 'confirmed');
expect(payment.invoiceId, 59);
expect(payment.invoiceNumber, 'INV/2026/00035');
expect(payment.currencySymbol, '₹');
});
test('parses DashboardFeeSummary inside DashboardData', () {
final json = {
'attendance': {'percentage': 95.0, 'present': 19, 'absent': 1, 'late': 0, 'medical_leave': 0, 'on_duty': 0},
'today_timetable': [],
'upcoming_exams': [],
'recent_results': [],
'announcements': [],
'fees': {
'total_fees': 30000.0,
'total_paid': 10000.0,
'outstanding_balance': 20000.0,
'overdue_amount': 0.0,
'payment_status': 'partial',
'next_due_date': '2026-10-23',
'currency_symbol': '₹'
}
};
final dashboard = DashboardData.fromJson(json);
expect(dashboard.fees, isNotNull);
expect(dashboard.fees?.outstandingBalance, 20000.0);
expect(dashboard.fees?.totalPaid, 10000.0);
expect(dashboard.fees?.paymentStatus, 'partial');
expect(dashboard.fees?.currencySymbol, '₹');
expect(dashboard.fees?.nextDueDate, '2026-10-23');
});
});
}