228 lines
8.9 KiB
Python
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
|