From 7fbb659fc6c6032cbeb22f5fe48b11d6c626e261 Mon Sep 17 00:00:00 2001 From: Chinenye Nmoh Date: Thu, 19 Jun 2025 21:30:21 +0100 Subject: [PATCH 1/3] added salary table --- app/models/loan.py | 12 ++++- app/models/repayment.py | 45 +++++++++++++++++++ app/models/salary.py | 29 +++++++++++- app/routes/autocall.py | 95 ++++++++++++++++++++++++--------------- app/services/loan.py | 8 ++++ app/services/repayment.py | 8 +++- app/services/salary.py | 8 +++- 7 files changed, 164 insertions(+), 41 deletions(-) diff --git a/app/models/loan.py b/app/models/loan.py index 6a595ec..845e9c2 100644 --- a/app/models/loan.py +++ b/app/models/loan.py @@ -218,4 +218,14 @@ class Loan(db.Model): return cls.query.filter( cls.disburse_date.isnot(None), cls.disburse_verify.is_(None) - ).order_by(cls.created_at.desc()).first() \ No newline at end of file + ).order_by(cls.created_at.desc()).first() + + @classmethod + def get_customer_loans(cls, customer_id): + """ + Get customer's active loans by customer_id. + """ + customer_loans = cls.query.filter_by( customer_id = customer_id).all() + if not customer_loans: + raise ValueError(f"Customer with Id {customer_id} does not have any loan.") + return customer_loans \ No newline at end of file diff --git a/app/models/repayment.py b/app/models/repayment.py index 7fd9ea2..8997b8e 100644 --- a/app/models/repayment.py +++ b/app/models/repayment.py @@ -14,6 +14,8 @@ class Repayment(db.Model): customer_id = db.Column(db.String(50), nullable=False) product_id = db.Column(db.String(20), nullable=True) transaction_id = db.Column(db.String(50), nullable=False) + initiated_by = db.Column(db.String(50), nullable=True) + salary_amount = db.Column(db.Float, nullable=True, default=0.0) created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) repay_date = db.Column(db.DateTime, nullable=True) @@ -39,12 +41,55 @@ class Repayment(db.Model): 'verifyResult': self.verify_result, 'verifyDescription': self.verify_description, 'transactionId': self.transaction_id, + 'initiatedBy':self.initiated_by, + 'salaryAmount':self.salary_amount, 'repayDate': self.repay_date.isoformat() if self.repay_date else None, 'VerifyDate': self.verify_date.isoformat() if self.verify_date else None, } + @classmethod + def add_repayment(cls, data: dict): + """ + Create and persist a new repayment record. + """ + logger.info(f"Received repayment data: {data}") + try: + new_repayment = cls( + loan_id=data["loanId"], + customer_id=data["customerId"], + product_id=data.get("productId"), + transaction_id=data["transactionId"], + initiated_by=data.get("initiatedBy"), + salary_amount=float(data.get("salaryAmount", 0.0)), + repay_date=( + datetime.strptime(data["repayDate"], "%Y-%m-%d") + .replace(tzinfo=timezone.utc) + if data.get("repayDate") + else None + ), + repay_result=data.get("repayResult"), + repay_description=data.get("repayDescription"), + verify_result=data.get("verifyResult"), + verify_description=data.get("verifyDescription"), + verify_date=( + datetime.strptime(data["verifyDate"], "%Y-%m-%d") + .replace(tzinfo=timezone.utc) + if data.get("verifyDate") + else None + ), + ) + + db.session.add(new_repayment) + db.session.commit() + logger.info("Repayment record committed.") + return new_repayment + + except Exception as e: + db.session.rollback() + logger.error(f"Error adding repayment data: {e}") + raise @classmethod def get_repayment_by_transaction_id(cls, transaction_id): return cls.query.filter_by(transaction_id=transaction_id).first() diff --git a/app/models/salary.py b/app/models/salary.py index 52c21b9..d11315b 100644 --- a/app/models/salary.py +++ b/app/models/salary.py @@ -45,7 +45,7 @@ class Salary(db.Model): try: new_data = cls( customer_id=data.get('customerId'), - amount=data.get('salaryAmount', 0.0), + amount=data.get('amount', 0.0), status='START', salary_date = datetime.strptime(data.get('salaryDate'), "%Y-%m-%d").date() if data.get('salaryDate') else None, account_id=data.get('accountId') @@ -69,3 +69,30 @@ class Salary(db.Model): except Exception as e: logger.error(f"Error fetching pending salaries: {str(e)}") return [] + @classmethod + def update_status(cls, salary_id, status): + """ + Update the status of the salary record with the given salary_id. + """ + try: + # Retrieve salary record + salary = cls.query.get(salary_id) + + if not salary: + raise ValueError(f"Salary with ID {salary_id} does not exist.") + + if salary.status == status: + return salary.to_dict() # Still return the current state if no change + + # Update status and timestamp + salary.status = status + salary.updated_at = datetime.now(timezone.utc) # Manually update timestamp if not auto-updating + db.session.commit() + + logger.info("Salary status updated and committed.") + return salary.to_dict() + + except Exception as e: + db.session.rollback() + logger.error(f"Error updating salary status: {e}") + raise Exception(f"Error updating salary status: {str(e)}") diff --git a/app/routes/autocall.py b/app/routes/autocall.py index c2aff66..38c2a63 100644 --- a/app/routes/autocall.py +++ b/app/routes/autocall.py @@ -113,53 +113,74 @@ def penal_charge(): @autocall_bp.route("/analytic-salary-detect", methods=["POST"]) def salary_detect(): - #*************************************************** - # PART 1 Accept any new import of salary detection - #************************************************** - data = request.get_json() - logger.info(f"Calling Salary Detect Endpoints") + payload = request.get_json() + logger.info(f"Calling Salary Detect endpoint") + # Attempt to add the incoming salary data try: - salary = SalaryService.add_salary_data(data) - if salary: - logger.info(f"Successful Salary Added") - # return ResponseHelper.success(salary.to_dict(), "Successful") + new_salary = SalaryService.add_salary_data(payload) + if new_salary: + logger.info(f"Salary added: {new_salary.id}" ) except Exception as e: - logger.info(f"Failed to save salary: {e}") - - # *************************************************** - # PART 2 SELECT * FROM salaries WHERE status IS 'START' ORDER BY id ASC - # ************************************************** + logger.info(f"Failed to save salary: ", e) + # Fetch all pending salaries pending_salaries = SalaryService.get_pending_salaries() - if not pending_salaries: logger.info(f"No pending salaries found") - # return ResponseHelper.success([], "No pending salaries found") + return ResponseHelper.success([], "No pending salaries") - logger.info(f"Found {len(pending_salaries)} pending salaries") + logger.info(f"Found pending salaries {len(pending_salaries)}" ) - #in the loop - # USE the customerID to find thu user Loan - # if loan is/are found for the user - # INSERT INTO repayments TABLE - # repayment = cls( - # customer_id=customer_id, - # loan_id=loan.id, - # product_id=loan.product_id, - # transaction_id = transaction_id, - # created_at=datetime.now(timezone.utc), - # updated_at=datetime.now(timezone.utc), - # initiated_by='SALARY_DETECT' - # ) + for pending_salary in pending_salaries: + logger.info(f"Processing salary ID: {pending_salary.id}" ) - # by the time you call this you must be on repayment table + # Step 1: Update status to PROCESSING early to avoid race condition + try: + SalaryService.update_status(pending_salary.id, "PROCESSING") + except Exception as e: + logger.info(f"Failed to update status to PROCESSING for salary ID : {pending_salary.id} {e}" ) + continue + + # Step 2: Fetch loans for this salary's customer + try: + loans = LoanService.get_customer_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: + logger.info(f"Error fetching loans for customer ID : {pending_salary.customer_id} {e}") + continue + + # Step 3: Loop through loans and create repayment entries + for loan in loans: + try: + loan_dict = loan.to_dict() + repayment_data = { + "customerId": pending_salary.customer_id, + "loanId": loan_dict["debtId"], + "productId": loan_dict["productId"], + "transactionId": loan_dict["transactionId"], + "initiatedBy": "SALARY_DETECT", + "salaryAmount": pending_salary.amount, + } + logger.info(f"Creating repayment for loan ID {loan_dict["debtId"]}") + repayment = RepaymentService.add_repayment(repayment_data) + logger.info(f"Created repayment ID: {repayment.id}", ) + except Exception as e: + logger.info(f"Error creating repayment for loan ID : {loan.id} {e}" ) + continue + + # Step 4: Optionally update salary to DONE after all loan processing + try: + SalaryService.update_status(pending_salary.id, "DONE") + except Exception as e: + logger.info(f"Failed to mark salary ID as DONE: {pending_salary.id} {e}") + + # Step 5: Call Simbrella integration after processing all salaries try: - SimbrellaClient.collect_loan_user_salary_detect(data) + SimbrellaClient.collect_loan_user_salary_detect(payload) except Exception as e: - logger.info(f"Failed to call collect_loan_user_salary_detect: {e}") + logger.info(f"Failed to call Simbrella client: {e}") - - return_data=[] - - return ResponseHelper.success(return_data, "AutoCall Successful") + return ResponseHelper.success([], "AutoCall Successful") diff --git a/app/services/loan.py b/app/services/loan.py index 6d1379c..f1d9910 100644 --- a/app/services/loan.py +++ b/app/services/loan.py @@ -70,3 +70,11 @@ class LoanService: Get the latest loan without a disbursement date. """ return Loan.get_latest_loan_with_disburse_date() + + @classmethod + def get_customer_loans(cls, customer_id): + """ + Get customer's active loans by customer_id. + """ + + return Loan.get_customer_loans(customer_id=customer_id) diff --git a/app/services/repayment.py b/app/services/repayment.py index 3784a98..6e52215 100644 --- a/app/services/repayment.py +++ b/app/services/repayment.py @@ -55,4 +55,10 @@ class RepaymentService: """ Get the latest repayment with a repay date and no verification date. """ - return Repayment.get_latest_loan_with_repay_date() \ No newline at end of file + return Repayment.get_latest_loan_with_repay_date() + @classmethod + def add_repayment(cls, data): + """ + Add a new repayment entry. + """ + return Repayment.add_repayment(data) \ No newline at end of file diff --git a/app/services/salary.py b/app/services/salary.py index c0cbbf3..f85e9af 100644 --- a/app/services/salary.py +++ b/app/services/salary.py @@ -15,4 +15,10 @@ class SalaryService: """ Get the pending salary for a given customer. """ - return Salary.get_pending_salaries() \ No newline at end of file + return Salary.get_pending_salaries() + @classmethod + def update_status(cls, salary_id, status): + """ + Update the status of the salary with the given salary_id. + """ + return Salary.update_status(salary_id, status) \ No newline at end of file From 684833bd667a930353ba13fd5cde1fdd7e8d845a Mon Sep 17 00:00:00 2001 From: Chinenye Nmoh Date: Thu, 19 Jun 2025 23:23:06 +0100 Subject: [PATCH 2/3] added loop --- app/models/loan.py | 3 +- app/routes/autocall.py | 65 ++++++++++++++++++++++-------------------- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/app/models/loan.py b/app/models/loan.py index 845e9c2..d26b5cc 100644 --- a/app/models/loan.py +++ b/app/models/loan.py @@ -227,5 +227,6 @@ class Loan(db.Model): """ customer_loans = cls.query.filter_by( customer_id = customer_id).all() if not customer_loans: - raise ValueError(f"Customer with Id {customer_id} does not have any loan.") + raise ValueError(f"Customer with Id {customer_id} does not have any loan.") + logger.info(f"Found {len(customer_loans)} loans for customer ID: {customer_id}") return customer_loans \ No newline at end of file diff --git a/app/routes/autocall.py b/app/routes/autocall.py index 38c2a63..3695371 100644 --- a/app/routes/autocall.py +++ b/app/routes/autocall.py @@ -105,54 +105,59 @@ def payment_callback(): @autocall_bp.route("/penal-charge", methods=["POST"]) def penal_charge(): data = request.get_json() - logger.info(f"Calling Penal Charge Endpoints") + logger.info(f"Calling Penal Charge Endpoint") - response = SimbrellaClient.penal_charge(data[0]) + 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") - return response @autocall_bp.route("/analytic-salary-detect", methods=["POST"]) def salary_detect(): payload = request.get_json() - logger.info(f"Calling Salary Detect endpoint") + logger.info("Calling Salary Detect endpoint") - # Attempt to add the incoming salary data + # Step 1: Try to add new salary data try: new_salary = SalaryService.add_salary_data(payload) if new_salary: - logger.info(f"Salary added: {new_salary.id}" ) + logger.info(f"Salary added: {new_salary.id}") except Exception as e: - logger.info(f"Failed to save salary: ", e) + logger.error(f"Failed to save salary: {e}") - # Fetch all pending salaries + # Step 2: Get all pending salaries pending_salaries = SalaryService.get_pending_salaries() if not pending_salaries: - logger.info(f"No pending salaries found") + logger.info("No pending salaries found") return ResponseHelper.success([], "No pending salaries") - logger.info(f"Found pending salaries {len(pending_salaries)}" ) + logger.info(f"Found {len(pending_salaries)} pending salaries to process") + # Step 3: Process each salary for pending_salary in pending_salaries: - logger.info(f"Processing salary ID: {pending_salary.id}" ) + logger.info(f"Processing salary ID: {pending_salary.id}") - # Step 1: Update status to PROCESSING early to avoid race condition + # Step 3.1: Update status to PROCESSING try: SalaryService.update_status(pending_salary.id, "PROCESSING") except Exception as e: - logger.info(f"Failed to update status to PROCESSING for salary ID : {pending_salary.id} {e}" ) + logger.warning(f"Could not update status for salary ID {pending_salary.id}: {e}") continue - # Step 2: Fetch loans for this salary's customer + # Step 3.2: Get loans try: loans = LoanService.get_customer_loans(pending_salary.customer_id) if not loans: - logger.warning(f"No loans found for customer ID: {pending_salary.customer_id}" ) + logger.warning(f"No loans found for customer ID: {pending_salary.customer_id}") continue except Exception as e: - logger.info(f"Error fetching loans for customer ID : {pending_salary.customer_id} {e}") + logger.error(f"Error fetching loans for customer ID {pending_salary.customer_id}: {e}") continue - # Step 3: Loop through loans and create repayment entries + # Step 3.3: Create repayments for loan in loans: try: loan_dict = loan.to_dict() @@ -164,23 +169,21 @@ def salary_detect(): "initiatedBy": "SALARY_DETECT", "salaryAmount": pending_salary.amount, } - logger.info(f"Creating repayment for loan ID {loan_dict["debtId"]}") + logger.info(f"Creating repayment for loan ID {loan_dict['debtId']}") repayment = RepaymentService.add_repayment(repayment_data) - logger.info(f"Created repayment ID: {repayment.id}", ) + logger.info(f"Created repayment ID: {repayment.id}") except Exception as e: - logger.info(f"Error creating repayment for loan ID : {loan.id} {e}" ) + logger.error(f"Error creating repayment for loan ID {loan.id}: {e}") continue + # Step 4: Simbrella integration call after all processing + try: + SimbrellaClient.collect_loan_user_salary_detect(repayment.to_dict()) + except Exception as e: + logger.error(f"Failed to call Simbrella client: {e}") - # Step 4: Optionally update salary to DONE after all loan processing - try: - SalaryService.update_status(pending_salary.id, "DONE") - except Exception as e: - logger.info(f"Failed to mark salary ID as DONE: {pending_salary.id} {e}") - - # Step 5: Call Simbrella integration after processing all salaries - try: - SimbrellaClient.collect_loan_user_salary_detect(payload) - except Exception as e: - logger.info(f"Failed to call Simbrella client: {e}") + + logger.info(f"Finished processing salary ID: {pending_salary.id}") return ResponseHelper.success([], "AutoCall Successful") + + From bcd9513a10ee4ee6c5474bc8391f982ebc2a7ef0 Mon Sep 17 00:00:00 2001 From: Chinenye Nmoh Date: Fri, 20 Jun 2025 11:47:21 +0100 Subject: [PATCH 3/3] added loop --- app/integrations/simbrella.py | 16 ++++++++-------- app/models/repayment.py | 3 +++ app/routes/autocall.py | 3 ++- app/services/repayment.py | 6 ++++++ 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/app/integrations/simbrella.py b/app/integrations/simbrella.py index 19a9c99..54f622b 100644 --- a/app/integrations/simbrella.py +++ b/app/integrations/simbrella.py @@ -182,7 +182,7 @@ class SimbrellaClient: def collect_loan_user_initiated(data): # InitiatedBy = USER_INITIATED logger.info(f"Calling CollectLoan collect_loan_user_initiated ******* endpoint with data: {data}") - return SimbrellaClient._collect_loan(data,1) + return SimbrellaClient._collect_loan(data,"1") @staticmethod def collect_loan_user_salary_detect(data): @@ -198,28 +198,28 @@ class SimbrellaClient: # return ResponseHelper.error(message="Failed to call salary endpoint", # status_code=400, # error=str(e) ) - return SimbrellaClient._collect_loan(data,2) + return SimbrellaClient._collect_loan(data,"2") @staticmethod def collect_loan_user_due_payment(data): # InitiatedBy = REPAYMENT_DUE - return SimbrellaClient._collect_loan(data,3) + return SimbrellaClient._collect_loan(data,"3") @staticmethod - def _collect_loan(data, collectionMethod: int): + def _collect_loan(data, collectionMethod: str): api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}{SimbrellaClient.BANK_CALL_COLLECT_LOAN_ENDPOINT}" logger.info(f"Calling CollectLoan api_url==> : {api_url}") logger.info(f"Calling CollectLoan endpoint with data: {data}") # Check if the repayment exists logger.info(f"Checking if repayment exists") - repayment = RepaymentService.get_repayment_by_transaction_id(transaction_id=data['transactionId']) + repayment = RepaymentService.get_repayment_by_id(id=data['Id']) logger.info(f"Repayment Response From Database ** : {repayment}") if not repayment: - logger.info(f"Repayment with transactionId: {data['transactionId']}, was not found") + logger.info(f"Repayment with id: {data['Id']}, was not found") return ResponseHelper.error("Repayment not found") logger.info(f"Repayment Response From Database ** : {repayment.to_dict()}") repayment_data = repayment.to_dict() @@ -252,7 +252,7 @@ class SimbrellaClient: "customerId": repayment_data['customerId'], "accountId": loan_data['accountId'], "productId": repayment_data['productId'], - "collectAmount": loan_data['repaymentAmount'], + "collectAmount": loan_data['repaymentAmount'] or 0, "penalCharge": 5, "channel": "USSD", "collectionMethod": collectionMethod, @@ -263,7 +263,7 @@ class SimbrellaClient: try: logger.info(f"Here is your CollectLoan Request data ***** : {collect_loan_data}") - response = requests.post(api_url, json=collect_loan_data, headers=get_headers()) + response = requests.post(api_url, json=collect_loan_data,timeout=30, headers=get_headers()) logger.info(f"CollectLoan response: {response.json()}") RepaymentService.set_repay_result(repayment_data['Id'], response.json().get('responseCode', ''), response.json().get('responseMessage', '')) diff --git a/app/models/repayment.py b/app/models/repayment.py index 8997b8e..25fc866 100644 --- a/app/models/repayment.py +++ b/app/models/repayment.py @@ -93,6 +93,9 @@ class Repayment(db.Model): @classmethod def get_repayment_by_transaction_id(cls, transaction_id): return cls.query.filter_by(transaction_id=transaction_id).first() + @classmethod + def get_repayment_by_id(cls, id): + return cls.query.filter_by(id=id).first() @classmethod def set_repay_date(cls, repayment_id, customer_id): diff --git a/app/routes/autocall.py b/app/routes/autocall.py index 3695371..b0c551f 100644 --- a/app/routes/autocall.py +++ b/app/routes/autocall.py @@ -75,7 +75,7 @@ def refresh_collection(): 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 disbursement date") + 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() @@ -86,6 +86,7 @@ def refresh_collection(): "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") diff --git a/app/services/repayment.py b/app/services/repayment.py index 6e52215..e212ff2 100644 --- a/app/services/repayment.py +++ b/app/services/repayment.py @@ -8,6 +8,12 @@ class RepaymentService: Get the repayment by transaction ID """ return Repayment.get_repayment_by_transaction_id(transaction_id) + @staticmethod + def get_repayment_by_id(id): + """ + Get the repayment by ID + """ + return Repayment.get_repayment_by_id(id) @classmethod def set_repay_date(cls, repayment_id, customer_id):