import logging from odoo import http from odoo.http import request from odoo.addons.encoach_api.controllers.base import ( jwt_required, _json_response, _error_response, _paginate, _get_json_body, ) _get_json = _get_json_body _logger = logging.getLogger(__name__) def _serialize_plan(r): """Build a fees-plan record with REAL paid/remaining pulled from the linked account.move (invoice), not a hard-coded zero.""" total = float(getattr(r, 'amount', 0) or 0) after_discount = float(getattr(r, 'after_discount_amount', 0) or total) invoice = r.invoice_id if hasattr(r, 'invoice_id') else False invoice_state = '' payment_state = 'not_paid' amount_residual = after_discount paid_amount = 0.0 invoice_id = 0 invoice_number = '' if invoice and invoice.exists(): invoice_id = invoice.id invoice_state = invoice.state or '' payment_state = getattr(invoice, 'payment_state', '') or 'not_paid' amount_residual = float(getattr(invoice, 'amount_residual', after_discount) or 0) amount_total = float(getattr(invoice, 'amount_total', after_discount) or 0) paid_amount = max(0.0, amount_total - amount_residual) invoice_number = getattr(invoice, 'name', '') or '' return { 'id': r.id, 'student_id': r.student_id.id if getattr(r, 'student_id', False) else 0, 'student_name': ( r.student_id.partner_id.name if getattr(r, 'student_id', False) and r.student_id.partner_id else '' ), 'course_id': r.course_id.id if getattr(r, 'course_id', False) else 0, 'course_name': r.course_id.name if getattr(r, 'course_id', False) else '', 'batch_id': r.batch_id.id if getattr(r, 'batch_id', False) else 0, 'batch_name': r.batch_id.name if getattr(r, 'batch_id', False) else '', 'product_id': r.product_id.id if getattr(r, 'product_id', False) else 0, 'product_name': r.product_id.name if getattr(r, 'product_id', False) else '', 'date': str(r.date) if getattr(r, 'date', False) else '', 'discount': float(getattr(r, 'discount', 0) or 0), 'total_amount': total, 'after_discount_amount': after_discount, 'paid_amount': round(paid_amount, 2), 'remaining_amount': round(amount_residual, 2), 'state': getattr(r, 'state', 'draft') or 'draft', 'invoice_id': invoice_id, 'invoice_number': invoice_number, 'invoice_state': invoice_state, 'payment_state': payment_state, 'currency': r.currency_id.name if getattr(r, 'currency_id', False) else '', } class FeesController(http.Controller): @http.route('/api/fees-plans', type='http', auth='public', methods=['GET'], csrf=False) @jwt_required def list_fees_plans(self, **kw): try: M = request.env['op.student.fees.details'].sudo() offset, limit, page = _paginate(kw) domain = [] state = (kw.get('state') or '').strip() if state: domain.append(('state', '=', state)) payment_state = (kw.get('payment_state') or '').strip() q = (kw.get('q') or '').strip() if q: domain += ['|', ('student_id.partner_id.name', 'ilike', q), ('product_id.name', 'ilike', q)] total = M.search_count(domain) recs = M.search(domain, offset=offset, limit=limit, order='id desc') items = [_serialize_plan(r) for r in recs] if payment_state: items = [i for i in items if i['payment_state'] == payment_state] return _json_response({ 'items': items, 'data': items, 'total': total, 'page': page, 'size': limit, }) except Exception as e: _logger.exception('list_fees_plans') return _error_response(str(e), 500) @http.route('/api/fees-plans/', type='http', auth='public', methods=['GET'], csrf=False) @jwt_required def get_fees_plan(self, fid, **kw): try: rec = request.env['op.student.fees.details'].sudo().browse(fid) if not rec.exists(): return _error_response('Not found', 404) return _json_response(_serialize_plan(rec)) except Exception as e: _logger.exception('get_fees_plan') return _error_response(str(e), 500) @http.route('/api/fees-plans//create-invoice', type='http', auth='public', methods=['POST'], csrf=False) @jwt_required def create_invoice(self, fid, **kw): """Generate the accounting invoice for this fee line (uses the built-in OpenEduCat `get_invoice()` model method).""" try: rec = request.env['op.student.fees.details'].sudo().browse(fid) if not rec.exists(): return _error_response('Fees plan not found', 404) if not rec.product_id: return _error_response('This fees plan has no product; cannot invoice.', 400) if float(rec.amount or 0) <= 0: return _error_response('Fees amount must be positive to invoice.', 400) # Auto-wire a default income account on the product/category if # the Odoo chart of accounts is set up but the product was seeded # without one. Otherwise OpenEduCat raises a UserError. prod_tmpl = rec.product_id.sudo().product_tmpl_id try: fp = prod_tmpl.get_product_accounts() if hasattr( prod_tmpl, 'get_product_accounts') else {} except Exception: fp = {} if not (fp or {}).get('income'): AA = request.env['account.account'].sudo() # Odoo 19: account.account has a M2M `company_ids`. try: income_acc = AA.search([ ('account_type', '=', 'income'), ('company_ids', 'in', request.env.company.id), ], limit=1) except Exception: income_acc = AA.browse() if not income_acc: income_acc = AA.search([('account_type', '=', 'income')], limit=1) if income_acc: try: prod_tmpl.with_company(request.env.company).write({ 'property_account_income_id': income_acc.id, }) except Exception: _logger.exception('auto-wire income account') rec.get_invoice() rec.invalidate_recordset() return _json_response(_serialize_plan(rec)) except Exception as e: _logger.exception('create_invoice') return _error_response(str(e), 500) @http.route('/api/fees-plans//register-payment', type='http', auth='public', methods=['POST'], csrf=False) @jwt_required def register_payment(self, fid, **kw): """Register a payment against the invoice linked to this fees plan. Body: { "amount": float, "journal_id": int (optional), "payment_date": "YYYY-MM-DD" (optional), "memo": str (optional) }""" try: body = _get_json() or {} rec = request.env['op.student.fees.details'].sudo().browse(fid) if not rec.exists(): return _error_response('Fees plan not found', 404) invoice = rec.invoice_id if not invoice or not invoice.exists(): return _error_response('Create an invoice before registering a payment.', 400) if invoice.state == 'draft': invoice.sudo().action_post() amount = float(body.get('amount') or invoice.amount_residual or 0) if amount <= 0: return _error_response('Payment amount must be positive.', 400) payment_date = body.get('payment_date') or False memo = body.get('memo') or f"Payment for {rec.product_id.name or 'fees'}" journal_id = body.get('journal_id') if not journal_id: journal = request.env['account.journal'].sudo().search( [('type', 'in', ('bank', 'cash'))], limit=1 ) if not journal: return _error_response( 'No bank/cash journal configured. Open Accounting → Configuration → Journals.', 400) journal_id = journal.id wiz_vals = { 'amount': amount, 'journal_id': int(journal_id), 'communication': memo, } if payment_date: wiz_vals['payment_date'] = payment_date wizard = request.env['account.payment.register'].sudo().with_context( active_model='account.move', active_ids=[invoice.id] ).create(wiz_vals) wizard.action_create_payments() invoice.invalidate_recordset() rec.invalidate_recordset() return _json_response(_serialize_plan(rec)) except Exception as e: _logger.exception('register_payment') return _error_response(str(e), 500) @http.route('/api/student-fees', type='http', auth='public', methods=['GET'], csrf=False) @jwt_required def list_student_fees(self, **kw): try: M = request.env['op.student.fees.details'].sudo() offset, limit, page = _paginate(kw) domain = [] student_id = kw.get('student_id') if student_id: try: domain.append(('student_id', '=', int(student_id))) except Exception: pass q = (kw.get('q') or '').strip() if q: domain += ['|', ('student_id.partner_id.name', 'ilike', q), ('product_id.name', 'ilike', q)] total = M.search_count(domain) recs = M.search(domain, offset=offset, limit=limit, order='id desc') items = [_serialize_plan(r) for r in recs] return _json_response({ 'items': items, 'data': items, 'total': total, 'page': page, 'size': limit, }) except Exception as e: _logger.exception('list_student_fees') return _error_response(str(e), 500) @http.route('/api/fees-terms', type='http', auth='public', methods=['GET'], csrf=False) @jwt_required def list_fees_terms(self, **kw): try: M = request.env['op.fees.terms'].sudo() offset, limit, page = _paginate(kw) total = M.search_count([]) recs = M.search([], offset=offset, limit=limit, order='id desc') items = [{ 'id': r.id, 'name': r.name or '', 'no_days': getattr(r, 'no_days', 0) or 0, 'line_ids': r.line_ids.ids if hasattr(r, 'line_ids') else [], } for r in recs] return _json_response({ 'items': items, 'data': items, 'total': total, 'page': page, 'size': limit, }) except Exception as e: _logger.exception('list_fees_terms') return _error_response(str(e), 500)