import json import logging # pyrefly: ignore [missing-import] from odoo import fields, _ # pyrefly: ignore [missing-import] from odoo.exceptions import UserError, ValidationError from .razorpay_service import RazorpayService from .reconciliation_service import ReconciliationService _logger = logging.getLogger(__name__) class PaymentService: @classmethod def create_order(cls, env, student, fee_ids=None, custom_amount=None): """ Validate fees server-side, create pending transaction, and generate Razorpay Order. """ if not student: raise ValidationError(_("No student record found for current user.")) fee_assignments = env['education.fee.assignment'].sudo() if fee_ids: fee_assignments = env['education.fee.assignment'].sudo().search([ ('id', 'in', fee_ids), ('student_id', '=', student.id), ('payment_status', '!=', 'paid'), ]) if not fee_assignments: raise UserError(_("Selected fee items are already paid or invalid.")) total_amount = sum(fee.amount for fee in fee_assignments) elif custom_amount: try: total_amount = float(custom_amount) except (ValueError, TypeError): raise UserError(_("Invalid payment amount specified.")) if total_amount <= 0 or total_amount > student.outstanding_amount: raise UserError(_("Payment amount must be greater than zero and cannot exceed outstanding balance.")) else: total_amount = student.outstanding_amount if total_amount <= 0: raise UserError(_("There are no outstanding fee dues to pay.")) company = student.company_id or env.company currency = company.currency_id amount_paise = int(round(total_amount * 100)) # Determine gateway provider ICP = env['ir.config_parameter'].sudo() provider = ICP.get_param('education_portal.education_payment_provider', 'razorpay') env_mode = ICP.get_param('education_portal.payment_environment', 'sandbox') if env_mode == 'sandbox' and provider == 'razorpay': provider = 'mock' if not ICP.get_param('education_portal.razorpay_key_id') else 'razorpay' # 1. Create Pending Payment Transaction tx_vals = { 'student_id': student.id, 'company_id': company.id, 'currency_id': currency.id, 'amount': total_amount, 'provider': provider, 'status': 'pending', 'fee_assignment_ids': [(6, 0, fee_assignments.ids)], } transaction = env['education.payment.transaction'].sudo().create(tx_vals) # 2. Call Razorpay Service order_res = RazorpayService.create_order( env=env, amount_paise=amount_paise, currency=currency.name or 'INR', receipt=str(transaction.name)[:40], notes={ 'student_id': str(student.id), 'admission_number': str(student.admission_number or ''), 'transaction_id': str(transaction.id), } ) order_id = order_res.get('id') transaction.sudo().write({ 'razorpay_order_id': order_id, 'gateway_response': json.dumps(order_res), }) transaction.log_audit(f"Order created on gateway. Order ID: {order_id}, Amount: ₹{total_amount:,.2f}") config = RazorpayService.get_config(env) return { 'status': 'created', 'transaction_id': transaction.id, 'transaction_ref': transaction.name, 'order_id': order_id, 'key_id': config['key_id'], 'amount_paise': amount_paise, 'amount_rupees': total_amount, 'currency': currency.name or 'INR', 'student_name': student.display_name, 'email': student.email or '', 'phone': student.guardian_phone or student.phone or '', 'company_name': company.name or 'Education Institute', 'is_mock': order_res.get('is_mock', False), } @classmethod def verify_and_reconcile(cls, env, transaction_id, razorpay_order_id, razorpay_payment_id, razorpay_signature): """ Cryptographically verify signature and execute DB reconciliation. """ transaction = env['education.payment.transaction'].sudo().browse(transaction_id) if not transaction.exists(): raise UserError(_("Transaction record not found.")) if transaction.status == 'success': return { 'status': 'success', 'redirect_url': f"/my/education/payment/success/{transaction.id}", 'message': 'Transaction already completed.' } # Store IDs transaction.sudo().write({ 'razorpay_order_id': razorpay_order_id or transaction.razorpay_order_id, 'razorpay_payment_id': razorpay_payment_id, 'razorpay_signature': razorpay_signature, }) is_valid = RazorpayService.verify_signature( env=env, razorpay_order_id=razorpay_order_id or transaction.razorpay_order_id, razorpay_payment_id=razorpay_payment_id, razorpay_signature=razorpay_signature ) if not is_valid: transaction.action_mark_failed("Cryptographic HMAC-SHA256 signature verification failed.") return { 'status': 'failed', 'redirect_url': f"/my/education/payment/failed/{transaction.id}", 'error': 'Payment verification failed. Invalid signature.' } # Execute Reconciliation ReconciliationService.reconcile_transaction( env=env, transaction=transaction, razorpay_payment_id=razorpay_payment_id, raw_gateway_response=json.dumps({ 'order_id': razorpay_order_id, 'payment_id': razorpay_payment_id, 'signature': razorpay_signature, 'verified': True, }) ) return { 'status': 'success', 'redirect_url': f"/my/education/payment/success/{transaction.id}", 'message': 'Payment successfully verified and reconciled.' } @classmethod def handle_webhook(cls, env, payload_bytes, signature_header): """ Process incoming webhooks idempotently. """ if not RazorpayService.verify_webhook_signature(env, payload_bytes, signature_header): _logger.warning("Invalid webhook signature received.") return {'status': 'error', 'reason': 'Invalid signature'} try: payload = json.loads(payload_bytes.decode('utf-8')) except Exception: return {'status': 'error', 'reason': 'Invalid JSON body'} event = payload.get('event') _logger.info("Received payment webhook event: %s", event) payment_entity = payload.get('payload', {}).get('payment', {}).get('entity', {}) order_id = payment_entity.get('order_id') payment_id = payment_entity.get('id') if not order_id and not payment_id: return {'status': 'ignored', 'reason': 'No order or payment ID'} # Locate transaction tx = env['education.payment.transaction'].sudo().search([ '|', ('razorpay_order_id', '=', order_id), ('razorpay_payment_id', '=', payment_id) ], limit=1) if not tx: _logger.info("No matching transaction found for webhook order %s", order_id) return {'status': 'ignored', 'reason': 'Transaction not found'} if event in ('payment.captured', 'order.paid'): ReconciliationService.reconcile_transaction( env=env, transaction=tx, razorpay_payment_id=payment_id, raw_gateway_response=json.dumps(payload) ) return {'status': 'success', 'event': event} elif event == 'payment.failed': tx.action_mark_failed(f"Webhook notified payment failure: {payment_entity.get('error_description', 'Failed')}") return {'status': 'success', 'event': event} return {'status': 'ignored', 'event': event} @classmethod def process_refund(cls, env, transaction): """Issue gateway refund and reverse ledger""" if transaction.status != 'success': raise UserError(_("Only successful transactions can be refunded.")) amount_paise = int(round(transaction.amount * 100)) refund_res = RazorpayService.process_refund( env=env, razorpay_payment_id=transaction.razorpay_payment_id or 'pay_mock_refund', amount_paise=amount_paise ) ReconciliationService.reverse_reconciliation(env, transaction) transaction.log_audit(f"Refund Gateway Response: {json.dumps(refund_res)}") return refund_res