Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca3ca1cac3 |
+10
-10
@@ -3,14 +3,14 @@ KAFKA_TIMEOUT=1000.0
|
|||||||
KAFKA_BROKER="10.20.30.50:9092"
|
KAFKA_BROKER="10.20.30.50:9092"
|
||||||
KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT
|
KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT
|
||||||
|
|
||||||
# DATABASE_USER=firstadvance
|
DATABASE_USER=firstadvance
|
||||||
# DATABASE_PASSWORD=FirstAdvance!
|
DATABASE_PASSWORD=FirstAdvance!
|
||||||
# DATABASE_HOST=10.20.30.60
|
DATABASE_HOST=10.20.30.60
|
||||||
# DATABASE_PORT=5432
|
DATABASE_PORT=5432
|
||||||
# DATABASE_NAME=firstadvancedev
|
DATABASE_NAME=firstadvancedev
|
||||||
|
|
||||||
DATABASE_USER=system
|
# DATABASE_USER=system
|
||||||
DATABASE_PASSWORD=FIRSTADV_PASS
|
#DATABASE_PASSWORD=FIRSTADV_PASS
|
||||||
DATABASE_HOST=10.10.33.65
|
#DATABASE_HOST=10.10.33.65
|
||||||
DATABASE_PORT=1521
|
#DATABASE_PORT=1521
|
||||||
DATABASE_SID=FREE
|
#DATABASE_SID=FREE
|
||||||
+10
-10
@@ -3,14 +3,14 @@ KAFKA_TIMEOUT=1000.0
|
|||||||
KAFKA_BROKER="dev-events.simbrellang.net:9085"
|
KAFKA_BROKER="dev-events.simbrellang.net:9085"
|
||||||
KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT
|
KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT
|
||||||
|
|
||||||
# DATABASE_USER=firstadvance
|
DATABASE_USER=firstadvance
|
||||||
# DATABASE_PASSWORD=FirstAdvance!
|
DATABASE_PASSWORD=FirstAdvance!
|
||||||
# DATABASE_HOST=dev-data.simbrellang.net
|
DATABASE_HOST=10.20.30.60
|
||||||
# DATABASE_PORT=10532
|
DATABASE_PORT=5432
|
||||||
# DATABASE_NAME=firstadvancedev
|
DATABASE_NAME=firstadvancedev
|
||||||
|
|
||||||
DATABASE_USER=system
|
# DATABASE_USER=system
|
||||||
DATABASE_PASSWORD=FIRSTADV_PASS
|
#DATABASE_PASSWORD=FIRSTADV_PASS
|
||||||
DATABASE_HOST=10.10.33.65
|
#DATABASE_HOST=10.10.33.65
|
||||||
DATABASE_PORT=1521
|
#DATABASE_PORT=1521
|
||||||
DATABASE_SID=FREE
|
#DATABASE_SID=FREE
|
||||||
+6
-27
@@ -21,10 +21,10 @@ class Config:
|
|||||||
BANK_CALL_APP_ID = os.getenv("BANK_CALL_APP_ID", "app1")
|
BANK_CALL_APP_ID = os.getenv("BANK_CALL_APP_ID", "app1")
|
||||||
BANK_CALL_API_KEY = os.getenv("BANK_CALL_API_KEY", "testtest-api-key-12345")
|
BANK_CALL_API_KEY = os.getenv("BANK_CALL_API_KEY", "testtest-api-key-12345")
|
||||||
BANK_CALL_BASIC_AUTH_USERNAME = os.environ.get(
|
BANK_CALL_BASIC_AUTH_USERNAME = os.environ.get(
|
||||||
"BANK_CALL_BASIC_AUTH_USERNAME", "simbrella"
|
"BANK_CALL_BASIC_AUTH_USERNAME", "user"
|
||||||
)
|
)
|
||||||
BANK_CALL_BASIC_AUTH_PASSWORD = os.environ.get(
|
BANK_CALL_BASIC_AUTH_PASSWORD = os.environ.get(
|
||||||
"BANK_CALL_BASIC_AUTH_PASSWORD", "G7$k9@pL2!qR"
|
"BANK_CALL_BASIC_AUTH_PASSWORD", "password"
|
||||||
)
|
)
|
||||||
|
|
||||||
DATABASE_USER = os.getenv("DATABASE_USER")
|
DATABASE_USER = os.getenv("DATABASE_USER")
|
||||||
@@ -33,17 +33,14 @@ class Config:
|
|||||||
DATABASE_NAME = os.getenv("DATABASE_NAME")
|
DATABASE_NAME = os.getenv("DATABASE_NAME")
|
||||||
DATABASE_PORT = os.getenv("DATABASE_PORT", 10532)
|
DATABASE_PORT = os.getenv("DATABASE_PORT", 10532)
|
||||||
DATABASE_SID = os.environ.get("DATABASE_SID", "FREE")
|
DATABASE_SID = os.environ.get("DATABASE_SID", "FREE")
|
||||||
DNS = f"(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST={DATABASE_HOST})(PORT={DATABASE_PORT}))(CONNECT_DATA=(SID={DATABASE_SID})))"
|
#DNS = f"(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST={DATABASE_HOST})(PORT={DATABASE_PORT}))(CONNECT_DATA=(SID={DATABASE_SID})))"
|
||||||
|
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URI_INTERNAL = (f"oracle+oracledb://{DATABASE_USER}:{DATABASE_PASSWORD}@{DNS}")
|
#SQLALCHEMY_DATABASE_URI = (f"oracle+oracledb://{DATABASE_USER}:{DATABASE_PASSWORD}@{DNS}")
|
||||||
#SQLALCHEMY_DATABASE_URI_INTERNAL = (f"oracle+oracledb://{DATABASE_USER}:{DATABASE_PASSWORD}@{DNS}")
|
|
||||||
SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI_FULL", SQLALCHEMY_DATABASE_URI_INTERNAL)
|
|
||||||
|
|
||||||
# SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}"
|
SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}"
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
# SQLALCHEMY_ECHO = True
|
# SQLALCHEMY_ECHO = True
|
||||||
OVERRIDE_COLLECTION_TRANCATION_ID = int(os.getenv("OVERRIDE_COLLECTION_TRANCATION_ID", 100))
|
|
||||||
|
|
||||||
|
|
||||||
MAIL_SERVER = os.getenv('MAIL_SERVER','smtp.zoho.com')
|
MAIL_SERVER = os.getenv('MAIL_SERVER','smtp.zoho.com')
|
||||||
@@ -53,31 +50,13 @@ class Config:
|
|||||||
MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'True').lower() in ('true', '1', 'yes')
|
MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'True').lower() in ('true', '1', 'yes')
|
||||||
MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'False').lower() in ('true', '1', 'yes')
|
MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'False').lower() in ('true', '1', 'yes')
|
||||||
MAIL_DEFAULT_SENDER = ('FirstAdvance', 'firstadvance@dynamikservices.tech')
|
MAIL_DEFAULT_SENDER = ('FirstAdvance', 'firstadvance@dynamikservices.tech')
|
||||||
MAIL_RECEIVER= os.getenv('MAIL_RECEIVER', 'chinenyeumeaku@gmail.com,umeakuchinenye@gmail.com')
|
MAIL_RECEIVER= os.getenv('MAIL_RECEIVER', 'chinenyeumeaku@gmail.com')
|
||||||
|
|
||||||
|
|
||||||
# Processing Overdue LOANS sections
|
|
||||||
OVERDUE_LOAN_BATCH_SIZE = int(os.getenv("OVERDUE_LOAN_BATCH_SIZE", 10))
|
|
||||||
OVERDUE_LOAN_DELAY_SECONDS = int(os.getenv("OVERDUE_LOAN_DELAY_SECONDS", 5))
|
|
||||||
OVERDUE_LOAN_BATCH_DELAY_SECONDS = int(
|
|
||||||
os.getenv("OVERDUE_LOAN_BATCH_DELAY_SECONDS", 5)
|
|
||||||
)
|
|
||||||
OVERDUE_GRACE_PERIOD_DAYS = int(os.getenv("OVERDUE_GRACE_PERIOD_DAYS", 30))
|
|
||||||
OVERDUE_PROCESSING_LIST_LIMIT = int(os.getenv("OVERDUE_PROCESSING_LIST_LIMIT", 100))
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
BANK_CALL_API_TIME_OUT = os.getenv("BANK_CALL_API_TIME_OUT", 100)
|
|
||||||
BANK_CALL_BASE_URL = os.getenv("BANK_CALL_BASE_URL", "https://bank-emulator.dev.simbrellang.net/api")
|
BANK_CALL_BASE_URL = os.getenv("BANK_CALL_BASE_URL", "https://bank-emulator.dev.simbrellang.net/api")
|
||||||
BANK_CALL_SMS_BASE_URL= os.getenv("BANK_CALL_SMS_BASE_URL","https://first-advance-middleware-develop.fbn-devops-dev-asenv.appserviceenvironment.net/SMS")
|
BANK_CALL_SMS_BASE_URL= os.getenv("BANK_CALL_SMS_BASE_URL","https://first-advance-middleware-develop.fbn-devops-dev-asenv.appserviceenvironment.net/SMS")
|
||||||
BANK_CALL_DISBURSE_LOAN_ENDPOINT = os.getenv("BANK_CALL_DISBURSE_LOAN_ENDPOINT","/DisburseLoan")
|
BANK_CALL_DISBURSE_LOAN_ENDPOINT = os.getenv("BANK_CALL_DISBURSE_LOAN_ENDPOINT","/DisburseLoan")
|
||||||
BANK_CALL_COLLECT_LOAN_ENDPOINT = os.getenv("BANK_CALL_COLLECT_LOAN_ENDPOINT","/CollectLoan")
|
BANK_CALL_COLLECT_LOAN_ENDPOINT = os.getenv("BANK_CALL_COLLECT_LOAN_ENDPOINT","/CollectLoan")
|
||||||
BANK_CALL_TRANSACTION_VERIFY = os.getenv("BANK_CALL_TRANSACTION_VERIFY", "/TransactionVerify")
|
BANK_CALL_TRANSACTION_VERIFY = os.getenv("BANK_CALL_TRANSACTION_VERIFY", "/TransactionVerify")
|
||||||
BANK_HEALTH_CHECK_ENDPOINT = os.getenv("BANK_HEALTH_CHECK_ENDPOINT", "/system-health-check")
|
|
||||||
BANK_CALL_AUTH_ENDPOINT = os.getenv("BANK_CALL_AUTH_ENDPOINT", "/Auth/generate-token")
|
|
||||||
BANK_GRANT_TYPE = os.getenv("BANK_GRANT_TYPE", "password")
|
|
||||||
TEST_NO = os.getenv("TEST_NO", "2347038224367")
|
TEST_NO = os.getenv("TEST_NO", "2347038224367")
|
||||||
|
|
||||||
settings = Config()
|
settings = Config()
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
from .transaction_type import TransactionType
|
from .transaction_type import TransactionType
|
||||||
from .loan_status import LoanStatus
|
from .loan_status import LoanStatus
|
||||||
from .repayment_schedule_status import RepaymentScheduleStatus
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
from enum import Enum
|
|
||||||
|
|
||||||
class RepaymentScheduleStatus(str, Enum):
|
|
||||||
PARTIALLY_PAID = "partially_paid"
|
|
||||||
REPAID = "repaid"
|
|
||||||
ACTIVE = "active"
|
|
||||||
OVERDUE = "overdue"
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import random
|
|
||||||
import string
|
|
||||||
from app.services.repayment import RepaymentService
|
|
||||||
from app.services.loan import LoanService
|
|
||||||
from app.helpers.response_helper import ResponseHelper
|
|
||||||
from app.utils.logger import logger
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
OVERRIDE_COLLECTION_TRANCATION_ID = settings.OVERRIDE_COLLECTION_TRANCATION_ID
|
|
||||||
|
|
||||||
class CollectLoanHelper:
|
|
||||||
@staticmethod
|
|
||||||
def _validate_repayment_and_loan(data):
|
|
||||||
repayment = RepaymentService.get_repayment_by_id(id=data['Id'])
|
|
||||||
if not repayment:
|
|
||||||
logger.info(f"Repayment id: {data['Id']}, was not found")
|
|
||||||
return None, None, ResponseHelper.error("Repayment not found")
|
|
||||||
|
|
||||||
repayment_data = repayment.to_dict()
|
|
||||||
loan = LoanService.get_loan_by_loan_id(loan_id=int(repayment_data['loanId']))
|
|
||||||
if not loan:
|
|
||||||
logger.info(f"Loan id: {repayment_data['loanId']}, was not found")
|
|
||||||
return None, None, ResponseHelper.error("Loan not found")
|
|
||||||
|
|
||||||
loan
|
|
||||||
return repayment_data, loan, None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_collect_loan_payload(loan_data, repayment_data, data, collectionMethod):
|
|
||||||
logger.info(f"building CollectLoan endpoint with data: {loan_data}")
|
|
||||||
|
|
||||||
debtId = str(loan_data.get('debtId', "")).strip().zfill(6)
|
|
||||||
#this can be overridden based on config
|
|
||||||
t_id = ''.join(random.choices(string.ascii_uppercase, k=22))
|
|
||||||
if OVERRIDE_COLLECTION_TRANCATION_ID == 100:
|
|
||||||
t_id = loan_data['transactionId']
|
|
||||||
return {
|
|
||||||
"transactionId": t_id,
|
|
||||||
"fbnTransactionId": loan_data['transactionId'],
|
|
||||||
"debtId": debtId,
|
|
||||||
"customerId": repayment_data['customerId'],
|
|
||||||
"accountId": loan_data['accountId'],
|
|
||||||
"productId": repayment_data['productId'],
|
|
||||||
"collectAmount": (
|
|
||||||
data['overdueLoanScheduleAmount']
|
|
||||||
if data.get('overdueLoanScheduleAmount')
|
|
||||||
is not None else loan_data.get('balance', 0)
|
|
||||||
),
|
|
||||||
"penalCharge": 0,
|
|
||||||
"channel": "USSD",
|
|
||||||
"collectionMethod": collectionMethod,
|
|
||||||
"lienAmount": 0,
|
|
||||||
"countryId": "NG",
|
|
||||||
"comment": "COLLECT LOAN"
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def chunk_list(data, chunk_size):
|
|
||||||
"""Yield successive chunk_size chunks from data."""
|
|
||||||
for i in range(0, len(data), chunk_size):
|
|
||||||
yield data[i:i + chunk_size]
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
from .kafka import KafkaIntegration
|
from .kafka import KafkaIntegration
|
||||||
from .simbrella import SimbrellaClient
|
from .simbrella import SimbrellaClient
|
||||||
from .bank_service import BankService
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import requests
|
|
||||||
from app.config import settings
|
|
||||||
from app.utils.auth import get_headers
|
|
||||||
from app.utils.logger import logger
|
|
||||||
|
|
||||||
|
|
||||||
class BankService:
|
|
||||||
BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL
|
|
||||||
BANK_HEALTH_CHECK_ENDPOINT = settings.BANK_HEALTH_CHECK_ENDPOINT
|
|
||||||
BANK_CALL_APP_ID = settings.BANK_CALL_APP_ID
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def health_check():
|
|
||||||
api_url = f"{BankService.BANK_CALL_BASE_URL}{BankService.BANK_HEALTH_CHECK_ENDPOINT}"
|
|
||||||
logger.info(f"Calling Health Check endpoint: {api_url}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.get(api_url, timeout=5, headers=get_headers())
|
|
||||||
logger.info(f"Health Check response status code: {response.status_code}")
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Health Check API call failed: {str(e)}", exc_info=True)
|
|
||||||
raise
|
|
||||||
+99
-220
@@ -1,13 +1,11 @@
|
|||||||
import requests
|
import requests
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.helpers.response_helper import ResponseHelper
|
from app.helpers.response_helper import ResponseHelper
|
||||||
# from app.routes.autocall import verify_transaction
|
|
||||||
from app.models.customer import Customer
|
|
||||||
from app.services.loan import LoanService
|
from app.services.loan import LoanService
|
||||||
from app.services.loan_repayment_schedule import LoanRepaymentScheduleService
|
|
||||||
from app.utils.auth import get_headers
|
from app.utils.auth import get_headers
|
||||||
from app.utils.extras import preprocess_loan_charges_data
|
from app.utils.extras import preprocess_loan_charges_data
|
||||||
import random
|
import random
|
||||||
|
import random
|
||||||
import string
|
import string
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.utils.logger import logger
|
from app.utils.logger import logger
|
||||||
@@ -18,22 +16,20 @@ from app.extensions import db
|
|||||||
from app.services.repayments_data import RepaymentsData
|
from app.services.repayments_data import RepaymentsData
|
||||||
from app.services.salary import SalaryService
|
from app.services.salary import SalaryService
|
||||||
from app.enums.loan_status import LoanStatus
|
from app.enums.loan_status import LoanStatus
|
||||||
from app.models.loan_repayment_schedule import LoanRepaymentSchedule
|
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
from requests.exceptions import SSLError, RequestException, Timeout, ReadTimeout, ConnectTimeout
|
from requests.exceptions import SSLError, RequestException,Timeout
|
||||||
import sys
|
import sys
|
||||||
|
from requests.exceptions import ReadTimeout, ConnectTimeout
|
||||||
import socket
|
import socket
|
||||||
from app.helpers.collect_loan_helper import CollectLoanHelper
|
|
||||||
|
|
||||||
|
|
||||||
class SimbrellaClient:
|
class SimbrellaClient:
|
||||||
|
|
||||||
BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL
|
BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL
|
||||||
BANK_CALL_SMS_BASE_URL = settings.BANK_CALL_SMS_BASE_URL
|
BANK_CALL_SMS_BASE_URL = settings.BANK_CALL_SMS_BASE_URL
|
||||||
BANK_CALL_DISBURSE_LOAN_ENDPOINT = settings.BANK_CALL_DISBURSE_LOAN_ENDPOINT
|
BANK_CALL_DISBURSE_LOAN_ENDPOINT = settings.BANK_CALL_DISBURSE_LOAN_ENDPOINT
|
||||||
BANK_CALL_COLLECT_LOAN_ENDPOINT = settings.BANK_CALL_COLLECT_LOAN_ENDPOINT
|
BANK_CALL_COLLECT_LOAN_ENDPOINT = settings.BANK_CALL_COLLECT_LOAN_ENDPOINT
|
||||||
BANK_CALL_TRANSACTION_VERIFY = settings.BANK_CALL_TRANSACTION_VERIFY
|
BANK_CALL_TRANSACTION_VERIFY = settings.BANK_CALL_TRANSACTION_VERIFY
|
||||||
BANK_HEALTH_CHECK_ENDPOINT = settings.BANK_HEALTH_CHECK_ENDPOINT
|
|
||||||
BANK_CALL_API_TIME_OUT = settings.BANK_CALL_API_TIME_OUT
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def disburse_loan(data):
|
def disburse_loan(data):
|
||||||
@@ -64,25 +60,13 @@ class SimbrellaClient:
|
|||||||
loan_data = loan.to_dict()
|
loan_data = loan.to_dict()
|
||||||
logger.info(f"Here is your loan data: {loan_data}")
|
logger.info(f"Here is your loan data: {loan_data}")
|
||||||
|
|
||||||
if loan_data['status'] != LoanStatus.ACTIVE:
|
|
||||||
logger.info(f"Loan with transaction id: {data['transactionId']} is not active")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if loan_data['disburseDate'] is not None:
|
if loan_data['disburseDate'] is not None:
|
||||||
logger.info("*************************SEEM LIKE A RETRY CALL -WE WILL VERIFY Result to Continue ")
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Please call verify loan : {data['transactionId']} loan sent for processing at {loan_data['disburseDate']}")
|
f"Please call verify loan : {data['transactionId']} loan send for processing at {loan_data['disburseDate']}")
|
||||||
# return 0 -- we need the disburseResult = '00' to be sure all is good
|
|
||||||
|
|
||||||
if loan_data['disburseDate'] is not None and loan_data['disburseResult'] == '00':
|
|
||||||
logger.info("*************************Duplicate call to completed loan ")
|
|
||||||
logger.info(
|
|
||||||
f"Duplicate call detected for loan : {data['transactionId']} loan sent for processing at {loan_data['disburseDate']}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# let us set disbursement date
|
# let us set disbursement date
|
||||||
LoanService.set_disbursement_date(loan_data['debtId'],
|
LoanService.set_disbursement_date(loan_data['debtId'],loan_data['customerId']) # toda this must return something
|
||||||
loan_data['customerId']) # toda this must return something
|
|
||||||
logger.info(f"Here is your loan data after setting disbursement date: {loan_data}")
|
logger.info(f"Here is your loan data after setting disbursement date: {loan_data}")
|
||||||
|
|
||||||
loan_charges = preprocess_loan_charges_data([loan_charge.to_dict() for loan_charge in loan.loan_charges])
|
loan_charges = preprocess_loan_charges_data([loan_charge.to_dict() for loan_charge in loan.loan_charges])
|
||||||
@@ -92,134 +76,43 @@ class SimbrellaClient:
|
|||||||
vat_fee = loan_charges.get("VAT")['amount']
|
vat_fee = loan_charges.get("VAT")['amount']
|
||||||
interest_fee = loan_charges.get("INTEREST")['amount']
|
interest_fee = loan_charges.get("INTEREST")['amount']
|
||||||
insurance_fee = loan_charges.get("INSURANCE")['amount']
|
insurance_fee = loan_charges.get("INSURANCE")['amount']
|
||||||
product_id = str(loan_data.get('productId', ""))
|
|
||||||
debtId = str(loan_data.get('debtId', "")).strip().zfill(6)
|
debtId = str(loan_data.get('debtId', "")).strip().zfill(6)
|
||||||
|
t_id = ''.join(random.choices(string.ascii_uppercase, k=22))
|
||||||
|
|
||||||
disbursement_data = {
|
disbursement_data = {
|
||||||
"transactionId": loan_data.get('transactionId'),
|
"transactionId": t_id,
|
||||||
"fbnTransactionId": loan_data.get('transactionId'),
|
"FbnTransactionId": loan_data.get('transactionId'),
|
||||||
"debtId": debtId,
|
"debtId": debtId,
|
||||||
"customerId": loan_data.get('customerId'),
|
"customerId": loan_data.get('customerId'),
|
||||||
"accountId": loan_data.get('accountId'),
|
"accountId": loan_data.get('accountId'),
|
||||||
"productId": str(loan_data.get('productId', "")),
|
"productId": str(loan_data.get('productId', "")),
|
||||||
"provideAmount": loan_data.get('currentLoanAmount'),
|
"provideAmount": loan_data.get('currentLoanAmount'),
|
||||||
"collectAmountInterest": interest_fee if product_id != '3MPC' else 0,
|
"collectAmountInterest": interest_fee,
|
||||||
"collectAmountMgtFee": mgt_fee,
|
"collectAmountMgtFee": mgt_fee,
|
||||||
"collectAmountInsurance": insurance_fee,
|
"collectAmountInsurance": insurance_fee,
|
||||||
"collectAmountVAT": vat_fee,
|
"collectAmountVAT": vat_fee,
|
||||||
"countryId": "01",
|
"countryId": "01",
|
||||||
"comment": "Loan Disbursement",
|
"comment": "Loan Disbursement",
|
||||||
}
|
}
|
||||||
# '''
|
|
||||||
# { Veryfing with the bank
|
|
||||||
# "transactionId": "string",
|
|
||||||
# "fbnTransactionId": "string",
|
|
||||||
# "debtId": "string",
|
|
||||||
# "customerId": "string",
|
|
||||||
# "accountId": "string",
|
|
||||||
# "productId": "string",
|
|
||||||
# "provideAmount": 0,
|
|
||||||
# "collectAmountInterest": 0,
|
|
||||||
# "collectAmountMgtFee": 0,
|
|
||||||
# "collectAmountInsurance": 0,
|
|
||||||
# "collectAmountVAT": 0,
|
|
||||||
# "countryId": "string",
|
|
||||||
# "comment": "string"
|
|
||||||
# }
|
|
||||||
# '''
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Calling Bank Disbursement with Request data ****** : {disbursement_data}")
|
logger.info(f"Here is your Disbursement Request data ****** : {disbursement_data}")
|
||||||
response = requests.post(api_url, json=disbursement_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT,
|
response = requests.post(api_url, json=disbursement_data, timeout=90, headers=get_headers())
|
||||||
headers=get_headers())
|
|
||||||
logger.info(
|
|
||||||
f"Call to bank end point returned with Here is your Disbursement Request data ****** : {disbursement_data}")
|
|
||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
logger.error("")
|
logger.error("Received 404 from external service")
|
||||||
LoanService.set_disbursement_loan_description(loan_data['debtId'],
|
|
||||||
"Disbursement Service url not found (404)")
|
|
||||||
return ResponseHelper.error("Disbursement Service url not found (404)", status_code=404)
|
return ResponseHelper.error("Disbursement Service url not found (404)", status_code=404)
|
||||||
|
|
||||||
logger.info(f"Disbursement response: {response.json()}")
|
logger.info(f"Disbursement response: {response.json()}")
|
||||||
|
|
||||||
if response.status_code == 200:
|
result = response.json()
|
||||||
result = response.json()
|
LoanService.set_disbursement_result(loan_data['debtId'],result.get('responseCode', ''), result.get('responseMessage', ''))
|
||||||
LoanService.set_disbursement_result(loan_data['debtId'], result.get('responseCode', ''),
|
return ResponseHelper.success(response.json(), "Successful")
|
||||||
result.get('responseMessage', ''))
|
|
||||||
reload_loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId'])
|
|
||||||
reload_loan_data = reload_loan.to_dict()
|
|
||||||
#mark repayment schedule as active
|
|
||||||
repayment_schedule = LoanRepaymentScheduleService.get_repayment_schedule_by_loan_id(
|
|
||||||
reload_loan_data['debtId'], include_paid=False)
|
|
||||||
logger.info(f'Loan repayment schedule: {repayment_schedule}')
|
|
||||||
if repayment_schedule:
|
|
||||||
for schedule in repayment_schedule:
|
|
||||||
logger.info(f"Updating repayment schedule ID {schedule.id} status to ACTIVE")
|
|
||||||
LoanRepaymentScheduleService.update_repayment_schedule_status_to_active(schedule.id)
|
|
||||||
|
|
||||||
SimbrellaClient.verify_disbursement_transaction(reload_loan_data)
|
|
||||||
|
|
||||||
return ResponseHelper.success(response.json(), "Successful")
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.error("")
|
|
||||||
errorMessage = "Unable to complete Disbursement Service with HTTP status code: " + str(
|
|
||||||
response.status_code)
|
|
||||||
LoanService.set_disbursement_loan_description(loan_data['debtId'], errorMessage)
|
|
||||||
return ResponseHelper.error(errorMessage, status_code=response.status_code)
|
|
||||||
|
|
||||||
except requests.exceptions.HTTPError as errh:
|
|
||||||
print(f"Disbursement HTTP Error: {errh}")
|
|
||||||
except requests.exceptions.ConnectionError as errc:
|
|
||||||
print(f"Disbursement Error Connecting: {errc}")
|
|
||||||
except requests.exceptions.Timeout as errt:
|
|
||||||
print(f"Disbursement Timeout Error: {errt}")
|
|
||||||
except requests.exceptions.RequestException as err:
|
|
||||||
print(f"Disbursement - Unexpected Error Occurred: {err}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info(f"Failed to call Disbursement endpoint: {e}")
|
logger.info(f"Failed to call Disbursement endpoint: {e}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# try:
|
|
||||||
# logger.info(f"Calling Bank Disbursement with Request data ****** : {disbursement_data}")
|
|
||||||
# response = requests.post(api_url, json=disbursement_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT,
|
|
||||||
# headers=get_headers())
|
|
||||||
# logger.info(
|
|
||||||
# f"Call to bank end point returned with Here is your Disbursement Request data ****** : {disbursement_data}")
|
|
||||||
# if response.status_code == 404:
|
|
||||||
# logger.error("")
|
|
||||||
# LoanService.set_disbursement_loan_description(loan_data['debtId'],
|
|
||||||
# "Disbursement Service url not found (404)")
|
|
||||||
# return ResponseHelper.error("Disbursement Service url not found (404)", status_code=404)
|
|
||||||
#
|
|
||||||
# logger.info(f"Disbursement response: {response.json()}")
|
|
||||||
#
|
|
||||||
# if response.status_code == 200:
|
|
||||||
# result = response.json()
|
|
||||||
# LoanService.set_disbursement_result(loan_data['debtId'], result.get('responseCode', ''),
|
|
||||||
# result.get('responseMessage', ''))
|
|
||||||
# reload_loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId'])
|
|
||||||
# reload_loan_data = reload_loan.to_dict()
|
|
||||||
# SimbrellaClient.verify_disbursement_transaction(reload_loan_data)
|
|
||||||
# return ResponseHelper.success(response.json(), "Successful")
|
|
||||||
#
|
|
||||||
# else:
|
|
||||||
# logger.error("")
|
|
||||||
# errorMessage = "Unable to complete Disbursement Service with HTTP status code: " + str(
|
|
||||||
# response.status_code)
|
|
||||||
# LoanService.set_disbursement_loan_description(loan_data['debtId'], errorMessage)
|
|
||||||
# return ResponseHelper.error(errorMessage, status_code=response.status_code)
|
|
||||||
|
|
||||||
# except Exception as e:
|
|
||||||
# logger.info(f"Failed to call Disbursement endpoint: {e}")
|
|
||||||
# return 0
|
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def verify_disbursement_transaction(loan_data):
|
|
||||||
if loan_data['disburseResult'] and loan_data['disburseResult'] == '00':
|
|
||||||
SimbrellaClient.verify_transaction(loan_data)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify_transaction(data):
|
def verify_transaction(data):
|
||||||
api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}{SimbrellaClient.BANK_CALL_TRANSACTION_VERIFY}"
|
api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}{SimbrellaClient.BANK_CALL_TRANSACTION_VERIFY}"
|
||||||
@@ -250,8 +143,8 @@ class SimbrellaClient:
|
|||||||
loan_data = loan.to_dict()
|
loan_data = loan.to_dict()
|
||||||
logger.info(f"Here is your loan data: {loan_data}")
|
logger.info(f"Here is your loan data: {loan_data}")
|
||||||
|
|
||||||
if loan_data['disburseDate'] is not None and loan_data['disburseVerify'] is None:
|
if loan_data['disburseDate'] is not None and loan_data['disburseVerify'] is None :
|
||||||
LoanService.set_disburse_verify_date(loan_data['debtId'], loan_data['customerId'])
|
LoanService.set_disburse_verify_date(loan_data['debtId'],loan_data['customerId'])
|
||||||
loan_data = loan.to_dict()
|
loan_data = loan.to_dict()
|
||||||
logger.info(f"Here is your loan data after setting verify date: {loan_data}")
|
logger.info(f"Here is your loan data after setting verify date: {loan_data}")
|
||||||
logger.info(f"Good to Verify transaction id: {data['transactionId']}")
|
logger.info(f"Good to Verify transaction id: {data['transactionId']}")
|
||||||
@@ -260,6 +153,7 @@ class SimbrellaClient:
|
|||||||
f"Please call disburse loan : {data['transactionId']} loan send for processing first")
|
f"Please call disburse loan : {data['transactionId']} loan send for processing first")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
verify_data = {
|
verify_data = {
|
||||||
"customerId": loan_data.get('customerId'),
|
"customerId": loan_data.get('customerId'),
|
||||||
"accountId": loan_data.get('accountId'),
|
"accountId": loan_data.get('accountId'),
|
||||||
@@ -269,53 +163,23 @@ class SimbrellaClient:
|
|||||||
"countryId": "NG",
|
"countryId": "NG",
|
||||||
"requestId": loan_data.get('transactionId')
|
"requestId": loan_data.get('transactionId')
|
||||||
}
|
}
|
||||||
# '''
|
|
||||||
# { Verify with bank
|
|
||||||
# "accountId": "string",
|
|
||||||
# "customerId": "string",
|
|
||||||
# "transactionId": "string",
|
|
||||||
# "fbnTransactionId": "string",
|
|
||||||
# "transactionType": "string",
|
|
||||||
# "countryId": "string",
|
|
||||||
# "requestId": "string"
|
|
||||||
# }
|
|
||||||
# '''
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Here is your TransactionVerify Request data ****** : {verify_data}")
|
logger.info(f"Here is your TransactionVerify Request data ****** : {verify_data}")
|
||||||
response = requests.post(api_url, json=verify_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT,
|
response = requests.post(api_url, json=verify_data, timeout=10, headers=get_headers())
|
||||||
headers=get_headers())
|
|
||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
logger.error("Received 404 from external service")
|
logger.error("Received 404 from external service")
|
||||||
return ResponseHelper.error("Verify Service url not found (404)", status_code=404)
|
return ResponseHelper.error("Verify Service url not found (404)", status_code=404)
|
||||||
result = response.json()
|
result = response.json()
|
||||||
#check for res 00 and status 200
|
|
||||||
logger.info(f"this is verify result, {result}")
|
logger.info(f"this is verify result, {result}")
|
||||||
LoanService.set_disburse_verify_result(loan_data['debtId'], result.get('responseCode', ''),
|
LoanService.set_disburse_verify_result(loan_data['debtId'],result.get('responseCode', ''), result.get('responseMessage', ''))
|
||||||
result.get('responseMessage', ''))
|
|
||||||
|
|
||||||
customer = Customer.get_customer(loan_data.get('customerId'))
|
|
||||||
if customer:
|
|
||||||
misisdn = customer.msisdn
|
|
||||||
else:
|
|
||||||
logger.info(f"Customer does not exist for customer id: {loan_data.get('customerId')}")
|
|
||||||
misisdn = settings.TEST_NO
|
|
||||||
|
|
||||||
sms_data = {
|
sms_data = {
|
||||||
"dest": misisdn,
|
"dest": transaction_data.get('phone_number') or settings.TEST_NO,
|
||||||
"text": f"Transaction {loan_data.get('transactionId')} verified successfully",
|
"text": f"Transaction {loan_data.get('transactionId')} verified successfully",
|
||||||
"unicode": True
|
"unicode": True
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
TransactionService.create_transaction(loan_data['transactionId'], loan_data['accountId'],
|
sms_response = requests.post(sms_url, json=sms_data, timeout=90, headers=get_headers())
|
||||||
loan_data['customerId'], "send_sms", "USSD")
|
|
||||||
except Exception as e:
|
|
||||||
logger.info(f"Failed to LOG SMS Transaction Record: {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
|
|
||||||
sms_response = requests.post(sms_url, json=sms_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT,
|
|
||||||
headers=get_headers())
|
|
||||||
sms_response.raise_for_status() # Raise an exception for 4xx or 5xx status codes
|
sms_response.raise_for_status() # Raise an exception for 4xx or 5xx status codes
|
||||||
|
|
||||||
result = sms_response.json()
|
result = sms_response.json()
|
||||||
@@ -331,7 +195,7 @@ class SimbrellaClient:
|
|||||||
return 0
|
return 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info(f"Failed to call TransactionVerify endpoint: {e}")
|
logger.info(f"Failed to call TransactionVerify endpoint: {e}")
|
||||||
return 0
|
return ResponseHelper.error("Unexpected error while processing loan disbursement", status_code=500, error=str(e))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def collect_loan_user_initiated(data):
|
def collect_loan_user_initiated(data):
|
||||||
@@ -359,18 +223,12 @@ class SimbrellaClient:
|
|||||||
error=str(e)
|
error=str(e)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def collect_loan_user_due_payment(data):
|
def collect_loan_user_due_payment(data):
|
||||||
# InitiatedBy = REPAYMENT_DUE
|
# InitiatedBy = REPAYMENT_DUE
|
||||||
try:
|
return SimbrellaClient._collect_loan(data,"3")
|
||||||
return SimbrellaClient._collect_loan(data, "3")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in collect_loan_user_due_payment: {e}")
|
|
||||||
return ResponseHelper.error(
|
|
||||||
message="Failed to collect loan for due payment",
|
|
||||||
status_code=500,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _collect_loan(data, collectionMethod: str):
|
def _collect_loan(data, collectionMethod: str):
|
||||||
@@ -378,10 +236,19 @@ class SimbrellaClient:
|
|||||||
logger.info(f"Calling CollectLoan api_url==> : {api_url}")
|
logger.info(f"Calling CollectLoan api_url==> : {api_url}")
|
||||||
logger.info(f"Calling CollectLoan endpoint with data: {data}")
|
logger.info(f"Calling CollectLoan endpoint with data: {data}")
|
||||||
|
|
||||||
repayment_data, loan, error = CollectLoanHelper._validate_repayment_and_loan(data)
|
repayment = RepaymentService.get_repayment_by_id(id=data['Id'])
|
||||||
if error:
|
if not repayment:
|
||||||
return error
|
logger.info(f"Repayment with id: {data['Id']} not found")
|
||||||
|
return ResponseHelper.error("Repayment not found")
|
||||||
|
|
||||||
|
repayment_data = repayment.to_dict()
|
||||||
|
loan = LoanService.get_loan_by_loan_id(loan_id=int(repayment_data['loanId']))
|
||||||
|
if not loan:
|
||||||
|
logger.info(f"Loan with debtId: {repayment_data['loanId']} not found")
|
||||||
|
return ResponseHelper.error("Loan not found")
|
||||||
|
|
||||||
loan_data = loan.to_dict()
|
loan_data = loan.to_dict()
|
||||||
|
logger.info(f"Loan data: {loan_data}")
|
||||||
|
|
||||||
if repayment_data['repayDate'] is not None:
|
if repayment_data['repayDate'] is not None:
|
||||||
logger.info(f"Repayment already processed at {repayment_data['repayDate']}")
|
logger.info(f"Repayment already processed at {repayment_data['repayDate']}")
|
||||||
@@ -391,12 +258,27 @@ class SimbrellaClient:
|
|||||||
repayment = RepaymentService.get_repayment_by_transaction_id(transaction_id=data['transactionId'])
|
repayment = RepaymentService.get_repayment_by_transaction_id(transaction_id=data['transactionId'])
|
||||||
repayment_data = repayment.to_dict()
|
repayment_data = repayment.to_dict()
|
||||||
|
|
||||||
collect_loan_data = CollectLoanHelper._build_collect_loan_payload(loan_data, repayment_data, data,
|
debtId = str(loan_data.get('debtId', "")).strip().zfill(6)
|
||||||
collectionMethod)
|
t_id = ''.join(random.choices(string.ascii_uppercase, k=22))
|
||||||
|
collect_loan_data = {
|
||||||
|
"transactionId": t_id,
|
||||||
|
"fbnTransactionId": loan_data['transactionId'],
|
||||||
|
"debtId": debtId,
|
||||||
|
"customerId": repayment_data['customerId'],
|
||||||
|
"accountId": loan_data['accountId'],
|
||||||
|
"productId": repayment_data['productId'],
|
||||||
|
"collectAmount": loan_data['balance'] or 0,
|
||||||
|
"penalCharge": 0,
|
||||||
|
"channel": "USSD",
|
||||||
|
"collectionMethod": collectionMethod,
|
||||||
|
"lienAmount": 0,
|
||||||
|
"countryId": "NG",
|
||||||
|
"comment": "COLLECT LOAN"
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Sending CollectLoan request............ {collect_loan_data}")
|
logger.info(f"Sending CollectLoan request............ {collect_loan_data}")
|
||||||
response = requests.post(api_url, json=collect_loan_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT,
|
response = requests.post(api_url, json=collect_loan_data, timeout=90, headers=get_headers())
|
||||||
headers=get_headers())
|
|
||||||
logger.info(f"HTTP response object: {response}")
|
logger.info(f"HTTP response object: {response}")
|
||||||
|
|
||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
@@ -406,10 +288,6 @@ class SimbrellaClient:
|
|||||||
'404',
|
'404',
|
||||||
'Collection Service url not found'
|
'Collection Service url not found'
|
||||||
)
|
)
|
||||||
if (data.get('overdueLoanScheduleId') is not None):
|
|
||||||
LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'],
|
|
||||||
'Collection Service url not found')
|
|
||||||
|
|
||||||
logger.error("Received 404 from external service")
|
logger.error("Received 404 from external service")
|
||||||
return ResponseHelper.error("Collection Service URL not found", status_code=404)
|
return ResponseHelper.error("Collection Service URL not found", status_code=404)
|
||||||
|
|
||||||
@@ -439,30 +317,45 @@ class SimbrellaClient:
|
|||||||
logger.info(f"Repayment data added: {new_repayment_data.to_dict()}")
|
logger.info(f"Repayment data added: {new_repayment_data.to_dict()}")
|
||||||
else:
|
else:
|
||||||
logger.warning("Failed to add repayment data")
|
logger.warning("Failed to add repayment data")
|
||||||
updated_loan = None
|
|
||||||
response_message = result.get('responseMessage')
|
|
||||||
if result.get('responseCode') == '00':
|
if result.get('responseCode') == '00':
|
||||||
amount_collected = Decimal(str(result.get('amountCollected', 0))).quantize(Decimal('0.01'),
|
amount_collected = Decimal(str(result.get('amountCollected', 0))).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|
||||||
rounding=ROUND_HALF_UP)
|
|
||||||
logger.info(f"Amount collected: {amount_collected}")
|
logger.info(f"Amount collected: {amount_collected}")
|
||||||
|
|
||||||
updated_loan = LoanService._update_loan_after_collection(
|
if loan.balance is None or loan.balance <= 0:
|
||||||
loan, loan_data, updated_loan, amount_collected, data, response_message=response_message
|
logger.warning(f"Loan ID {loan.id} has no balance. Skipping loan update.")
|
||||||
)
|
return ResponseHelper.error("Loan has no balance. Skipping.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Updating loan balance for loan ID {loan_data['debtId']} with amount collected: {amount_collected}")
|
||||||
|
updated_loan = LoanService.update_loan_balance(int(loan_data['debtId']), amount_collected)
|
||||||
|
logger.info(f"Updated loan: {updated_loan}")
|
||||||
|
except Exception as ex:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error updating loan balance for loan ID {loan.id}: {ex}")
|
||||||
|
return ResponseHelper.error("Error updating loan balance")
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated_balance = Decimal(str(updated_loan['balance'])).quantize(Decimal('0.01'))
|
||||||
|
logger.info(f"Updated balance: {updated_balance}")
|
||||||
|
|
||||||
|
if updated_balance <= Decimal('0.00'):
|
||||||
|
logger.info('Loan fully repaid')
|
||||||
|
repaid = LoanService.update_status(updated_loan['debtId'], LoanStatus.REPAID)
|
||||||
|
logger.info(f'Updated loan with repaid status: {repaid}')
|
||||||
|
else:
|
||||||
|
logger.info('Loan partially repaid')
|
||||||
|
partial = LoanService.update_status(updated_loan['debtId'], LoanStatus.ACTIVE_PARTIAL)
|
||||||
|
logger.info(f'Updated loan with partial status: {partial}')
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error while updating loan status for debtId {updated_loan['debtId']}: {e}")
|
||||||
|
|
||||||
return ResponseHelper.success(result, "Successful")
|
return ResponseHelper.success(result, "Successful")
|
||||||
|
|
||||||
except SSLError as ssl_err:
|
except SSLError as ssl_err:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
logger.exception(f"SSL error while calling Simbrella endpoint: {ssl_err}")
|
logger.exception(f"SSL error while calling Simbrella endpoint: {ssl_err}")
|
||||||
RepaymentService.set_repay_result(
|
|
||||||
repayment_data['Id'],
|
|
||||||
'502',
|
|
||||||
'SSL error occurred while calling Simbrella'
|
|
||||||
)
|
|
||||||
if (data.get('overdueLoanScheduleId') is not None):
|
|
||||||
LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'],
|
|
||||||
'SSL error occurred')
|
|
||||||
|
|
||||||
return ResponseHelper.error("SSL handshake failed with Simbrella", status_code=502, error=str(ssl_err))
|
return ResponseHelper.error("SSL handshake failed with Simbrella", status_code=502, error=str(ssl_err))
|
||||||
|
|
||||||
except (Timeout, ReadTimeout, ConnectTimeout, socket.timeout, TimeoutError) as timeout_err:
|
except (Timeout, ReadTimeout, ConnectTimeout, socket.timeout, TimeoutError) as timeout_err:
|
||||||
@@ -473,10 +366,6 @@ class SimbrellaClient:
|
|||||||
'500',
|
'500',
|
||||||
'There was a timeout while calling Simbrella'
|
'There was a timeout while calling Simbrella'
|
||||||
)
|
)
|
||||||
if (data.get('overdueLoanScheduleId') is not None):
|
|
||||||
LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'],
|
|
||||||
'Timeout occurred')
|
|
||||||
|
|
||||||
return ResponseHelper.error("Connection to Simbrella timed out", status_code=504, error=str(timeout_err))
|
return ResponseHelper.error("Connection to Simbrella timed out", status_code=504, error=str(timeout_err))
|
||||||
except RequestException as req_err:
|
except RequestException as req_err:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
@@ -486,9 +375,7 @@ class SimbrellaClient:
|
|||||||
'500',
|
'500',
|
||||||
'There was a request error while calling Simbrella'
|
'There was a request error while calling Simbrella'
|
||||||
)
|
)
|
||||||
if (data.get('overdueLoanScheduleId') is not None):
|
return ResponseHelper.error("Connection to Simbrella failed", status_code=503, error=str(req_err))
|
||||||
LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'],
|
|
||||||
'Request error occurred')
|
|
||||||
|
|
||||||
except SystemExit as sys_exit:
|
except SystemExit as sys_exit:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
@@ -498,11 +385,8 @@ class SimbrellaClient:
|
|||||||
'500',
|
'500',
|
||||||
'There was a system error while calling Simbrella'
|
'There was a system error while calling Simbrella'
|
||||||
|
|
||||||
)
|
|
||||||
if (data.get('overdueLoanScheduleId') is not None):
|
|
||||||
LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'],
|
|
||||||
'Unexpected shutdown occurred')
|
|
||||||
|
|
||||||
|
)
|
||||||
return ResponseHelper.error("Unexpected shutdown detected", status_code=500, error=str(sys_exit))
|
return ResponseHelper.error("Unexpected shutdown detected", status_code=500, error=str(sys_exit))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -513,12 +397,8 @@ class SimbrellaClient:
|
|||||||
'500',
|
'500',
|
||||||
'Unexpected error while processing loan collection'
|
'Unexpected error while processing loan collection'
|
||||||
)
|
)
|
||||||
if (data.get('overdueLoanScheduleId') is not None):
|
return ResponseHelper.error("Unexpected error while processing loan collection", status_code=500, error=str(e))
|
||||||
LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'],
|
|
||||||
'Unexpected error occurred')
|
|
||||||
|
|
||||||
return ResponseHelper.error("Unexpected error while processing loan collection", status_code=500,
|
|
||||||
error=str(e))
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def penal_charge(data):
|
def penal_charge(data):
|
||||||
@@ -531,8 +411,7 @@ class SimbrellaClient:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Here is your Penal Charge Request data ****** : {data}")
|
logger.info(f"Here is your Penal Charge Request data ****** : {data}")
|
||||||
response = requests.post(api_url, json=data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT,
|
response = requests.post(api_url, json=data, timeout=10, headers=get_headers())
|
||||||
headers=get_headers())
|
|
||||||
logger.info(f"Penal Charge response: {response.json()}")
|
logger.info(f"Penal Charge response: {response.json()}")
|
||||||
return ResponseHelper.success(response.json(), "Successful")
|
return ResponseHelper.success(response.json(), "Successful")
|
||||||
|
|
||||||
|
|||||||
+5
-82
@@ -47,9 +47,6 @@ class Loan(db.Model):
|
|||||||
verify_description = db.Column(db.String(100), nullable=True)
|
verify_description = db.Column(db.String(100), nullable=True)
|
||||||
reference = db.Column(db.String(50), 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)
|
|
||||||
|
|
||||||
customer = relationship(
|
customer = relationship(
|
||||||
"Customer",
|
"Customer",
|
||||||
primaryjoin="Customer.id == Loan.customer_id",
|
primaryjoin="Customer.id == Loan.customer_id",
|
||||||
@@ -95,14 +92,11 @@ class Loan(db.Model):
|
|||||||
'reference': self.reference,
|
'reference': self.reference,
|
||||||
'balance': self.balance,
|
'balance': self.balance,
|
||||||
'tenor': self.tenor,
|
'tenor': self.tenor,
|
||||||
'totalPenalCharge': self.total_penal_charge,
|
|
||||||
'lastPenalDate': self.last_penal_date
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_loan_by_transaction_id(cls, transaction_id):
|
def get_loan_by_transaction_id(cls, transaction_id):
|
||||||
return cls.query.filter_by(transaction_id=transaction_id).first()
|
return cls.query.filter_by(transaction_id=transaction_id).first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_loan_by_loan_id(cls, loan_id):
|
def get_loan_by_loan_id(cls, loan_id):
|
||||||
return cls.query.filter_by(id=loan_id).first()
|
return cls.query.filter_by(id=loan_id).first()
|
||||||
@@ -164,29 +158,6 @@ class Loan(db.Model):
|
|||||||
logger.error(f"Failed to update disburse verify date: {e}")
|
logger.error(f"Failed to update disburse verify date: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def set_disbursement_message(cls, loan_id, description):
|
|
||||||
"""
|
|
||||||
Update the disburse result and description of the loan with the given loan_id.
|
|
||||||
"""
|
|
||||||
# Retrieve loan
|
|
||||||
loan = cls.query.get(loan_id)
|
|
||||||
|
|
||||||
if not loan:
|
|
||||||
raise ValueError(f"Loan with ID {loan_id} does not exist.")
|
|
||||||
|
|
||||||
# Update disburse description only
|
|
||||||
loan.disburse_description = description
|
|
||||||
|
|
||||||
# Commit changes to database
|
|
||||||
try:
|
|
||||||
logger.info(f"Updating disburse result for loan ID {loan_id} with description {description}")
|
|
||||||
db.session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
logger.error(f"Failed to update disbursement result: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_disbursement_result(cls, loan_id, result, description):
|
def set_disbursement_result(cls, loan_id, result, description):
|
||||||
@@ -211,8 +182,6 @@ class Loan(db.Model):
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
logger.error(f"Failed to update disbursement result: {e}")
|
logger.error(f"Failed to update disbursement result: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_disburse_verify_result(cls, loan_id, result, description):
|
def set_disburse_verify_result(cls, loan_id, result, description):
|
||||||
"""
|
"""
|
||||||
@@ -241,15 +210,9 @@ class Loan(db.Model):
|
|||||||
"""
|
"""
|
||||||
Get the latest loan without a disbursement date.
|
Get the latest loan without a disbursement date.
|
||||||
"""
|
"""
|
||||||
logger.info("Fetching latest loan without disburse date")
|
return cls.query.filter(
|
||||||
|
cls.disburse_date.is_(None)
|
||||||
try:
|
).order_by(cls.created_at.desc()).first()
|
||||||
return cls.query.filter(
|
|
||||||
cls.disburse_date.is_(None)
|
|
||||||
).order_by(cls.created_at.desc()).first()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching latest loan without disburse date: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_latest_loan_with_disburse_date(cls):
|
def get_latest_loan_with_disburse_date(cls):
|
||||||
@@ -354,16 +317,11 @@ class Loan(db.Model):
|
|||||||
|
|
||||||
# Ensure valid repayment amount
|
# Ensure valid repayment amount
|
||||||
if amount_collected <= Decimal("0.00"):
|
if amount_collected <= Decimal("0.00"):
|
||||||
logger.info(f"Repayment amount is less than or equal to 0: {amount_collected}. Must be greater than 0.00")
|
raise ValueError("Repayment amount must be greater than zero.")
|
||||||
if balance <= Decimal("0.00"):
|
if balance <= Decimal("0.00"):
|
||||||
raise ValueError("There is no balance for this loan.")
|
raise ValueError("There is no balance for this loan.")
|
||||||
if amount_collected > balance:
|
if amount_collected > balance:
|
||||||
# allow tiny rounding diff
|
raise ValueError("Repayment amount exceeds current loan balance.")
|
||||||
if abs(amount_collected - balance) <= Decimal("0.01"):
|
|
||||||
amount_collected = balance
|
|
||||||
else:
|
|
||||||
raise ValueError("Repayment amount exceeds current loan balance.")
|
|
||||||
|
|
||||||
|
|
||||||
# Deduct the amount from the current balance
|
# Deduct the amount from the current balance
|
||||||
new_balance = balance - amount_collected
|
new_balance = balance - amount_collected
|
||||||
@@ -378,38 +336,3 @@ class Loan(db.Model):
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
logger.error(f"Error updating loan balance: {e}")
|
logger.error(f"Error updating loan balance: {e}")
|
||||||
raise Exception(f"Error updating loan balance: {str(e)}")
|
raise Exception(f"Error updating loan balance: {str(e)}")
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_overdue_loans(cls):
|
|
||||||
"""
|
|
||||||
Get all overdue loans.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
overdue_loans = cls.query.filter(
|
|
||||||
cls.due_date < datetime.now(timezone.utc),
|
|
||||||
cls.status != 'repaid'
|
|
||||||
).all()
|
|
||||||
|
|
||||||
if not overdue_loans:
|
|
||||||
logger.info("No overdue loans found.")
|
|
||||||
return []
|
|
||||||
|
|
||||||
logger.info(f"Found {len(overdue_loans)} overdue loans.")
|
|
||||||
return overdue_loans
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching overdue loans: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def apply_penal_to_loan(cls, loan_id, penal_amount):
|
|
||||||
|
|
||||||
loan = cls.query.get(loan_id)
|
|
||||||
|
|
||||||
if not loan:
|
|
||||||
raise ValueError("Loan not found")
|
|
||||||
penal_amount = Decimal(str(penal_amount))
|
|
||||||
|
|
||||||
loan.total_penal_charge = Decimal(str(loan.total_penal_charge or 0)) + penal_amount
|
|
||||||
loan.last_penal_date = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from os.path import devnull
|
from os.path import devnull
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
@@ -43,85 +43,7 @@ class LoanCharge(db.Model):
|
|||||||
'description': self.description,
|
'description': self.description,
|
||||||
'due': self.due
|
'due': self.due
|
||||||
}
|
}
|
||||||
#get last penal
|
|
||||||
@classmethod
|
|
||||||
def get_last_penal_no(cls, loan_id):
|
|
||||||
"""
|
|
||||||
Returns the last penal number created for a loan.
|
|
||||||
Example:
|
|
||||||
PENAL1 -> returns 1
|
|
||||||
PENAL3 -> returns 3
|
|
||||||
If none exists, returns 0.
|
|
||||||
"""
|
|
||||||
last_penal = (
|
|
||||||
cls.query
|
|
||||||
.filter(cls.loan_id == loan_id)
|
|
||||||
.filter(cls.code.like("PENAL%"))
|
|
||||||
.order_by(cls.id.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not last_penal:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
return int(last_penal.code.replace("PENAL", ""))
|
|
||||||
except ValueError:
|
|
||||||
return 0
|
|
||||||
@classmethod
|
|
||||||
def get_penal_charges_by_loan_id(cls, loan_id):
|
|
||||||
"""
|
|
||||||
Returns all penal charges for a specific loan.
|
|
||||||
"""
|
|
||||||
return cls.query.filter(
|
|
||||||
cls.loan_id == loan_id,
|
|
||||||
cls.code.like("PENAL%")
|
|
||||||
).all()
|
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_loan_charge_by_debt_id(cls, debt_id):
|
def get_loan_charge_by_debt_id(cls, debt_id):
|
||||||
return cls.query.filter_by(loan_id=debt_id)
|
return cls.query.filter_by(loan_id=debt_id)
|
||||||
|
|
||||||
#create penal charge
|
|
||||||
@classmethod
|
|
||||||
def create_penal_charges_for_loan(cls, loan_id, transaction_id, percent, penal_no, schedule_number, penal_amount=0.0):
|
|
||||||
"""
|
|
||||||
Create a penal charge for a given loan and schedule.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if loan_id is None:
|
|
||||||
raise ValueError("loan_id cannot be None")
|
|
||||||
|
|
||||||
code = f"PENAL{penal_no:02d}-SCHEDULE{schedule_number:02d}"
|
|
||||||
|
|
||||||
# Check if this penal charge already exists
|
|
||||||
existing = cls.query.filter_by(
|
|
||||||
loan_id=loan_id,
|
|
||||||
code=code
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
penal_charge = cls(
|
|
||||||
loan_id=loan_id,
|
|
||||||
transaction_id=transaction_id,
|
|
||||||
code=code,
|
|
||||||
amount=penal_amount,
|
|
||||||
percent=percent,
|
|
||||||
description=f"Penal Charge {penal_no} for loan {loan_id} schedule {schedule_number}",
|
|
||||||
due=True,
|
|
||||||
due_date=now
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
db.session.add(penal_charge)
|
|
||||||
db.session.commit()
|
|
||||||
except IntegrityError as err:
|
|
||||||
db.session.rollback()
|
|
||||||
raise ValueError(f"Database integrity error: {err}")
|
|
||||||
|
|
||||||
return penal_charge
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from app.extensions import db
|
|
||||||
from app.utils.logger import logger
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
from sqlalchemy import or_
|
|
||||||
from app.enums.repayment_schedule_status import RepaymentScheduleStatus
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
|
||||||
# from dateutil.relativedelta import relativedelta
|
|
||||||
|
|
||||||
class LoanRepaymentSchedule(db.Model):
|
|
||||||
__tablename__ = 'loan_repayment_schedules'
|
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
|
||||||
loan_id = db.Column(db.Integer, nullable=False)
|
|
||||||
transaction_id = db.Column(db.String(50), nullable=True)
|
|
||||||
product_id = db.Column(db.String(20), nullable=True)
|
|
||||||
installment_number = db.Column(db.Integer, nullable=False)
|
|
||||||
due_date = db.Column(db.DateTime, nullable=False)
|
|
||||||
installment_amount= db.Column(db.Float, default=0.0)
|
|
||||||
total_repayment_amount = db.Column(db.Float, default=0.0)
|
|
||||||
paid = db.Column(db.Boolean, default=False)
|
|
||||||
paid_at = db.Column(db.DateTime, nullable=True)
|
|
||||||
due_process_date = db.Column(db.DateTime, nullable=True)
|
|
||||||
due_process_count = db.Column(db.Integer, default=0)
|
|
||||||
paid_status = db.Column(db.String(20), nullable=True)
|
|
||||||
repay_description = db.Column(db.String(255), nullable=True)
|
|
||||||
partial_balance = db.Column(db.Float, 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))
|
|
||||||
penal_charge = db.Column(db.Float, default=0.0)
|
|
||||||
penal_count = db.Column(db.Integer, default=0)
|
|
||||||
last_penal_date = db.Column(db.DateTime, nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def to_dict(self):
|
|
||||||
return {
|
|
||||||
'id': self.id,
|
|
||||||
'loan_id': self.loan_id,
|
|
||||||
'product_id': self.product_id,
|
|
||||||
'transaction_id': self.transaction_id,
|
|
||||||
'installment_number': self.installment_number,
|
|
||||||
'due_date': self.due_date.isoformat() if self.due_date else None,
|
|
||||||
'installment_amount': self.installment_amount,
|
|
||||||
'total_repayment_amount': self.total_repayment_amount,
|
|
||||||
'paid': self.paid,
|
|
||||||
'due_process_date': self.due_process_date.isoformat() if self.due_process_date else None,
|
|
||||||
'due_process_count': self.due_process_count,
|
|
||||||
'paid_status': self.paid_status,
|
|
||||||
'repay_description': self.repay_description,
|
|
||||||
'partial_balance': self.partial_balance,
|
|
||||||
'paid_at': self.paid_at.isoformat() if self.paid_at else None,
|
|
||||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
||||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
|
||||||
'penal_charge': self.penal_charge,
|
|
||||||
'penal_count': self.penal_count,
|
|
||||||
'last_penal_date': self.last_penal_date.isoformat() if self.last_penal_date else None
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f'<LoanRepaymentSchedule Loan:{self.loan_id} Installment:{self.installment_number}>'
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_repayment_schedule_by_loan_id(cls, loan_id, include_paid=True):
|
|
||||||
"""
|
|
||||||
Get repayment schedules by loan ID.
|
|
||||||
|
|
||||||
:param loan_id: Loan ID to filter by
|
|
||||||
:param include_paid: If True, include all schedules. If False, only unpaid ones.
|
|
||||||
:return: List of repayment schedules ordered by due_date
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
query = cls.query.filter_by(loan_id=loan_id)
|
|
||||||
if not include_paid:
|
|
||||||
query = query.filter_by(paid=False)
|
|
||||||
|
|
||||||
schedules = query.order_by(cls.due_date.asc()).all()
|
|
||||||
return schedules
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching repayment schedules for loan {loan_id}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_repayment_schedule_by_id_and_transaction_id(cls, id, transaction_id):
|
|
||||||
"""
|
|
||||||
Get repayment schedule by ID and transaction ID
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return cls.query.filter_by(id=id, transaction_id=transaction_id).first()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching repayment schedule for id={id}, transaction_id={transaction_id}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_overdue_repayment_schedule(cls):
|
|
||||||
"""
|
|
||||||
Get all overdue repayment schedules that are not repaid.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return cls.query.filter(cls.due_date < datetime.now(timezone.utc), cls.paid == False).order_by(cls.due_date.asc()).all()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching overdue repayment schedules: {e}")
|
|
||||||
return []
|
|
||||||
@classmethod
|
|
||||||
def get_active_overdue_repayment_schedule(cls):
|
|
||||||
"""
|
|
||||||
Get all overdue repayment schedules that are active.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return (
|
|
||||||
cls.query
|
|
||||||
.filter(
|
|
||||||
cls.due_date < datetime.now(timezone.utc),
|
|
||||||
cls.paid_status == RepaymentScheduleStatus.ACTIVE
|
|
||||||
)
|
|
||||||
.order_by(cls.due_date.asc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching active overdue repayment schedules: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_overdue_repayment_schedule_with_grace_period(cls, grace_period_days, limit=None):
|
|
||||||
try:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
grace_period_date = now - timedelta(days=grace_period_days)
|
|
||||||
penal_interval = timedelta(days=settings.PENAL_CHARGE_INTERVAL_DAYS)
|
|
||||||
|
|
||||||
return cls.query.filter(
|
|
||||||
cls.due_date < grace_period_date,
|
|
||||||
cls.paid == False,
|
|
||||||
or_(
|
|
||||||
cls.last_penal_date == None, # never penalized before
|
|
||||||
cls.last_penal_date < now - penal_interval
|
|
||||||
)
|
|
||||||
).order_by(cls.due_date.asc()).limit(limit).all()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching overdue repayment schedules with grace period: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_partially_paid_overdue_repayment_schedule(cls):
|
|
||||||
"""
|
|
||||||
Get all overdue repayment schedules that are partially paid.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return (
|
|
||||||
cls.query
|
|
||||||
.filter(
|
|
||||||
cls.due_date < datetime.now(timezone.utc),
|
|
||||||
cls.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID
|
|
||||||
)
|
|
||||||
.order_by(cls.due_date.asc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching partially paid overdue repayment schedules: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_repayment_schedule_by_transaction_id(cls, transaction_id):
|
|
||||||
"""
|
|
||||||
Get repayment schedule by transaction ID
|
|
||||||
"""
|
|
||||||
return cls.query.filter_by(transaction_id=transaction_id).all()
|
|
||||||
@classmethod
|
|
||||||
def update_repayment_schedule_description(cls, schedule_id, description):
|
|
||||||
"""
|
|
||||||
Update the repayment description for a specific schedule.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
schedule = cls.query.get(schedule_id)
|
|
||||||
if not schedule:
|
|
||||||
raise ValueError(f"Schedule with ID {schedule_id} does not exist.")
|
|
||||||
|
|
||||||
schedule.repay_description = description
|
|
||||||
schedule.updated_at = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
logger.info(f"Updated repayment description for schedule ID {schedule_id}")
|
|
||||||
|
|
||||||
return schedule.to_dict()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
logger.error(f"Error updating repayment description for schedule {schedule_id}: {e}")
|
|
||||||
raise
|
|
||||||
@classmethod
|
|
||||||
def update_repayment_schedule_status(cls, schedule_id):
|
|
||||||
"""
|
|
||||||
Mark a repayment schedule as fully repaid when the parent loan is fully repaid.
|
|
||||||
This function does not take amount_collected because the loan is already cleared.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Fetch schedule
|
|
||||||
schedule = cls.query.get(schedule_id)
|
|
||||||
if not schedule:
|
|
||||||
raise ValueError(f"Schedule with ID {schedule_id} does not exist.")
|
|
||||||
|
|
||||||
# Force balance to 0
|
|
||||||
schedule.partial_balance = 0.0
|
|
||||||
schedule.paid_status = RepaymentScheduleStatus.REPAID
|
|
||||||
schedule.paid = True
|
|
||||||
schedule.paid_at = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# Track due processing
|
|
||||||
if schedule.due_process_count is None:
|
|
||||||
schedule.due_process_count = 0
|
|
||||||
schedule.due_process_count += 1
|
|
||||||
schedule.due_process_date = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# Update timestamp
|
|
||||||
schedule.updated_at = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# Commit changes
|
|
||||||
db.session.commit()
|
|
||||||
logger.info(f"Schedule {schedule_id} marked as REPAID since parent loan is fully repaid.")
|
|
||||||
|
|
||||||
return schedule.to_dict()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
logger.error(f"Error updating repayment schedule {schedule_id} after loan repayment: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def update_repayment_schedule_status_to_active(cls, schedule_id):
|
|
||||||
"""
|
|
||||||
Update repayment schedule status to ACTIVE.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
schedule = cls.query.get(schedule_id)
|
|
||||||
if not schedule:
|
|
||||||
raise ValueError(f"Schedule with ID {schedule_id} does not exist.")
|
|
||||||
schedule.paid_status = RepaymentScheduleStatus.ACTIVE
|
|
||||||
schedule.updated_at = datetime.now(timezone.utc)
|
|
||||||
db.session.commit()
|
|
||||||
logger.info(f"Updated repayment schedule ID {schedule_id} status to ACTIVE")
|
|
||||||
return schedule.to_dict()
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
logger.error(f"Error updating repayment schedule status for schedule {schedule_id}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def update_repayment_schedule_balance(cls, schedule_id, amount_collected):
|
|
||||||
"""
|
|
||||||
Apply repayment to a loan schedule:
|
|
||||||
- Deduct from partial balance if partially paid.
|
|
||||||
- Otherwise deduct from installment amount.
|
|
||||||
- Update partial balance, paid status, timestamps, etc.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
schedule = cls.query.get(schedule_id)
|
|
||||||
if not schedule:
|
|
||||||
raise ValueError(f"Schedule with ID {schedule_id} does not exist.")
|
|
||||||
|
|
||||||
# Normalize amount
|
|
||||||
amount_collected = Decimal(str(amount_collected)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
|
||||||
if amount_collected <= Decimal("0.00"):
|
|
||||||
logger.info("Repayment amount must be greater than zero.")
|
|
||||||
return schedule.to_dict()
|
|
||||||
|
|
||||||
# Determine current balance
|
|
||||||
if schedule.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID and (schedule.partial_balance or 0) > 0:
|
|
||||||
balance = Decimal(str(schedule.partial_balance))
|
|
||||||
else:
|
|
||||||
balance = Decimal(str(schedule.installment_amount))
|
|
||||||
|
|
||||||
# Deduct repayment
|
|
||||||
new_balance = balance - amount_collected
|
|
||||||
if new_balance < 0:
|
|
||||||
new_balance = Decimal("0.00") # prevent negatives
|
|
||||||
|
|
||||||
# Update schedule fields
|
|
||||||
schedule.partial_balance = float(new_balance) if new_balance > 0 else 0.0
|
|
||||||
schedule.updated_at = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
if new_balance == 0:
|
|
||||||
schedule.paid_status = RepaymentScheduleStatus.REPAID
|
|
||||||
schedule.paid = True
|
|
||||||
schedule.paid_at = datetime.now(timezone.utc)
|
|
||||||
else:
|
|
||||||
schedule.paid_status = RepaymentScheduleStatus.PARTIALLY_PAID
|
|
||||||
schedule.paid = False # not fully paid yet
|
|
||||||
|
|
||||||
# Track due processing
|
|
||||||
if schedule.due_process_count is None:
|
|
||||||
schedule.due_process_count = 0
|
|
||||||
schedule.due_process_count += 1
|
|
||||||
schedule.due_process_date = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# Commit
|
|
||||||
db.session.commit()
|
|
||||||
logger.info(f"Repayment applied for schedule ID {schedule_id}. Remaining balance: {schedule.partial_balance}")
|
|
||||||
|
|
||||||
return schedule.to_dict()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
logger.error(f"Error applying repayment for schedule {schedule_id}: {e}")
|
|
||||||
raise
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def apply_penal_to_schedule(cls, schedule_id, penal_amount):
|
|
||||||
|
|
||||||
schedule = cls.query.get(schedule_id)
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
penal_amount = Decimal(str(penal_amount))
|
|
||||||
|
|
||||||
current_penal = Decimal(str(schedule.penal_charge)) if schedule.penal_charge else Decimal("0")
|
|
||||||
|
|
||||||
schedule.penal_count = (schedule.penal_count or 0) + 1
|
|
||||||
schedule.penal_charge = current_penal + penal_amount
|
|
||||||
schedule.last_penal_date = now
|
|
||||||
schedule.due_process_date = now
|
|
||||||
schedule.updated_at = now
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
# Calculate penal charge
|
|
||||||
@classmethod
|
|
||||||
def calculate_penal_charge(cls, schedule):
|
|
||||||
|
|
||||||
if schedule.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID:
|
|
||||||
outstanding = Decimal(str(schedule.partial_balance))
|
|
||||||
else:
|
|
||||||
outstanding = Decimal(str(schedule.installment_amount))
|
|
||||||
|
|
||||||
rate = Decimal(str(settings.PENAL_CHARGE_PERCENTAGE)) / 100
|
|
||||||
|
|
||||||
penal_charge = (outstanding * rate).quantize(
|
|
||||||
Decimal("0.01"),
|
|
||||||
rounding=ROUND_HALF_UP
|
|
||||||
)
|
|
||||||
|
|
||||||
return penal_charge
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.utils.logger import logger
|
|
||||||
from sqlalchemy import and_, or_, not_
|
|
||||||
|
|
||||||
class Transaction(db.Model):
|
class Transaction(db.Model):
|
||||||
__tablename__ = "transactions"
|
__tablename__ = "transactions"
|
||||||
|
|
||||||
@@ -21,31 +18,6 @@ class Transaction(db.Model):
|
|||||||
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
|
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))
|
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create_transaction(cls, transaction_id, account_id, customer_id, type, channel):
|
|
||||||
logger.error(f"**Setting Transaction {transaction_id} for Type {type}")
|
|
||||||
if cls.query.filter( and_( cls.transaction_id ==transaction_id, cls.type==type) ).first():
|
|
||||||
logger.error(f"Transaction already exists for {type}")
|
|
||||||
return '' # dont raise - do not crash beacause of this
|
|
||||||
|
|
||||||
transaction = cls(
|
|
||||||
transaction_id = transaction_id,
|
|
||||||
customer_id = customer_id,
|
|
||||||
account_id = account_id,
|
|
||||||
type = type,
|
|
||||||
channel = channel,
|
|
||||||
created_at=datetime.now(timezone.utc),
|
|
||||||
updated_at=datetime.now(timezone.utc)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
db.session.add(transaction)
|
|
||||||
db.session.commit()
|
|
||||||
except IntegrityError as err:
|
|
||||||
raise ValueError(f"Database integrity error: {err}")
|
|
||||||
|
|
||||||
return transaction
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Transaction {self.id}>'
|
return f'<Transaction {self.id}>'
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
import requests
|
import requests
|
||||||
from app.extensions import db
|
|
||||||
from sqlalchemy import text
|
|
||||||
from app.utils.auth import get_headers
|
from app.utils.auth import get_headers
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.utils.logger import logger
|
from app.utils.logger import logger
|
||||||
from app.integrations.bank_service import BankService
|
|
||||||
|
|
||||||
auth_bp = Blueprint("auth", __name__)
|
auth_bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
@@ -15,58 +12,7 @@ BASE_URL = settings.BANK_CALL_BASE_URL
|
|||||||
@auth_bp.route("/health", methods=["GET"])
|
@auth_bp.route("/health", methods=["GET"])
|
||||||
def health():
|
def health():
|
||||||
logger.info("Health check endpoint called")
|
logger.info("Health check endpoint called")
|
||||||
errors = [] # collect all errors
|
return jsonify({"status": "Up"})
|
||||||
|
|
||||||
try:
|
|
||||||
# Detect database type
|
|
||||||
dialect = db.engine.dialect.name.lower()
|
|
||||||
logger.info(f"Database dialect detected: {dialect}")
|
|
||||||
|
|
||||||
# Build correct query based on DB type
|
|
||||||
if "oracle" in dialect:
|
|
||||||
query = text("SELECT 1 FROM dual")
|
|
||||||
else:
|
|
||||||
query = text("SELECT 1")
|
|
||||||
|
|
||||||
# Test database connection
|
|
||||||
try:
|
|
||||||
db.session.execute(query)
|
|
||||||
logger.info("Database connection successful.")
|
|
||||||
except Exception as db_err:
|
|
||||||
logger.error(f"Database connection failed: {str(db_err)}")
|
|
||||||
errors.append(f"Database connection failed: {str(db_err)}")
|
|
||||||
|
|
||||||
# Check Bank Service health
|
|
||||||
try:
|
|
||||||
bank_response = BankService.health_check()
|
|
||||||
logger.info(f"Bank Service health check response: {bank_response}")
|
|
||||||
except Exception as bank_err:
|
|
||||||
logger.error(f"Bank Service health check failed: {str(bank_err)}")
|
|
||||||
errors.append(f"Bank Service health check failed: {str(bank_err)}")
|
|
||||||
|
|
||||||
# Build final response
|
|
||||||
if errors:
|
|
||||||
return jsonify({
|
|
||||||
"status": "error",
|
|
||||||
"database": dialect,
|
|
||||||
"errors": errors
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"status": "success",
|
|
||||||
"database": dialect,
|
|
||||||
"db_status": "connected",
|
|
||||||
"bank_service_status": "operational",
|
|
||||||
"message": "All systems operational"
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Unexpected error during health check")
|
|
||||||
return jsonify({
|
|
||||||
"status": "error",
|
|
||||||
"errors": [str(e)]
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/login", methods=["POST"])
|
@auth_bp.route("/login", methods=["POST"])
|
||||||
|
|||||||
+2
-428
@@ -1,27 +1,20 @@
|
|||||||
import time as time_module
|
|
||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
import requests
|
import requests
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.helpers.response_helper import ResponseHelper
|
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.auth import get_headers
|
||||||
from app.utils.logger import logger
|
from app.utils.logger import logger
|
||||||
from app.integrations.simbrella import SimbrellaClient
|
from app.integrations.simbrella import SimbrellaClient
|
||||||
from app.services.loan import LoanService
|
from app.services.loan import LoanService
|
||||||
from app.services.repayment import RepaymentService
|
from app.services.repayment import RepaymentService
|
||||||
from app.services.salary import SalaryService
|
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.loan_status import LoanStatus
|
||||||
from app.enums.repayment_schedule_status import RepaymentScheduleStatus
|
|
||||||
from app.utils.mail import send_report_email, get_report_data
|
from app.utils.mail import send_report_email, get_report_data
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
autocall_bp = Blueprint("autocall", __name__)
|
autocall_bp = Blueprint("autocall", __name__)
|
||||||
|
|
||||||
|
|
||||||
@autocall_bp.route("/refresh-verify-disbursement", methods=["GET"])
|
@autocall_bp.route("/refresh-verify-disbursement", methods=["GET"])
|
||||||
def verify_transaction():
|
def verify_transaction():
|
||||||
logger.info(f"Calling VerifyTransaction Components")
|
logger.info(f"Calling VerifyTransaction Components")
|
||||||
@@ -35,7 +28,7 @@ def verify_transaction():
|
|||||||
|
|
||||||
data = {
|
data = {
|
||||||
"transactionId": loan_data.get('transactionId'),
|
"transactionId": loan_data.get('transactionId'),
|
||||||
"fbnTransactionId": loan_data.get('transactionId'),
|
"FbnTransactionId": loan_data.get('transactionId'),
|
||||||
"debtId": str(loan_data.get('debtId')),
|
"debtId": str(loan_data.get('debtId')),
|
||||||
"customerId": loan_data.get('customerId'),
|
"customerId": loan_data.get('customerId'),
|
||||||
"accountId": loan_data.get('accountId'),
|
"accountId": loan_data.get('accountId'),
|
||||||
@@ -68,179 +61,6 @@ def disbursement():
|
|||||||
response = SimbrellaClient.disburse_loan(data)
|
response = SimbrellaClient.disburse_loan(data)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@autocall_bp.route("/retry-disbursement", methods=["POST"])
|
|
||||||
def retry_disbursement():
|
|
||||||
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("/direct/loan", methods=["POST"])
|
|
||||||
def direct_loan():
|
|
||||||
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():
|
|
||||||
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"])
|
@autocall_bp.route("/refresh-verify-collection", methods=["GET"])
|
||||||
def refresh_verify_collection():
|
def refresh_verify_collection():
|
||||||
@@ -368,11 +188,6 @@ def process_salary_list():
|
|||||||
# Step 4: Create repayments for each loan
|
# Step 4: Create repayments for each loan
|
||||||
for loan in loans:
|
for loan in loans:
|
||||||
logger.info(f"Processing Loan ID: {loan.id}")
|
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:
|
try:
|
||||||
repayment_data = {
|
repayment_data = {
|
||||||
"customerId": loan.customer_id,
|
"customerId": loan.customer_id,
|
||||||
@@ -393,8 +208,7 @@ def process_salary_list():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if loan.status == LoanStatus.ACTIVE:
|
LoanService.update_status(loan_id=loan.id, status=LoanStatus.START_REPAY)
|
||||||
LoanService.update_status(loan_id=loan.id, status=LoanStatus.START_REPAY)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
logger.error(f"Failed to update loan status for loan ID {loan.id}: {e}")
|
logger.error(f"Failed to update loan status for loan ID {loan.id}: {e}")
|
||||||
@@ -442,243 +256,3 @@ def report():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error generating or sending report: {e}")
|
logger.error(f"Error generating or sending report: {e}")
|
||||||
return ResponseHelper.error("Failed to send report", status_code=500, error=str(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():
|
|
||||||
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():
|
|
||||||
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}")
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
from app.models import Loan, LoanCharge
|
from app.models import Loan, LoanCharge
|
||||||
from app.utils.logger import logger
|
|
||||||
from app.enums.loan_status import LoanStatus
|
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
|
||||||
from app.services.loan_repayment_schedule import LoanRepaymentScheduleService
|
|
||||||
|
|
||||||
class LoanService:
|
class LoanService:
|
||||||
|
|
||||||
@@ -54,13 +50,6 @@ class LoanService:
|
|||||||
Update the disbursement result of the loan with the given loan_id.
|
Update the disbursement result of the loan with the given loan_id.
|
||||||
"""
|
"""
|
||||||
return Loan.set_disbursement_result(loan_id, result, description)
|
return Loan.set_disbursement_result(loan_id, result, description)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def set_disbursement_loan_description(cls,loan_id,description):
|
|
||||||
|
|
||||||
return Loan.set_disbursement_message(loan_id, description)
|
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_disburse_verify_result(cls, loan_id, result, description):
|
def set_disburse_verify_result(cls, loan_id, result, description):
|
||||||
"""
|
"""
|
||||||
@@ -110,39 +99,3 @@ class LoanService:
|
|||||||
update the loan balance after successful repayment
|
update the loan balance after successful repayment
|
||||||
"""
|
"""
|
||||||
return Loan.update_loan_balance(loan_id,amount_collected)
|
return Loan.update_loan_balance(loan_id,amount_collected)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_overdue_loans(cls):
|
|
||||||
"""
|
|
||||||
Get all overdue loans.
|
|
||||||
"""
|
|
||||||
return Loan.get_overdue_loans()
|
|
||||||
@classmethod
|
|
||||||
def apply_penal_to_loan(cls,loan_id,penal_charge):
|
|
||||||
return Loan.apply_penal_to_loan(loan_id,penal_charge)
|
|
||||||
@staticmethod
|
|
||||||
def _update_loan_after_collection(loan, loan_data, updated_loan, amount_collected, data, response_message):
|
|
||||||
if loan.balance is None or loan.balance <= 0:
|
|
||||||
logger.warning(f"Loan ID {loan.id} has no balance. Skipping loan update.")
|
|
||||||
updated_loan = loan.to_dict()
|
|
||||||
else:
|
|
||||||
updated_loan = LoanService.update_loan_balance(int(loan_data['debtId']), amount_collected)
|
|
||||||
updated_balance = Decimal(str(updated_loan['balance'])).quantize(Decimal('0.01'))
|
|
||||||
|
|
||||||
if updated_balance <= Decimal('0.00'):
|
|
||||||
updated_loan = LoanService.update_status(updated_loan['debtId'], LoanStatus.REPAID)
|
|
||||||
else:
|
|
||||||
updated_loan = LoanService.update_status(updated_loan['debtId'], LoanStatus.ACTIVE_PARTIAL)
|
|
||||||
logger.info(f"Updated loan status: {updated_loan.get('status')}")
|
|
||||||
# lets update the loan repayment schedule
|
|
||||||
LoanRepaymentScheduleService.handle_schedule_updates(
|
|
||||||
updated_loan=updated_loan,
|
|
||||||
data=data,
|
|
||||||
amount_collected=amount_collected,
|
|
||||||
message=response_message,
|
|
||||||
loan_data=loan_data
|
|
||||||
)
|
|
||||||
return updated_loan
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from app.models.loan_charge import LoanCharge
|
|
||||||
|
|
||||||
class LoanChargesService:
|
|
||||||
@classmethod
|
|
||||||
def create_penal_charges_for_loan(cls, loan_id, transaction_id, percent, penal_no, schedule_number, penal_amount=0.0,):
|
|
||||||
return LoanCharge.create_penal_charges_for_loan(loan_id, transaction_id, percent, penal_no,schedule_number, penal_amount)
|
|
||||||
@classmethod
|
|
||||||
def get_last_penal_no(cls,loan_id):
|
|
||||||
return LoanCharge.get_last_penal_no(loan_id)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_penal_charges_by_loan_id(cls,loan_id):
|
|
||||||
return LoanCharge.get_penal_charges_by_loan_id(loan_id)
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
from app.models.loan_repayment_schedule import LoanRepaymentSchedule
|
|
||||||
from app.utils.logger import logger
|
|
||||||
from app.enums.loan_status import LoanStatus
|
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
|
||||||
|
|
||||||
|
|
||||||
class LoanRepaymentScheduleService:
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_repayment_schedule_by_loan_id(cls, loan_id, include_paid=True):
|
|
||||||
return LoanRepaymentSchedule.get_repayment_schedule_by_loan_id(loan_id, include_paid=include_paid)
|
|
||||||
@classmethod
|
|
||||||
def get_overdue_repayment_schedule(cls):
|
|
||||||
return LoanRepaymentSchedule.get_overdue_repayment_schedule()
|
|
||||||
@classmethod
|
|
||||||
def get_active_overdue_repayment_schedule(cls):
|
|
||||||
return LoanRepaymentSchedule.get_active_overdue_repayment_schedule()
|
|
||||||
@classmethod
|
|
||||||
def get_partially_paid_overdue_repayment_schedule(cls):
|
|
||||||
return LoanRepaymentSchedule.get_partially_paid_overdue_repayment_schedule()
|
|
||||||
@classmethod
|
|
||||||
def get_repayment_schedule_by_id_and_transaction_id(cls, id, transaction_id):
|
|
||||||
return LoanRepaymentSchedule.get_repayment_schedule_by_id_and_transaction_id(id, transaction_id)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_repayment_schedule_by_transaction_id(cls, transaction_id):
|
|
||||||
return LoanRepaymentSchedule.get_repayment_schedule_by_transaction_id(transaction_id)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def update_repayment_schedule_status(cls, schedule_id):
|
|
||||||
"""
|
|
||||||
Update repayment schedule status.
|
|
||||||
"""
|
|
||||||
return LoanRepaymentSchedule.update_repayment_schedule_status(schedule_id)
|
|
||||||
@classmethod
|
|
||||||
def update_repayment_schedule_status_to_active(cls, schedule_id):
|
|
||||||
"""
|
|
||||||
Update repayment schedule status.
|
|
||||||
"""
|
|
||||||
return LoanRepaymentSchedule.update_repayment_schedule_status_to_active(schedule_id)
|
|
||||||
@classmethod
|
|
||||||
def update_repayment_schedule_balance(cls, schedule_id, amount_collected):
|
|
||||||
"""
|
|
||||||
Update repayment schedule balance.
|
|
||||||
"""
|
|
||||||
return LoanRepaymentSchedule.update_repayment_schedule_balance(schedule_id, amount_collected)
|
|
||||||
@classmethod
|
|
||||||
def calculate_penal_charge(cls, schedule):
|
|
||||||
"""
|
|
||||||
Calculate penal charge for a repayment schedule.
|
|
||||||
"""
|
|
||||||
return LoanRepaymentSchedule.calculate_penal_charge(schedule)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def update_repayment_schedule_description(cls, schedule_id, description):
|
|
||||||
"""
|
|
||||||
Update repayment schedule description.
|
|
||||||
"""
|
|
||||||
return LoanRepaymentSchedule.update_repayment_schedule_description(schedule_id, description)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_overdue_repayment_schedule_with_grace_period(cls, grace_period_days, limit=None):
|
|
||||||
return LoanRepaymentSchedule.get_overdue_repayment_schedule_with_grace_period(grace_period_days, limit=limit)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def apply_penal_to_schedule(cls, schedule_id, penal_amount):
|
|
||||||
"""
|
|
||||||
Apply penal charge to a repayment schedule.
|
|
||||||
"""
|
|
||||||
return LoanRepaymentSchedule.apply_penal_to_schedule(schedule_id, penal_amount)
|
|
||||||
@staticmethod
|
|
||||||
def handle_schedule_updates(updated_loan, data, amount_collected, message, loan_data):
|
|
||||||
"""
|
|
||||||
Handles updating loan repayment schedules depending on loan status
|
|
||||||
and overdue schedule data.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Case 1: Loan fully repaid → mark all schedules paid
|
|
||||||
if updated_loan and updated_loan.get('status') == LoanStatus.REPAID:
|
|
||||||
repayment_schedule = LoanRepaymentScheduleService.get_repayment_schedule_by_loan_id(
|
|
||||||
updated_loan['debtId'], include_paid=False
|
|
||||||
)
|
|
||||||
logger.info(f'Loan repayment schedule: {repayment_schedule}')
|
|
||||||
|
|
||||||
if repayment_schedule:
|
|
||||||
for installment in repayment_schedule:
|
|
||||||
try:
|
|
||||||
logger.info(f'Processing installment: {installment}')
|
|
||||||
LoanRepaymentScheduleService.update_repayment_schedule_status(installment.id)
|
|
||||||
LoanRepaymentScheduleService.update_repayment_schedule_description(
|
|
||||||
installment.id,
|
|
||||||
message
|
|
||||||
)
|
|
||||||
logger.info(f'Updated installment {installment.id} as paid')
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update installment {installment.id}: {e}")
|
|
||||||
logger.info('All installments processed')
|
|
||||||
|
|
||||||
# Case 2: Partial repayment made on a full loan without overdueLoanScheduleId
|
|
||||||
elif updated_loan and updated_loan.get('status') == LoanStatus.ACTIVE_PARTIAL and not data.get('overdueLoanScheduleId'):
|
|
||||||
logger.info("Partial repayment detected, but no overdue schedule ID provided.")
|
|
||||||
# TODO: implement proportional installment updates
|
|
||||||
|
|
||||||
# Case 3: when we are processing Overdue schedule repayment → update balance & description
|
|
||||||
elif data.get('overdueLoanScheduleId') is not None:
|
|
||||||
logger.info(f"Overdue loan schedule ID: {data['overdueLoanScheduleId']}")
|
|
||||||
try:
|
|
||||||
schedule_to_update = LoanRepaymentScheduleService.get_repayment_schedule_by_id_and_transaction_id(
|
|
||||||
data["overdueLoanScheduleId"], data["transactionId"]
|
|
||||||
)
|
|
||||||
logger.info(f"Schedule to update: {schedule_to_update}")
|
|
||||||
|
|
||||||
if schedule_to_update is None:
|
|
||||||
logger.warning(
|
|
||||||
f"Repayment schedule not found for ID {data['overdueLoanScheduleId']} "
|
|
||||||
f"and transaction ID {loan_data['transactionId']}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if not schedule_to_update.paid:
|
|
||||||
update_schedule_balance = LoanRepaymentScheduleService.update_repayment_schedule_balance(
|
|
||||||
schedule_to_update.id, amount_collected
|
|
||||||
)
|
|
||||||
logger.info(f"Updated loan schedule balance: {update_schedule_balance}")
|
|
||||||
LoanRepaymentScheduleService.update_repayment_schedule_description(
|
|
||||||
schedule_to_update.id,
|
|
||||||
message
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update repayment schedule installment: {e}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Unexpected error while handling schedule updates: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -9,9 +9,3 @@ class TransactionService:
|
|||||||
"""
|
"""
|
||||||
return Transaction.get_transaction_by_transaction_id(transaction_id)
|
return Transaction.get_transaction_by_transaction_id(transaction_id)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_transaction(transaction_id, account_id, customer_id, type, channel):
|
|
||||||
"""
|
|
||||||
Create Transaction Entry
|
|
||||||
"""
|
|
||||||
return Transaction.create_transaction(transaction_id, account_id, customer_id, type, channel)
|
|
||||||
|
|||||||
+5
-41
@@ -1,45 +1,9 @@
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
import requests
|
|
||||||
from app.utils.logger import logger
|
|
||||||
|
|
||||||
def get_headers():
|
def get_headers():
|
||||||
BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL
|
return {
|
||||||
BANK_CALL_AUTH_ENDPOINT = settings.BANK_CALL_AUTH_ENDPOINT
|
"Content-Type": "application/json",
|
||||||
BANK_CALL_BASIC_AUTH_USERNAME = settings.BANK_CALL_BASIC_AUTH_USERNAME
|
"x-api-key": settings.BANK_CALL_API_KEY,
|
||||||
BANK_CALL_BASIC_AUTH_PASSWORD = settings.BANK_CALL_BASIC_AUTH_PASSWORD
|
"App-Id": settings.BANK_CALL_APP_ID,
|
||||||
BANK_GRANT_TYPE = settings.BANK_GRANT_TYPE
|
|
||||||
#authenticate
|
|
||||||
url = f"{BANK_CALL_BASE_URL}{BANK_CALL_AUTH_ENDPOINT}"
|
|
||||||
data = {
|
|
||||||
"grant_type": BANK_GRANT_TYPE,
|
|
||||||
"username": BANK_CALL_BASIC_AUTH_USERNAME,
|
|
||||||
"password": "G7$k9@pL2!qR"
|
|
||||||
}
|
}
|
||||||
logger.info(f"Calling Bank Call-Auth Endpoint: {url}")
|
|
||||||
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.post(url, json=data, headers=headers, timeout=10)
|
|
||||||
response.raise_for_status() # Raises HTTPError for 4xx/5xx
|
|
||||||
result = response.json()
|
|
||||||
|
|
||||||
|
|
||||||
# Check if access_token is present
|
|
||||||
if 'access_token' not in result:
|
|
||||||
logger.error("No access_token found in Bank Call Auth response")
|
|
||||||
return {"error": "Authentication failed: no access_token returned"}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-api-key": settings.BANK_CALL_API_KEY,
|
|
||||||
"App-Id": settings.BANK_CALL_APP_ID,
|
|
||||||
"Authorization": f"Bearer {result['access_token']}"
|
|
||||||
}
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
logger.error(f"Failed to get auth token: {e}")
|
|
||||||
raise
|
|
||||||
except ValueError as e:
|
|
||||||
logger.error(f"Failed to parse auth response JSON: {e}")
|
|
||||||
raise
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from flask import current_app
|
|||||||
from app.extensions import mail
|
from app.extensions import mail
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from app.utils.logger import logger
|
||||||
|
|
||||||
def get_report_data():
|
def get_report_data():
|
||||||
"""
|
"""
|
||||||
@@ -37,4 +38,5 @@ def send_report_email(report_data: list, recipients: list):
|
|||||||
with current_app.app_context():
|
with current_app.app_context():
|
||||||
mail.send(msg)
|
mail.send(msg)
|
||||||
|
|
||||||
|
|
||||||
return "Report email sent"
|
return "Report email sent"
|
||||||
+2
-50
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Event Manager API
|
title: Eco Integration Event Manager API
|
||||||
description: The documentation for Event Manager API
|
description: The documentation for Eco Event Manager API
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
contact:
|
contact:
|
||||||
name: API Support
|
name: API Support
|
||||||
@@ -17,8 +17,6 @@ servers:
|
|||||||
description: Remote Temporary development server
|
description: Remote Temporary development server
|
||||||
- url: https://event-core.simbrellang.net
|
- url: https://event-core.simbrellang.net
|
||||||
description: Remote development server
|
description: Remote development server
|
||||||
- url: http://10.2.249.133:5000
|
|
||||||
description: Internal development server
|
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
/health:
|
/health:
|
||||||
@@ -210,50 +208,4 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: A successful response
|
description: A successful response
|
||||||
/autocall/overdue-loans:
|
|
||||||
get:
|
|
||||||
summary: Get all overdue loans
|
|
||||||
responses:
|
|
||||||
200:
|
|
||||||
description: A successful response
|
|
||||||
/autocall/process-penal-charges:
|
|
||||||
get:
|
|
||||||
summary: Get all overdue loans with grace period
|
|
||||||
responses:
|
|
||||||
200:
|
|
||||||
description: A successful response
|
|
||||||
/autocall/direct/loan:
|
|
||||||
post:
|
|
||||||
summary: Direct call for loan disbursement
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
transactionId:
|
|
||||||
type: string
|
|
||||||
example: "TXN123456"
|
|
||||||
responses:
|
|
||||||
200:
|
|
||||||
description: A successful response
|
|
||||||
/autocall/direct/repayment:
|
|
||||||
post:
|
|
||||||
summary: Direct call for loan repayment
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
transactionId:
|
|
||||||
type: string
|
|
||||||
example: "TXN123456"
|
|
||||||
|
|
||||||
responses:
|
|
||||||
200:
|
|
||||||
description: A successful response
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user