diff --git a/app/config.py b/app/config.py index dbc0fa8..823ff42 100644 --- a/app/config.py +++ b/app/config.py @@ -67,6 +67,14 @@ class Config: PENAL_CHARGE_PERCENTAGE = os.getenv("PENAL_CHARGE_PERCENTAGE", 1) PENAL_CHARGE_INTERVAL_DAYS = os.getenv("PENAL_CHARGE_INTERVAL_DAYS", 30) PENAL_CHARGE_MAXIMUM_COUNT = os.getenv("PENAL_CHARGE_MAXIMUM_COUNT", 6) + + #processing failed disbursement sections + FAILED_DISBURSEMENT_BATCH_SIZE = int(os.getenv("FAILED_DISBURSEMENT_BATCH_SIZE", 10)) + FAILED_DISBURSEMENT_DELAY_SECONDS = int(os.getenv("FAILED_DISBURSEMENT_DELAY_SECONDS", 5)) + FAILED_DISBURSEMENT_BATCH_DELAY_SECONDS = int( + os.getenv("FAILED_DISBURSEMENT_BATCH_DELAY_SECONDS", 5) + ) + FAILED_RETRY_TIME_INTERVAL_SECONDS = int(os.getenv("FAILED_RETRY_TIME_INTERVAL_SECONDS", 86400)) #24 hours = 86400 seconds BANK_CALL_API_TIME_OUT = os.getenv("BANK_CALL_API_TIME_OUT", 100) diff --git a/app/enums/loan_status.py b/app/enums/loan_status.py index fa9f0c5..b5e53ca 100644 --- a/app/enums/loan_status.py +++ b/app/enums/loan_status.py @@ -5,4 +5,5 @@ class LoanStatus(str, Enum): ACTIVE = "active" ACTIVE_PARTIAL = "active_partial" START_REPAY = "start_repay" - REPAID = "repaid" \ No newline at end of file + REPAID = "repaid" + FAILED = "failed" \ No newline at end of file diff --git a/app/integrations/simbrella.py b/app/integrations/simbrella.py index 57cf740..245b19d 100644 --- a/app/integrations/simbrella.py +++ b/app/integrations/simbrella.py @@ -137,6 +137,8 @@ class SimbrellaClient: logger.error("") LoanService.set_disbursement_loan_description(loan_data['debtId'], "Disbursement Service url not found (404)") + logger.info(f"Loan status updated to {LoanStatus.FAILED} for loan ID {loan_data['debtId']} due to disbursement failure") + LoanService.update_status(loan_data['debtId'], LoanStatus.FAILED) return ResponseHelper.error("Disbursement Service url not found (404)", status_code=404) logger.info(f"Disbursement response: {response.json()}") @@ -165,6 +167,8 @@ class SimbrellaClient: errorMessage = "Unable to complete Disbursement Service with HTTP status code: " + str( response.status_code) LoanService.set_disbursement_loan_description(loan_data['debtId'], errorMessage) + updatedLoan = LoanService.update_status(loan_data['debtId'], LoanStatus.FAILED) + logger.info(f"Loan status updated to {updatedLoan.get('status')} for loan ID {loan_data['debtId']} due to disbursement failure") return ResponseHelper.error(errorMessage, status_code=response.status_code) except requests.exceptions.HTTPError as errh: diff --git a/app/models/loan.py b/app/models/loan.py index fae9acc..852f3af 100644 --- a/app/models/loan.py +++ b/app/models/loan.py @@ -9,6 +9,7 @@ from app.utils.logger import logger from app.extensions import db from decimal import Decimal, ROUND_HALF_UP from datetime import datetime, timezone +from app.config import settings class Loan(db.Model): __tablename__ = "loans" @@ -46,7 +47,6 @@ class Loan(db.Model): verify_result = db.Column(db.String(10), nullable=True) verify_description = db.Column(db.String(100), nullable=True) reference = db.Column(db.String(50), nullable=True) - total_penal_charge = db.Column(db.Float, default=0.0) last_penal_date = db.Column(db.DateTime, nullable=True) @@ -102,6 +102,19 @@ class Loan(db.Model): @classmethod def get_loan_by_transaction_id(cls, transaction_id): return cls.query.filter_by(transaction_id=transaction_id).first() + + #update loan status + @classmethod + def update_status(cls, loan_id, status): + loan = cls.query.get(loan_id) + if loan: + loan.status = status + db.session.commit() + return loan.to_dict() + else: + raise ValueError(f"Loan with ID {loan_id} not found.") + + @classmethod def get_loan_by_loan_id(cls, loan_id): @@ -250,6 +263,29 @@ class Loan(db.Model): except Exception as e: logger.error(f"Error fetching latest loan without disburse date: {e}") raise + + @classmethod + def get_failed_disbursements(cls): + """ + Get all loans with failed disbursement. + """ + try: + last_time_interval = settings.FAILED_RETRY_TIME_INTERVAL_SECONDS or 0 + failed_loans = cls.query.filter( + cls.disburse_date.is_(None), + cls.created_at >= datetime.utcnow() - timedelta(seconds=last_time_interval) + ).all() + + if not failed_loans: + logger.info("No failed disbursements found.") + return [] + + logger.info(f"Found {len(failed_loans)} failed disbursements.") + return failed_loans + + except Exception as e: + logger.error(f"Error fetching failed disbursements: {e}") + return [] @classmethod def get_latest_loan_with_disburse_date(cls): diff --git a/app/routes/autocall.py b/app/routes/autocall.py index b2d3101..a2472c6 100644 --- a/app/routes/autocall.py +++ b/app/routes/autocall.py @@ -26,6 +26,12 @@ autocall_bp = Blueprint("autocall", __name__) # @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() @@ -49,6 +55,11 @@ def verify_transaction(): @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() @@ -72,6 +83,14 @@ def disbursement(): @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}") @@ -103,8 +122,64 @@ def retry_disbursement(): 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}") @@ -156,6 +231,10 @@ def direct_loan(): @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}") @@ -239,6 +318,9 @@ def direct_repayment(): @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") @@ -248,6 +330,11 @@ def refresh_verify_collection(): @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 @@ -283,7 +370,11 @@ def payment_callback(): 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") @@ -296,6 +387,13 @@ def penal_charge(): @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") @@ -421,6 +519,7 @@ def process_salary_list(): @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}") @@ -435,6 +534,12 @@ def report(): @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 @@ -549,6 +654,12 @@ def process_penal_charges(): @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() diff --git a/app/services/loan.py b/app/services/loan.py index d090dbf..02e02b2 100644 --- a/app/services/loan.py +++ b/app/services/loan.py @@ -59,6 +59,21 @@ class LoanService: def set_disbursement_loan_description(cls,loan_id,description): return Loan.set_disbursement_message(loan_id, description) + + @classmethod + def get_failed_disbursements(cls): + """ + Get all loans with failed disbursement. + """ + return Loan.get_failed_disbursements() + + @classmethod + def update_status(cls, loan_id, status): + """ + Update the status of the loan with the given loan_id. + """ + # Retrieve loan + return Loan.update_status(loan_id, status) @classmethod diff --git a/openapi.yml b/openapi.yml index 4434beb..c4a8b9e 100644 --- a/openapi.yml +++ b/openapi.yml @@ -110,6 +110,12 @@ paths: responses: 200: description: A successful response + /autocall/retry-failed-disbursements: + get: + summary: Retry failed disbursements + responses: + 200: + description: A successful response /autocall/refresh-disbursement: get: summary: Refresh the disbursement