2026-09-25 18:00:58 +05:30

228 lines
8.9 KiB
Python

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