feat(education_finance): integrate student fees and payment tracking
This commit is contained in:
parent
bce010fcb8
commit
ff2d7903b6
@ -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()
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 & 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 & Post to Ledger" type="object" class="oe_highlight" invisible="state == 'confirmed'"/>
|
||||
<button name="action_cancel" string="Cancel & 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 & 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 & 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 & 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 & 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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
@ -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';
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(
|
||||
|
||||
1049
flutter_app/lib/features/fees/fees_screen.dart
Normal file
1049
flutter_app/lib/features/fees/fees_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
279
flutter_app/lib/models/fee.dart
Normal file
279
flutter_app/lib/models/fee.dart
Normal 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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
32
flutter_app/lib/providers/fee_provider.dart
Normal file
32
flutter_app/lib/providers/fee_provider.dart
Normal 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();
|
||||
});
|
||||
111
flutter_app/lib/repositories/fee_repository.dart
Normal file
111
flutter_app/lib/repositories/fee_repository.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
235
flutter_app/test/fees_widget_test.dart
Normal file
235
flutter_app/test/fees_widget_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user