import time as time_module from flask import Blueprint, request, jsonify, current_app import requests from app.extensions import db from app.config import settings from app.helpers.response_helper import ResponseHelper from app.helpers.collect_loan_helper import CollectLoanHelper from app.utils.auth import get_headers from app.utils.logger import logger from app.integrations.simbrella import SimbrellaClient from app.services.loan import LoanService from app.services.repayment import RepaymentService from app.services.salary import SalaryService from app.services.loan_repayment_schedule import LoanRepaymentScheduleService from app.services.loan_charge import LoanChargesService from app.enums.loan_status import LoanStatus from app.enums.repayment_schedule_status import RepaymentScheduleStatus from app.utils.mail import send_report_email, get_report_data from datetime import datetime, timezone, timedelta from app.config import settings autocall_bp = Blueprint("autocall", __name__) #refresh-verify-disbursement # # @autocall_bp.route("/refresh-verify-disbursement", methods=["GET"]) def verify_transaction(): """ Get the latest loan with disbursement date and no verification date. Then call the verify transaction endpoint with the loan data. This is called after disbursement to ensure the loan was actually disbursed Then a verification date is set """ logger.info(f"Calling VerifyTransaction Components") loan = LoanService.get_latest_loan_with_disburse_date() if not loan: logger.info(f"No loan found without disbursement date") return 0 logger.info(f"Calling VerifyTransaction endpoint with data: {loan}") loan_data = loan.to_dict() data = { "transactionId": loan_data.get('transactionId'), "fbnTransactionId": loan_data.get('transactionId'), "debtId": str(loan_data.get('debtId')), "customerId": loan_data.get('customerId'), "accountId": loan_data.get('accountId'), "productId": str(loan_data.get('productId', "")), "provideAmount": loan_data.get('currentLoanAmount'), } response = SimbrellaClient.verify_transaction(data) return response @autocall_bp.route("/refresh-disbursement", methods=["GET"]) def disbursement(): """ Get the latest loan without disbursement date. Then call the disburse loan endpoint with the loan data. """ # data = request.json() logger.info(f"Calling Disbursement Components") loan = LoanService.get_latest_loan_without_disburse_date() if not loan: logger.info(f"No loan found without disbursement date") return 0 logger.info(f"Calling DisburseLoan endpoint with data: {loan}") loan_data = loan.to_dict() data = { "transactionId": loan_data.get('transactionId'), "FbnTransactionId": loan_data.get('transactionId'), "debtId": str(loan_data.get('debtId')), "customerId": loan_data.get('customerId'), "accountId": loan_data.get('accountId'), "productId": str(loan_data.get('productId', "")), "provideAmount": loan_data.get('currentLoanAmount'), } response = SimbrellaClient.disburse_loan(data) return response @autocall_bp.route("/retry-disbursement", methods=["POST"]) def retry_disbursement(): """ This takes in a transaction id as input and retries the disbursement for the loan with that transaction id. This is to be used in cases where the disbursement failed due to network issues or other transient issues. It will call the disbursement endpoint with the loan data for the loan with the given transaction id. """ try: data = request.get_json() logger.info(f"Retry Transaction ID Data Received for :::: {data}") transactionId = data["transactionId"] logger.info(f"Starting Transaction ID Data Received for :::: {transactionId}") logger.info(f"Calling Disbursement Components for Retry Transaction ID Data Received for :::: {transactionId}") loan = LoanService.get_loan_by_transaction_id(transactionId) if not loan: logger.info(f"No loan found without disbursement date") return 0 logger.info(f"Calling DisburseLoan endpoint with data: {loan}") loan_data = loan.to_dict() data = { "transactionId": loan_data.get('transactionId'), "FbnTransactionId": loan_data.get('transactionId'), "debtId": str(loan_data.get('debtId')), "customerId": loan_data.get('customerId'), "accountId": loan_data.get('accountId'), "productId": str(loan_data.get('productId', "")), "provideAmount": loan_data.get('currentLoanAmount'), } response = SimbrellaClient.disburse_loan(data) # return response logger.info(f"Retry Disbursement Transaction ID Result Received for :::: {response}") return ResponseHelper.success(message="Retry Disbursement Request Sent Successfully", status_code=200) except Exception as e: logger.error(f"Failed to call retry disbursement {data}: {e}") @autocall_bp.route("/retry-failed-disbursements", methods=["GET"]) def retry_failed_disbursements(): """ This endpoint is for retrying failed disbursements. It will get the list of failed disbursements and call the disbursement endpoint for each of them. This is to be used in cases where the disbursement failed due to network issues or other transient issues. It will call the disbursement endpoint with the loan data for the loan with the given transaction id. """ try: logger.info(f"Calling Retry Failed Disbursements Components") failed_disbursements = LoanService.get_failed_disbursements() if not failed_disbursements: logger.info(f"No failed disbursements found") return ResponseHelper.success(message="No failed disbursements found", status_code=200) logger.info(f"Found {len(failed_disbursements)} failed disbursements to retry") #get batch size from settings # Safe config values batch_size = max(1, settings.FAILED_DISBURSEMENT_BATCH_SIZE or 1) delay_seconds = max(0, settings.FAILED_DISBURSEMENT_DELAY_SECONDS or 0) batch_delay = max(0, settings.FAILED_DISBURSEMENT_BATCH_DELAY_SECONDS or 0) for i in range(0, len(failed_disbursements), batch_size): batch = failed_disbursements[i:i + batch_size] for loan in batch: logger.info(f"Retrying disbursement for loan ID {loan.id}") loan_data = loan.to_dict() data = { "transactionId": loan_data.get('transactionId'), "FbnTransactionId": loan_data.get('transactionId'), "debtId": str(loan_data.get('debtId')), "customerId": loan_data.get('customerId'), "accountId": loan_data.get('accountId'), "productId": str(loan_data.get('productId', "")), "provideAmount": loan_data.get('currentLoanAmount'), } response = SimbrellaClient.disburse_loan(data) logger.info(f"Retry Disbursement Result Received for loan ID {loan.id}: {response}") time_module.sleep(delay_seconds) time_module.sleep(batch_delay) return ResponseHelper.success(message="Retry Failed Disbursements Request Sent Successfully", status_code=200) except Exception as e: logger.error(f"Failed to retry disbursements: {e}") return ResponseHelper.error("Failed to retry disbursements", status_code=500, error=str(e)) @autocall_bp.route("/direct/loan", methods=["POST"]) def direct_loan(): """ This endpoint is for directly calling the disbursement endpoint with a transaction id. This is to be used in cases where the disbursement failed due to network issues or other transient issues. It will call the disbursement endpoint with the loan data for the loan with the given transaction id. """ data = request.get_json() logger.info(f"Data received: {data}") REQUIRED_KEYS = [ "transactionId" ] # Check for missing keys missing_keys = [key for key in REQUIRED_KEYS if key not in data or data[key] is None] if missing_keys: logger.warning(f"Missing required keys: {missing_keys}") return jsonify({ "status": "error", "message": f"Missing required fields: {', '.join(missing_keys)}" }), 400 # Check if the loan exists logger.info(f"Checking if loan with transaction id {data['transactionId']} exists") transaction_id = data["transactionId"].strip() loan = LoanService.get_loan_by_transaction_id(transaction_id=transaction_id) if not loan: logger.warning(f"Loan with transaction id {transaction_id} does not exist") return jsonify({ "status": "error", "message": f"Loan with transaction id {transaction_id} does not exist" }), 400 loan_data = loan.to_dict() # Prevent double disbursement if loan_data.get('disburseDate') is not None: return jsonify({ "status": "error", "message": f"Loan with transaction id {data['transactionId']} has already been processed" }), 400 data_to_process = { "transactionId": loan_data.get('transactionId'), "FbnTransactionId": loan_data.get('transactionId'), "debtId": str(loan_data.get('debtId')), "customerId": loan_data.get('customerId'), "accountId": loan_data.get('accountId'), "productId": str(loan_data.get('productId', "")), "provideAmount": loan_data.get('currentLoanAmount'), } response = SimbrellaClient.disburse_loan(data_to_process) return response @autocall_bp.route("/direct/repayment", methods=["POST"]) def direct_repayment(): """ This endpoint is for directly calling the collect loan endpoint with a transaction id. """ data = request.get_json() logger.info(f"Data received: {data}") REQUIRED_KEYS = ["transactionId"] # Check for missing keys missing_keys = [key for key in REQUIRED_KEYS if key not in data or data[key] is None] if missing_keys: logger.warning(f"Missing required keys: {missing_keys}") return jsonify({ "status": "error", "message": f"Missing required fields: {', '.join(missing_keys)}" }), 400 # Check if the loan exists logger.info(f"Checking if loan with transaction id {data['transactionId']} exists") loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId']) if not loan: logger.info(f"Loan with transaction id {data['transactionId']} does not exist") return jsonify({ "status": "error", "message": f"Loan with transaction id {data['transactionId']} does not exist" }), 400 loan_data = loan.to_dict() # check if loan has been repaid if loan_data.get("status") == LoanStatus.REPAID and loan_data.get("balance") <= 0: logger.info(f"Loan with Id {loan_data.get('debtId')} has been repaid") return jsonify({ "status": "error", "message": f"loan with Id {loan_data.get('debtId')} has been repaid" }), 400 repayment_data = { "customerId": loan_data.get("customerId"), "loanId": loan_data.get("debtId"), "productId": loan_data.get("productId"), "transactionId": loan_data.get("transactionId"), "initiatedBy": "USER INITIATED", "salaryAmount": 0, "LoanStatus": loan_data.get("status"), } logger.info(f"Creating repayment with data: {repayment_data}") try: repayment = RepaymentService.create_repayment(repayment_data) logger.info(f"Repayment created: {repayment}") except Exception as e: db.session.rollback() logger.error(f"Repayment creation raised exception: {e}") return jsonify({ "status": "error", "message": "Failed to create repayment" }), 500 if not repayment or (isinstance(repayment, dict) and "error" in repayment): db.session.rollback() logger.error(f"Repayment creation failed for loan ID {loan_data.get('debtId')}: {repayment}") try: if loan_data.get('status') == LoanStatus.ACTIVE: LoanService.update_status(loan_id=loan_data.get('debtId'), status=LoanStatus.START_REPAY) except Exception as e: db.session.rollback() logger.error(f"Failed to update loan status for loan ID {loan_data.get('debtId')}: {e}") repayment_data_dict = repayment.to_dict() data_to_process = { "transactionId": repayment_data_dict['transactionId'], "debtId": repayment_data_dict['loanId'], "customerId": repayment_data_dict['customerId'], "productId": repayment_data_dict['productId'], "Id":repayment_data_dict['Id'] } response = SimbrellaClient.collect_loan_user_initiated(data_to_process) return response @autocall_bp.route("/refresh-verify-collection", methods=["GET"]) def refresh_verify_collection(): """ This endpoint is for directly calling the verify collection endpoint. """ data = request.get_json() logger.info(f"Calling Verify Collection") response = SimbrellaClient.collect_loan(data) return response @autocall_bp.route("/refresh-collection", methods=["GET"]) def refresh_collection(): """ This endpoint is for directly calling the collect loan endpoint. It will get the latest repayment with no repay date and call the collect loan endpoint with that repayment data. This is to be used in cases where the collection failed due to network issues or other transient issues. It will call the collection endpoint """ #data = request.get_json() logger.info(f"Calling Collection ") #grab the last repayments with repay date is none repayment = RepaymentService.get_latest_repayment_without_repay_date() #repayment = RepaymentService.get_latest_repayment_with_loanId(13735) if not repayment: logger.info(f"No repayment found without repay date") return 0 logger.info(f"Calling repay loan endpoint with data: {repayment}") repayment_data = repayment.to_dict() logger.info(f"here is the dict form of repayment {repayment_data}") data = { "transactionId": repayment_data['transactionId'], "debtId": repayment_data['loanId'], "customerId": repayment_data['customerId'], "productId": repayment_data['productId'], "Id":repayment_data['Id'] } logger.info(f"Data being sent to Simbrella: {data}") logger.info(f"calling simbrella") response = SimbrellaClient.collect_loan_user_initiated(data) return response @autocall_bp.route("/payment-callback", methods=["POST"]) def payment_callback(): data = request.get_json() logger.info(f"Calling Callback Components") response = SimbrellaClient.payment_callback(data) return response @autocall_bp.route("/penal-charge", methods=["POST"]) def penal_charge(): """ This endpoint is for directly calling the penal charge endpoint. """ data = request.get_json() logger.info(f"Calling Penal Charge Endpoint") try: response = SimbrellaClient.penal_charge(data[0]) return response except Exception as e: logger.error(f"Error in Penal Charge: {e}") return ResponseHelper.error("Penal charge failed") @autocall_bp.route("/analytic-salary-detect", methods=["POST"]) def salary_detect(): """ Creates new salary data Gets the salary list not yet processed then, gets the loan list for each salary and creates repayments for each loan with the salary data, then calls the collect loan endpoint for each repayment created. """ payload = request.get_json() logger.info("Calling Salary Detect endpoint") if payload is None: logger.warning("No payload received in request") return ResponseHelper.error("Missing request payload", status_code=400) # Step 1: Try to add new salary data try: new_salary = SalaryService.add_salary_data(payload) # TODO - This will come as array of salaries - not just one if new_salary: logger.info(f"Salary added: {new_salary.id}") except Exception as e: logger.error(f"Failed to save salary: {e}") # Step 2: Try processing salary list try: process_salary_list() except Exception as e: logger.exception("Unhandled error occurred while processing salary list {e}") return ResponseHelper.error("Failed to process salary list", status_code=500, error=str(e)) logger.info("Finished processing List") return ResponseHelper.success([], "AutoCall Add Salary Successful") @autocall_bp.route("/analytic-salary-process", methods=["POST"]) def salary_process(): response = process_salary_list() return ResponseHelper.success([], "AutoCall Successful") def process_salary_list(): # Step 1: Get all pending salaries pending_salaries = SalaryService.get_pending_salaries() if not pending_salaries: logger.info("No pending salaries found") return ResponseHelper.success([], "No pending salaries") logger.info(f"Found {len(pending_salaries)} pending salaries to process") for pending_salary in pending_salaries: logger.info(f"Processing salary ID: {pending_salary.id}") # Step 2: Update salary status to PROCESSING try: SalaryService.update_status(pending_salary.id, "PROCESSING") except Exception as e: db.session.rollback() logger.warning(f"Could not update status for salary ID {pending_salary.id}: {e}") continue # Step 3: Get customer's active loans try: loans, total_amount = LoanService.get_customer_active_loans(pending_salary.customer_id) if not loans: logger.warning(f"No loans found for customer ID: {pending_salary.customer_id}") continue except Exception as e: db.session.rollback() logger.error(f"Error fetching loans for customer ID {pending_salary.customer_id}: {e}") continue # Step 4: Create repayments for each loan for loan in loans: logger.info(f"Processing Loan ID: {loan.id}") #check if the loan has been repaid if loan.status in [LoanStatus.REPAID] and loan.balance <= 0: logger.info(f"Skipping loan ID {loan.id} because it is already repaid/closed") continue try: repayment_data = { "customerId": loan.customer_id, "loanId": loan.id, "productId": loan.product_id, "transactionId": loan.transaction_id, "initiatedBy": "SALARY_DETECT", "salaryAmount": pending_salary.amount, "LoanStatus": loan.status, } logger.info(f"Creating repayment with data: {repayment_data}") repayment = RepaymentService.create_repayment(repayment_data) if not repayment or isinstance(repayment, dict) and "error" in repayment: db.session.rollback() # important in case create_repayment failed mid-way logger.error(f"Repayment creation failed for loan ID {loan.id}: {repayment}") continue try: if loan.status == LoanStatus.ACTIVE: LoanService.update_status(loan_id=loan.id, status=LoanStatus.START_REPAY) except Exception as e: db.session.rollback() logger.error(f"Failed to update loan status for loan ID {loan.id}: {e}") logger.info(f"Created repayment ID: {repayment.id}") # Step 5: Call Simbrella try: simbrella_response = SimbrellaClient.collect_loan_user_salary_detect(repayment.to_dict()) if isinstance(simbrella_response, tuple): simbrella_response, status_code = simbrella_response logger.warning(f"Simbrella returned tuple: status={status_code}, response={simbrella_response}") if isinstance(simbrella_response, dict): if simbrella_response.get("status") != "success": logger.warning(f"Simbrella failed for repayment ID {repayment.id}: {simbrella_response}") else: logger.warning(f"Unexpected Simbrella response: {type(simbrella_response)}") except Exception as e: logger.error(f"Failed to call Simbrella for repayment ID {repayment.id}: {e}") except Exception as e: db.session.rollback() logger.error(f"Error creating repayment for loan ID {loan.id}: {e}") continue logger.info(f"Finished processing salary ID: {pending_salary.id}") return ResponseHelper.success([], "Processed all pending salaries") @autocall_bp.route("/report", methods=["GET"]) def report(): """This endpoint is for generating the report and sending the email.""" try: report_data = get_report_data() logger.info(f"Generated report data: {report_data}") send_report_email( report_data, recipients = [email.strip() for email in settings.MAIL_RECEIVER.split(",")]) logger.info(f"Report sent successfully") return ResponseHelper.success(message="Report sent successfully",status_code=200) except Exception as e: logger.error(f"Error generating or sending report: {e}") return ResponseHelper.error("Failed to send report", status_code=500, error=str(e)) @autocall_bp.route("/process-penal-charges", methods=["GET"]) def process_penal_charges(): """ This endpoint is for processing penal charges for overdue loans schedule with grace period. It will check for overdue loans, calculate the penal charge, create a new penal charge record, update the loan and repayment schedule with the new penal charge, and call the Simbrella endpoint to update the penal charge on their system. """ try: OVERDUE_GRACE_PERIOD_DAYS = settings.OVERDUE_GRACE_PERIOD_DAYS OVERDUE_PROCESSING_LIST_LIMIT = settings.OVERDUE_PROCESSING_LIST_LIMIT PENAL_CHARGE_MAXIMUM_COUNT = settings.PENAL_CHARGE_MAXIMUM_COUNT PENAL_CHARGE_INTERVAL_DAYS = settings.PENAL_CHARGE_INTERVAL_DAYS now = datetime.now(timezone.utc) overdue_schedules = ( LoanRepaymentScheduleService .get_overdue_repayment_schedule_with_grace_period( OVERDUE_GRACE_PERIOD_DAYS, OVERDUE_PROCESSING_LIST_LIMIT ) ) logger.info(f"Found {len(overdue_schedules)} overdue loan schedule.") if not overdue_schedules: return ResponseHelper.success( message="No overdue loan schedule found", status_code=200 ) processed_loans = [] for schedule in overdue_schedules: loan = LoanService.get_loan_by_loan_id(schedule.loan_id) if not loan: logger.info(f"Loan with id {schedule.loan_id} not found") continue penal_count = schedule.penal_count or 0 # MAX PENAL CHECK if penal_count >= PENAL_CHARGE_MAXIMUM_COUNT: logger.info( f"Penal count for schedule {schedule.id} has reached the maximum limit." ) continue # INTERVAL CHECK (PER SCHEDULE) if schedule.last_penal_date: # ensure last_penal_date is timezone-aware last_penal = schedule.last_penal_date if last_penal.tzinfo is None: last_penal = last_penal.replace(tzinfo=timezone.utc) next_allowed_date = last_penal + timedelta(days=PENAL_CHARGE_INTERVAL_DAYS) if now < next_allowed_date: logger.info( f"Penal interval for schedule {schedule.id} has not passed yet." ) continue # NEXT PENAL NUMBER next_penal_no = penal_count + 1 # CALCULATE PENAL penal_amount = LoanRepaymentScheduleService.calculate_penal_charge(schedule) # CREATE PENAL CHARGE new_penal_charge = LoanChargesService.create_penal_charges_for_loan( loan_id=schedule.loan_id, transaction_id=schedule.transaction_id, percent=settings.PENAL_CHARGE_PERCENTAGE, penal_no=next_penal_no, schedule_number=schedule.installment_number, penal_amount=penal_amount ) if not new_penal_charge: logger.error(f"Failed to create penal charge for loan ID: {loan.id}") continue logger.info(f"Penal charge created: {new_penal_charge.to_dict()}") # UPDATE SCHEDULE LoanRepaymentScheduleService.apply_penal_to_schedule( schedule.id, penal_amount ) logger.info(f"Penal charge applied to schedule {schedule.id}") # UPDATE LOAN TOTAL LoanService.apply_penal_to_loan( loan.id, penal_amount ) logger.info(f"Penal charge applied to loan {loan.id}") processed_loans.append(loan.to_dict()) return ResponseHelper.success( message="Penal Charges Processed Successfully", status_code=200, data=processed_loans ) except Exception as e: logger.exception(f"Error processing penal charges: {e}") return ResponseHelper.error( "Failed to process penal charges", status_code=500, error=str(e) ) @autocall_bp.route("/overdue-loans", methods=["GET"]) def overdue_loans(): """ This endpoint is for processing overdue loans. It will get all active overdue loan schedules, and then for each loan schedule, it will create a repayment, update the loan status, and call the Simbrella endpoint to collect the loan. """ try: # Step 1: Get all active overdue loans overdue_loans = LoanRepaymentScheduleService.get_active_overdue_repayment_schedule() logger.info(f"Found {len(overdue_loans)} overdue loans.") if not overdue_loans: logger.info("No overdue loans found.") return ResponseHelper.success(message="No overdue loans found", status_code=200) #get batch size from settings loan_delay_seconds = max(0, settings.OVERDUE_LOAN_DELAY_SECONDS) batch_delay_seconds = max(0, settings.OVERDUE_LOAN_BATCH_DELAY_SECONDS) batch_size = max(1, settings.OVERDUE_LOAN_BATCH_SIZE) loan_chunks = list(CollectLoanHelper.chunk_list(overdue_loans, batch_size)) logger.info(f"Found {len(loan_chunks)} loan chunks to process.") # Step 2: Process each loan for chunk_index, loan_chunk in enumerate(loan_chunks): logger.info(f"Processing chunk {chunk_index + 1} of {len(loan_chunks)} with {len(loan_chunk)} loans.") for loan in loan_chunk: try: process_overdue_loan(loan) except Exception: logger.exception(f"Failed processing loan {loan.id}") finally: time_module.sleep(loan_delay_seconds) if chunk_index < len(loan_chunks) - 1: logger.info(f"Waiting {batch_delay_seconds} seconds before processing next chunk...") time_module.sleep(batch_delay_seconds) # Delay between chunks return ResponseHelper.success(message="Processed overdue loans successfully", status_code=200) except Exception as e: logger.exception(f"Error fetching overdue loans: {e}") return ResponseHelper.error("Failed to fetch overdue loans", status_code=500, error=str(e)) def process_overdue_loan(loan): """ Handles repayment creation, loan status update, and Simbrella call for a single overdue loan. """ logger.info(f"Processing Loan ID: {loan.loan_id}") full_loan_data = LoanService.get_loan_by_loan_id(loan.loan_id) logger.info(f"full loan details: {full_loan_data.to_dict()}") if not full_loan_data: logger.warning(f"Full Loan ID {loan.loan_id} not found in database") else: #lets check if the loan with the repayment has been repaid, then update the loan schedule to paid if full_loan_data.to_dict().get("status") == LoanStatus.REPAID and full_loan_data.to_dict().get("balance") == 0: try: LoanRepaymentScheduleService.update_repayment_schedule_status(loan.id) logger.info(f"Updated Loan Repayment Schedule ID {loan.id} to PAID") return except Exception as e: db.session.rollback() logger.error(f"Failed to update Loan Repayment Schedule ID {loan.id} to PAID: {e}") customer_id = full_loan_data.to_dict().get("customerId") loan_status = full_loan_data.to_dict().get("status") try: repayment_data = { "customerId": customer_id, "loanId": loan.loan_id, "productId": loan.product_id, "transactionId": loan.transaction_id, "initiatedBy": "SYSTEM", # To be reviewed "salaryAmount": 0, "LoanStatus": loan_status, } logger.info(f"Creating repayment with data: {repayment_data}") repayment = RepaymentService.create_repayment(repayment_data) if not repayment or (isinstance(repayment, dict) and "error" in repayment): db.session.rollback() # important in case create_repayment failed mid-way logger.error(f"Repayment creation failed for loan ID {loan.loan_id}: {repayment}") return # Update loan status try: logger.info(f"Updating loan status for loan ID {loan.loan_id}") LoanService.update_status(loan_id=loan.loan_id, status=LoanStatus.START_REPAY) except Exception as e: db.session.rollback() logger.error(f"Failed to update loan status for loan ID {loan.loan_id}: {e}") logger.info(f"Created repayment ID: {repayment.id}") # Step 3: Call Simbrella try: #lets add the overdue loan schedule id and amount we are currently processing to the repayment data if loan.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID: amount = loan.partial_balance or 0 else: amount = loan.installment_amount repayment_data["overdueLoanScheduleAmount"] = amount repayment_data["overdueLoanScheduleId"] = loan.id repayment_data["Id"] = repayment.id logger.info(f"Calling Simbrella for with repayment data: {repayment_data}") simbrella_response = SimbrellaClient.collect_loan_user_due_payment(repayment_data) if isinstance(simbrella_response, tuple): simbrella_response, status_code = simbrella_response logger.warning(f"Simbrella returned tuple: status={status_code}, response={simbrella_response}") if isinstance(simbrella_response, dict): if simbrella_response.get("status") != "success": logger.warning(f"Simbrella failed for repayment ID {repayment.id}: {simbrella_response}") else: logger.warning(f"Unexpected Simbrella response: {type(simbrella_response)}") except Exception as e: logger.error(f"Failed to call Simbrella for repayment ID {repayment.id}: {e}") except Exception as e: db.session.rollback() logger.error(f"Error creating repayment for loan ID {loan.id}: {e}") finally: logger.info(f"Finished processing loan ID: {loan.id}")