Compare commits

..

47 Commits

Author SHA1 Message Date
VivianDee ad27a26aec Update offer_analysis.py 2025-11-12 16:00:43 +01:00
VivianDee 537b6d68f9 Update offer_analysis.py 2025-11-12 14:19:02 +01:00
VivianDee e78e7402c8 Update customer.py 2025-11-11 21:35:56 +01:00
CHIEFSOFT\ameye 2a184d134d eligibility clean up 2025-11-07 18:35:52 -05:00
CHIEFSOFT\ameye 5e5f1b83ad checks 2025-11-07 18:17:16 -05:00
CHIEFSOFT\ameye 2413446107 has loan 2025-11-07 18:12:08 -05:00
CHIEFSOFT\ameye ff5cbcc49d 3MPC check 2025-11-07 18:02:35 -05:00
ameye 9f2daad7c8 Merge branch 'get_active_loans_fix' of DigiFi/digifi-BankToProductCore into master 2025-11-05 14:01:35 +00:00
VivianDee 9f7435227f [fix]: Get active loans 2025-11-05 14:23:54 +01:00
CHIEFSOFT\ameye 0961a65b19 added logger 2025-11-03 12:52:01 -05:00
CHIEFSOFT\ameye 798d264748 Handle duplicate repayment attempts 2025-11-03 12:47:17 -05:00
CHIEFSOFT\ameye 5b1d867d49 aloow partial to repay 2025-11-03 10:04:15 -05:00
ameye c4b2df714c Merge branch 'loan_schedules_update' of DigiFi/digifi-BankToProductCore into master 2025-10-30 13:52:45 +00:00
VivianDee 6138085c0e Update provide_loan.py 2025-10-30 14:47:54 +01:00
VivianDee fc9f7fe175 [fix]: swagger and health check response 2025-10-30 14:47:53 +01:00
ameye 2aee3d08ed Merge branch 'bank_Call_authorization' of DigiFi/digifi-BankToProductCore into master 2025-10-27 18:48:35 +00:00
VivianDee d99640345a [fix]: swagger and health check response 2025-10-27 19:42:23 +01:00
VivianDee 6221447353 Merge branch 'master' of https://gitlab.chiefsoft.net/DigiFi/digifi-BankToProductCore 2025-10-27 19:24:41 +01:00
VivianDee 54e52c639b [add]: token expiry 2025-10-27 19:24:08 +01:00
VivianDee bb85c8f166 [add]: bank call auth endpoint 2025-10-27 19:14:59 +01:00
ameye 6077c78840 Merge branch 'update_loan_status_response' of DigiFi/digifi-BankToProductCore into master 2025-10-23 10:42:23 +00:00
VivianDee 9cbc824661 [fix]: settled amount 2025-10-23 09:17:59 +01:00
VivianDee 2e08c636a2 [fix]: Current loan amount 2025-10-23 09:00:29 +01:00
VivianDee ad043ba3f8 [add]: Summary to loan status response 2025-10-23 08:52:28 +01:00
CHIEFSOFT\ameye 63da7e8292 Addded marker on name 2025-10-22 13:23:05 -04:00
CHIEFSOFT\ameye b73bf9a234 changed swagger name 2025-10-22 13:20:24 -04:00
ameye e8abb3c668 Merge branch 'add_dafaults_to_provide_loan_response' of DigiFi/digifi-BankToProductCore into master 2025-10-22 09:44:00 +00:00
VivianDee 396516b941 [update]: added interest to response and fixed defaults amounts due 2025-10-22 09:49:42 +01:00
ameye c59068d3bb Merge branch 'add_dafaults_to_provide_loan_response' of DigiFi/digifi-BankToProductCore into master 2025-10-20 16:27:04 +00:00
VivianDee ccd5b12f2c Update provide_loan.py 2025-10-20 16:57:57 +01:00
VivianDee 9913f6500c Merge branch 'master' of https://gitlab.chiefsoft.net/DigiFi/digifi-BankToProductCore 2025-10-20 16:49:11 +01:00
VivianDee 17c760981a [add]: routes and defaults 2025-10-20 16:47:47 +01:00
CHIEFSOFT\ameye 68aca1407c SELECT table_name FROM user_tables ORDER BY table_name 2025-10-17 19:48:58 -04:00
CHIEFSOFT\ameye 550895b8ef rearrange for parts 2025-10-17 19:22:29 -04:00
CHIEFSOFT\ameye b7e3527f35 added network 2025-10-16 23:49:38 -04:00
ameye 1bab78ec1a Merge branch 'add_db_uri' of DigiFi/digifi-BankToProductCore into master 2025-10-16 10:12:34 +00:00
VivianDee fe5d3fbc6e Update AuthorizeRefresh.json 2025-10-15 12:54:49 +01:00
VivianDee c516c3f52c [fix]: Provide loan charge scheduleand swagger doc 2025-10-15 12:46:13 +01:00
ameye 55140efed8 Merge branch 'add_db_uri' of DigiFi/digifi-BankToProductCore into master 2025-10-15 11:30:52 +00:00
VivianDee f8da81d564 [add]: Loan schedule on provide loan endpoint 2025-10-15 12:01:29 +01:00
ameye 0faf01bcfa Merge branch 'add_db_uri' of DigiFi/digifi-BankToProductCore into master 2025-10-10 16:52:35 +00:00
VivianDee 9da259900c [add]: DB URI 2025-10-10 16:21:28 +01:00
CHIEFSOFT\ameye 4da0b8c716 SQLALCHEMY_DATABASE_URI 2025-10-09 19:17:57 -04:00
CHIEFSOFT\ameye ddb08d8063 rem remove swagger file path 2025-10-09 16:14:30 -04:00
CHIEFSOFT\ameye 8c405c5d2e Try catch at start 2025-10-09 16:00:57 -04:00
vivian.d.simbrellang.com 4c95112bde Merge branch 'health_check' of DigiFi/digifi-BankToProductCore into master 2025-10-06 17:28:49 +00:00
ameye 324293ee96 Merge branch 'health_check' of DigiFi/digifi-BankToProductCore into master 2025-10-03 15:28:18 +00:00
19 changed files with 464 additions and 124 deletions
+14 -11
View File
@@ -22,19 +22,17 @@ def create_app():
# import oracledb
# oracledb.init_oracle_client(lib_dir=None)
app = Flask(__name__)
# Load configuration
app.config.from_object(Config)
CORS(app)
JWTManager(app)
CORS(app, supports_credentials=True)
try:
app = Flask(__name__)
# Load configuration
app.config.from_object(Config)
CORS(app)
JWTManager(app)
CORS(app, supports_credentials=True)
# Swagger Doc
SWAGGER_URL = app.config.get("SWAGGER_URL")
API_URL = app.config.get("API_URL")
@@ -45,6 +43,11 @@ def create_app():
swagger_ui_blueprint = get_swaggerui_blueprint(SWAGGER_URL, API_URL)
app.register_blueprint(swagger_ui_blueprint, url_prefix=SWAGGER_URL)
except Exception as e:
print(f"Swagger Unexpected error occurred: {e}")
try:
# Error Handlers
register_error_handlers(app)
+83 -8
View File
@@ -1,13 +1,64 @@
from os import access
import httpx
import json
import time
from app.utils.logger import logger
from app.config import settings
import logging
class SimbrellaIntegration:
BASE_URL = settings.SIMBRELLA_BASE_URL
ENDPOINT_RAC_CHECKS = settings.SIMBRELLA_ENDPOINT_RAC_CHECKS
HEALTH_ENDPOINT = settings.SIMBRELLA_HEALTH
AUTH_ENDPOINT = settings.BANK_CALL_AUTH_ENDPOINT
_access_token = None # cache token in memory
_token_expiry = 0
@staticmethod
def generate_token():
"""
Generate a new access token using the username and password from settings.
"""
url = f"{SimbrellaIntegration.BASE_URL}{SimbrellaIntegration.AUTH_ENDPOINT}"
payload = {
"username": settings.BANK_CALL_USERNAME,
"password": settings.BANK_CALL_PASSWORD,
"grant_type": "password"
}
headers = {"Content-Type": "application/json"}
try:
logger.info(f"Requesting Bank token from {url}")
response = httpx.post(url, json=payload, headers=headers, timeout=10.0)
response.raise_for_status()
data = response.json()
expires_in = data.get("expires_in", 1800)
SimbrellaIntegration._access_token = data.get("access_token")
SimbrellaIntegration._token_expiry = time.time() + expires_in - 60
if not SimbrellaIntegration._access_token:
raise Exception("Access token not found in Bank Authorization response")
logger.info("Successfully retrieved Bank access token")
return SimbrellaIntegration._access_token
except Exception as e:
logger.error(f"Token generation failed: {str(e)}", exc_info=True)
raise Exception(f"Token generation failed: {str(e)}")
@staticmethod
def _get_token():
"""
Return a valid token, refreshing if expired or missing
"""
if not SimbrellaIntegration._access_token or time.time() >= SimbrellaIntegration._token_expiry:
return SimbrellaIntegration.generate_token()
return SimbrellaIntegration._access_token
@staticmethod
def rac_check(customer_id, account_id, transaction_id):
@@ -26,13 +77,14 @@ class SimbrellaIntegration:
"channel": "USSD"
}
headers = {
"Content-Type": "application/json",
"x-api-key": f"{settings.VALID_API_KEY}",
"App-Id": f"{settings.VALID_APP_ID}",
}
try:
access_token = SimbrellaIntegration._get_token()
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}"
}
response = httpx.post(url, json=payload, headers=headers, timeout=10.0)
logger.info(f"This is Response: {str(response)}", exc_info=True)
@@ -42,4 +94,27 @@ class SimbrellaIntegration:
except Exception as e:
logger.error(f"RACCheck API call failed: {str(e)}", exc_info=True)
raise Exception(f"RACCheck API call failed: {str(e)}")
@staticmethod
def health_check():
"""
Health check for Bank Service
"""
url = f"{SimbrellaIntegration.BASE_URL}/{SimbrellaIntegration.HEALTH_ENDPOINT}"
logger.info(f"Bank Health Check URL: {url}")
try:
access_token = SimbrellaIntegration._get_token()
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}"
}
response = httpx.get(url, headers=headers, timeout=10.0)
logger.info(f"Bank Health Check Response: {response.text}")
return response
except Exception as e:
logger.error(f"Bank Health Check API call failed: {str(e)}", exc_info=True)
raise Exception(f"Bank Health Check API call failed: {str(e)}")
+25 -9
View File
@@ -1,4 +1,5 @@
from app.api.integrations.events_service import EventServiceIntegration
from app.api.integrations.simbrella import SimbrellaIntegration
from flask import Blueprint, request, jsonify, send_from_directory
from app.api.services import (
EligibilityCheckService,
@@ -23,7 +24,6 @@ from flask_jwt_extended import (
from sqlalchemy import text
from app.extensions import db
from app.config import settings
from urllib.parse import urlparse
api = Blueprint("api", __name__)
@@ -42,10 +42,10 @@ def swagger_json():
return send_from_directory(swagger_dir, "digifi_swagger.json")
# @api.route("/swagger/<path:filename>")
# def serve_paths(filename):
# swagger_dir = os.path.join("swagger")
# return send_from_directory(swagger_dir, filename)
@api.route("/swagger/<path:filename>")
def serve_paths(filename):
swagger_dir = os.path.join("swagger")
return send_from_directory(swagger_dir, filename)
# EligibilityCheck Endpoint
@@ -118,7 +118,6 @@ def notification_callback():
response = NotificationCallbackService.process_request(data)
return response
# Health Check Endpoint
@api.route("/health", methods=["GET"])
def health_check():
@@ -126,9 +125,11 @@ def health_check():
response = {}
db_status = "Connection Successful"
events_service_status = "Connection Successful"
bank_status = "Connection Successful"
errors = []
status = "ok"
# Extract the database URI
try:
db_uri = db.engine.url.render_as_string(hide_password=False)
@@ -140,7 +141,7 @@ def health_check():
# Check database connection
try:
logger.info(f"Database Health == : {SQLALCHEMY_DATABASE_URI}")
db.session.execute(text("SELECT 1"))
db.session.execute(text("SELECT table_name FROM user_tables ORDER BY table_name"))
except Exception as e:
db_status = "Connection Failed"
errors.append(f"Database Error: {str(e)}")
@@ -158,16 +159,31 @@ def health_check():
except Exception as e:
events_service_status = "Connection Successful"
events_service_status = "Connection Failed"
status = "failed"
errors.append(f"Events Service connection failed: {str(e)}")
# Check Bank health
try:
emulator_response = SimbrellaIntegration.health_check()
if emulator_response.status_code != 200:
bank_status = "Connection Failed"
status = "failed"
errors.append(f"Bank Connection response: {emulator_response.text}")
except Exception as e:
bank_status = "Connection Failed"
status = "failed"
errors.append(f"Connection to Bank failed: {str(e)}")
response = {
"status": status,
"db_status": db_status,
"db_uri": db_uri,
"events_service_status": events_service_status,
"bank_status": bank_status,
"db_uri": db_uri,
"errors": errors or None
}
+13 -1
View File
@@ -51,6 +51,13 @@ class EligibilityCheckService(BaseService):
db.session.flush()
# Determine if there is any loan of 3MPC active
current_loan = EligibilityCheckService.get_current_active_loans_by_account_id(account_id = account_id)
if current_loan:
logger.info(f"Account {current_loan.account_id} has active loan {current_loan}")
if current_loan.product_id =='3MPC':
return ResponseHelper.error(result_description="Max loan count for 3MPC reached")
# Determine Loan count
is_eligible = EligibilityCheckService.check_loan_limits(customer_id)
@@ -167,7 +174,12 @@ class EligibilityCheckService(BaseService):
logger.error(f"An error occurred: {str(e)}", exc_info=True)
db.session.rollback()
return ResponseHelper.internal_server_error()
@staticmethod
def get_current_active_loans_by_account_id(account_id):
current_loan = Loan.get_current_active_loans_by_account_id(account_id)
return current_loan
@staticmethod
def check_loan_limits(customer_id):
+55 -22
View File
@@ -3,15 +3,15 @@ from marshmallow import ValidationError
from app.api.enums.loan_status import LoanStatus
from app.models import Customer
from app.utils.logger import logger
from app.api.schemas.loan_status import LoanStatusSchema
from app.api.schemas.loan_status import LoanStatusSchema
from app.api.services.base_service import BaseService
from app.api.enums import TransactionType
from app.api.enums import TransactionType
from app.extensions import db
from app.api.helpers.response_helper import ResponseHelper
class LoanStatusService(BaseService):
TRANSACTION_TYPE = TransactionType.LOAN_STATUS
TRANSACTION_TYPE = TransactionType.LOAN_STATUS
@staticmethod
def process_request(data):
@@ -27,31 +27,57 @@ class LoanStatusService(BaseService):
try:
with db.session.begin():
# Validate data
validated_data = LoanStatusService.validate_data(data, LoanStatusSchema())
validated_data = LoanStatusService.validate_data(
data, LoanStatusSchema()
)
customer_id = validated_data.get('customerId')
customer_id = validated_data.get("customerId")
logger.info(f"Looking for customer *** {customer_id}")
customer = Customer.get_customer_with_loan_list(customer_id)
transactionId = validated_data.get('transactionId')
account_id = validated_data.get('accountId')
if(LoanStatusService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
transactionId = validated_data.get("transactionId")
account_id = validated_data.get("accountId")
if LoanStatusService.validate_account_ownership(
account_id=account_id, customer_id=customer_id
):
# Get loans
loans = [loan.to_dict() for loan in customer.loans if loan.status == LoanStatus.ACTIVE]
transaction = LoanStatusService.log_transaction(validated_data = validated_data)
customer_loans = customer.loans
loans = [
loan.to_dict()
for loan in customer_loans
if loan.status in [LoanStatus.ACTIVE, LoanStatus.START_REPAY, LoanStatus.ACTIVE_PARTIAL]
]
transaction = LoanStatusService.log_transaction(
validated_data=validated_data
)
if not transaction:
logger.error(f"Failed to log transaction")
return ResponseHelper.error(result_description="Failed to log transaction.")
else:
return ResponseHelper.error(result_description="Invalid Customer or Account")
total_debt_amount = sum(
loan.get("currentLoanAmount") or 0
for loan in loans
return ResponseHelper.error(
result_description="Failed to log transaction."
)
else:
return ResponseHelper.error(
result_description="Invalid Customer or Account"
)
# CONFIRM IF THE TOTAL DEBT IF FOR ONLY ACTIVE LOANS OR ALL LOANS
total_debt_amount = sum(
loan.get("currentLoanAmount") or 0 for loan in loans
)
total_outstanding_amount = sum(
loan.get("currentLoanAmount") or 0 for loan in loans
)
total_active_loan_amount = sum(
loan.get("repaymentAmount") or 0 for loan in loans
)
total_settled_amount = total_active_loan_amount - total_outstanding_amount
# Simulated processing logic
response_data = {
"customerId": customer_id,
@@ -59,6 +85,11 @@ class LoanStatusService(BaseService):
"transactionId": transactionId,
"loans": loans,
"totalDebtAmount": total_debt_amount,
"summary": {
"totalSettledAmount": total_settled_amount,
"totalOutstandingAmount": total_outstanding_amount,
"totalActiveLoanAmount": total_active_loan_amount,
}
}
db.session.commit()
@@ -68,9 +99,11 @@ class LoanStatusService(BaseService):
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
db.session.rollback()
return ResponseHelper.unprocessable_entity(result_description="Validation exception")
except ValueError as err:
return ResponseHelper.unprocessable_entity(
result_description="Validation exception"
)
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
db.session.rollback()
return ResponseHelper.error(result_description=str(err))
@@ -78,4 +111,4 @@ class LoanStatusService(BaseService):
except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True)
db.session.rollback()
return ResponseHelper.internal_server_error()
return ResponseHelper.internal_server_error()
+7 -2
View File
@@ -180,7 +180,7 @@ class OfferAnalysis:
if real_eligible_amount < original_transaction_offer.min_amount:
logger.error(f"Max eligible amount ({real_eligible_amount}) is less than the minimum offer amount ({original_transaction_offer.min_amount}).")
raise ValueError("You are not eligible for a loan at this time.")
raise ValueError("You are not eligible for a loan at this time - Minimum amount not met.")
# if real_eligible_amount < 100:
# logger.error(f"Max eligible amount ({real_eligible_amount}) is less than the minimum offer amount ({original_transaction_offer.min_amount}).")
@@ -225,7 +225,8 @@ class OfferAnalysis:
if approved_amount < offer.min_amount:
logger.error(f"Max eligible amount ({approved_amount}) is less than the minimum offer amount ({offer.min_amount}).")
raise ValueError("You are not eligible for a loan at this time.")
continue
# raise ValueError("You are not eligible for a loan at this time.")
# if approved_amount < 100:
# logger.error(f"Max eligible amount ({approved_amount}) is less than the minimum offer amount ({offer.min_amount}).")
@@ -255,4 +256,8 @@ class OfferAnalysis:
"tenor": offer.tenor
})
if not eligible_offers:
logger.error("No eligible offers found for customer: {customer_id} - Minimum amount not met")
raise ValueError("You are not eligible for a loan at this time - Minimum amount not met")
return eligible_offers
+88 -8
View File
@@ -1,3 +1,4 @@
from gettext import install
from flask import request, jsonify
from marshmallow import ValidationError
from app.api.integrations.kafka import KafkaIntegration
@@ -11,7 +12,7 @@ from threading import Thread
from app.models import Loan, Offer, Charge , TransactionOffer, RACCheck
from app.api.enums import LoanStatus
from app.extensions import db
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from dateutil.relativedelta import relativedelta
from app.api.integrations import EventServiceIntegration
from app.models import LoanRepaymentSchedule
@@ -143,10 +144,10 @@ class ProvideLoanService(BaseService):
db.session.flush()
current_product_id = offer.product_id
schedule = LoanRepaymentSchedule.add_repayment_schedule(loan = loan, num_schedules = num_schedules, transaction_id = transaction_id)
schedules = LoanRepaymentSchedule.add_repayment_schedule(loan = loan, num_schedules = num_schedules, transaction_id = transaction_id)
if not schedule:
if not schedules:
logger.error(f"Failed to create repayment schedule for loan ID {loan.id}")
return ResponseHelper.error(result_description="Failed to generate loan repayment schedule.")
@@ -158,20 +159,27 @@ class ProvideLoanService(BaseService):
loan_charges = LoanCharge.create_charges_for_loan(loan_id = loan_id, transaction_id = transaction_id, referenced_amount = 800, charges = charges)
else:
return ResponseHelper.error(result_description="Invalid Customer or Account")
charge_schedule_items = ProvideLoanService.get_charge_schedule_items(
loan_charges=loan_charges,
offer=offer,
loan_ref=loan_ref,
amount=amount,
schedules=schedules
)
response_data = {
"requestId": request_id,
"transactionId": transaction_id,
"loanRef": loan_ref,
"customerId": customer_id,
"accountId": account_id,
"msisdn": customer.msisdn
"msisdn": customer.msisdn,
"schedule": charge_schedule_items,
}
@@ -207,4 +215,76 @@ class ProvideLoanService(BaseService):
response = EventServiceIntegration.direct_loan(transaction_id=transaction_id)
return response
@classmethod
def get_charge_schedule_items(cls, loan_charges, offer, loan_ref, amount, schedules):
now = datetime.now(timezone.utc)
due_date = now + timedelta(days=offer.tenor)
id_counter = 1
charge_schedule_items = []
charge_schedule_items.append({
"id": id_counter,
"dueDate": due_date.isoformat(),
"amountDue": amount,
"componentName": "PRINCIPAL",
"startDate": now.isoformat(),
})
interest_amount = 0.0
for charge in loan_charges:
code = charge.code.upper()
if code == "INTEREST":
interest_amount = float(charge.amount)
continue
item = {
"id": id_counter,
"dueDate": charge.due_date.isoformat(),
"amountDue": float(charge.amount),
"componentName": charge.code.upper(), # e.g. INTEREST, MGMT_FEE, VAT_FEE
"startDate": charge.created_at.isoformat(),
}
charge_schedule_items.append(item)
id_counter += 1
num_schedules = len(schedules)
if num_schedules > 0:
principal_per_schedule = amount / num_schedules
else:
principal_per_schedule = 0.0
for schedule in schedules:
default = {
"id": id_counter,
"installmentNo": schedule.installment_number,
"dueDate": schedule.due_date.isoformat(),
"amountDue": round(principal_per_schedule, 2),
"componentName": "DEFAULT",
"startDate": schedule.created_at.isoformat(),
}
charge_schedule_items.append(default)
id_counter += 1
interest = {
"id": id_counter,
"dueDate": schedule.due_date.isoformat(),
"amountDue": round(interest_amount, 2),
"componentName": "INTEREST",
"startDate": schedule.created_at.isoformat()
}
charge_schedule_items.append(interest)
id_counter += 1
return charge_schedule_items
+1 -1
View File
@@ -57,7 +57,7 @@ class RepaymentService(BaseService):
return ResponseHelper.error(result_description="Failed to save repayment details.")
#Update Loan status
Loan.update_status(loan_id = loan_id, status = LoanStatus.START_REPAY) # repay started bu user
Loan.update_status(loan_id = loan_id, status = LoanStatus.START_REPAY) # repay started by user
transaction = RepaymentService.log_transaction(validated_data = validated_data)
if not transaction:
+1
View File
@@ -38,6 +38,7 @@ class SelectOfferService(BaseService):
transaction_id = validated_data.get("transactionId")
request_id = validated_data.get("requestId")
offer_id = int(transaction_offer_id[5:]) # The last part is int
#"offerId": "SAL30001129",
+7
View File
@@ -46,6 +46,13 @@ class Config:
VALID_API_KEY = os.getenv("SIMBRELLA_API_KEY", "test-api-key-12345")
SIMBRELLA_BASE_URL = os.getenv("SIMBRELLA_BASE_URL", "http://127.0.0.1:6337")
SIMBRELLA_ENDPOINT_RAC_CHECKS = os.getenv("SIMBRELLA_ENDPOINT_RAC_CHECKS","api/rac-check")
SIMBRELLA_HEALTH = os.getenv("SIMBRELLA_ENDPOINT_RAC_CHECKS","api/system-health-check")
BANK_CALL_AUTH_ENDPOINT = os.getenv("BANK_CALL_AUTH_ENDPOINT", "/api/Auth/generate-token")
BANK_CALL_USERNAME = os.getenv("BANK_CALL_USERNAME", "simbrella")
BANK_CALL_PASSWORD = os.getenv("BANK_CALL_PASSWORD", "G7$k9@pL2!qR")
EVENTS_SERVICE_BASE_URL = os.getenv("EVENTS_SERVICE_BASE_URL","https://event-core.simbrellang.net")
ENDPOINT_DIRECT_LOAN = os.getenv("ENDPOINT_DIRECT_LOAN","/autocall/direct/loan")
ENDPOINT_DIRECT_REPAYMENT = os.getenv("ENDPOINT_DIRECT_REPAYMENT","/autocall/direct/repayment")
+3 -3
View File
@@ -47,11 +47,11 @@ class Customer(db.Model):
@classmethod
def create_customer(cls, id, msisdn, country_code, account_id, account_type='savings'):
if cls.query.filter_by(id=id).first():
raise ValueError("Customer already exists")
raise ValueError("Customer ID '{id}' already exists.")
elif Account.query.filter_by(id=account_id).first():
raise ValueError("Account already exists")
raise ValueError(f"Account ID '{account_id}' already exists.")
elif cls.query.filter_by(msisdn=msisdn).first():
raise ValueError("msisdn already exists")
raise ValueError("MSISDN '{msisdn}' already exists")
# Create the customer
customer = cls(
+24 -4
View File
@@ -1,5 +1,6 @@
from datetime import datetime, timezone, timedelta
from itertools import product
from app.api.enums.loan_status import LoanStatus
from app.extensions import db
from app.models.customer import Customer
from app.models.account import Account
@@ -192,13 +193,32 @@ class Loan(db.Model):
Get all active loans with the same original_transaction ID.
"""
active_loans = cls.query.filter_by(
original_transaction=original_transaction_id,
# status='active'
active_loans = cls.query.filter(
cls.original_transaction == original_transaction_id,
or_(
cls.status == LoanStatus.ACTIVE.value,
cls.status == LoanStatus.START_REPAY.value,
cls.status == LoanStatus.ACTIVE_PARTIAL.value,
)
).all()
return active_loans
@classmethod
def get_current_active_loans_by_account_id(cls, account_id):
"""
Get the first active loan based on the accountID.
"""
first_active_loan = cls.query.filter(
cls.account_id == account_id,
or_(
cls.status == LoanStatus.ACTIVE.value,
cls.status == LoanStatus.START_REPAY.value,
cls.status == LoanStatus.ACTIVE_PARTIAL.value,
)
).order_by(cls.id.desc()).first()
return first_active_loan
@classmethod
def update_status(cls, loan_id, status):
@@ -246,7 +266,7 @@ class Loan(db.Model):
'loanRef': self.reference,
'productId': self.product_id,
'initialLoanAmount': self.initial_loan_amount,
'currentLoanAmount': self.current_loan_amount,
'currentLoanAmount': self.balance,
'defaultPenaltyFee': self.default_penalty_fee,
'continuousFee': self.continuous_fee,
'collectionType': self.collection_type,
+7 -2
View File
@@ -36,9 +36,8 @@ class Repayment(db.Model):
def create_repayment(cls, customer_id, loan, transaction_id):
# Check that the loan is active
if loan.status not in [LoanStatus.ACTIVE, LoanStatus.START_REPAY]:
if loan.status not in [LoanStatus.ACTIVE, LoanStatus.START_REPAY, LoanStatus.ACTIVE_PARTIAL]:
raise ValueError(f"Repayment cannot be processed. Loan status: ({loan.status})")
repayment = cls(
customer_id=customer_id,
@@ -56,6 +55,12 @@ class Repayment(db.Model):
raise ValueError(f"Database integrity error: {err}")
return repayment
# Get loan repayments
@classmethod
def get_repayments_by_id(cls, loan_id):
return cls.query.filter_by(loan_id=loan_id).all()
def to_dict(self):
return {
+21 -11
View File
@@ -4,6 +4,12 @@ from app.models import account
from sqlalchemy.exc import IntegrityError
from sqlalchemy import and_, or_, not_
from sqlalchemy.sql import func
from app.api.enums import TransactionType
import logging
logger = logging.getLogger(__name__)
class Transaction(db.Model):
__tablename__ = 'transactions'
@@ -20,7 +26,7 @@ class Transaction(db.Model):
phone_number = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def __repr__(self):
return f'<Transaction {self.id}>'
@@ -30,17 +36,21 @@ class Transaction(db.Model):
# if cls.query.filter_by(transaction_id=transaction_id).first():
# raise ValueError("Duplicate Transaction")
if cls.query.filter( and_( cls.transaction_id ==transaction_id, cls.type==type) ).first():
raise ValueError("Duplicate Transaction")
if cls.query.filter(and_(cls.transaction_id == transaction_id, cls.type == type)).first():
if type == TransactionType.REPAYMENT:
logger.info('Repayment transaction already exists :::: But we like to continue.')
now = datetime.now()
type = TransactionType.REPAYMENT + '.'+ now.strftime("%Y%m%d%H%M%S")
logger.info('Modify Type :::: {0}'.format(type))
else:
raise ValueError("Duplicate Transaction")
transaction = cls(
transaction_id = transaction_id,
customer_id = customer_id,
account_id = account_id,
type = type,
channel = channel,
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)
)
@@ -54,4 +64,4 @@ class Transaction(db.Model):
@classmethod
def get_transaction_by_id(cls, transaction_id):
return cls.query.get(transaction_id)
return cls.query.get(transaction_id)
+44 -40
View File
@@ -1,7 +1,7 @@
{
"openapi": "3.0.3",
"info": {
"title": "Swagger Bank Channel to Simbrella FirstAdvance - OpenAPI 3.0",
"title": "Swagger Simbrella Core [10/22/2025] - OpenAPI 3.0",
"description": "This is a Simbrella FirstAdvance Backend Server with the OpenAPI 3.0 specification. \n\n\nSome useful links:\n- [Web Simulated Demo Page](https://digifi-salaryloan.chiefsoft.net/)\n- [Web Management Support Portal](https://digifi-office.chiefsoft.net/auth/login)",
"termsOfService": "http://swagger.io/terms/",
"contact": {
@@ -28,6 +28,10 @@
}
],
"tags": [
{
"name": "Health",
"description": "System health check including DB status."
},
{
"name": "Authorize",
"description": "This feature will be used for authorizing customers.",
@@ -83,13 +87,48 @@
"description": "Find out more",
"url": "https://www.simbrellang.net"
}
},
{
"name": "Health",
"description": "System health check including DB status."
}
],
"paths": {
"/health": {
"get": {
"tags": ["Health"],
"summary": "Health Check",
"description": "Returns service health information including DB connection status.",
"responses": {
"200": {
"description": "Health check successful",
"content": {
"application/json": {
"example": {
"status": "ok",
"db_status": "Connection Successful",
"events_service_status":"Connection Successful",
"bank_status":"Connection Successful",
"db_uri": "postgresql://user:****@localhost:5432/digifi_db",
"error": []
}
}
}
},
"500": {
"description": "Health check failed",
"content": {
"application/json": {
"example": {
"status": "failed",
"db_status": "Connection Failed",
"events_service_status":"Connection Failed",
"bank_status":"Connection Failed",
"db_uri": "Unavailable",
"error":["could not connect to server: Connection refused"]
}
}
}
}
}
}
},
"/Authorize": {
"$ref": "swagger/paths/Authorize.json"
},
@@ -110,41 +149,6 @@
},
"/Repayment": {
"$ref": "swagger/paths/Repayment.json"
},
"/health": {
"get": {
"tags": ["Health"],
"summary": "Health Check",
"description": "Returns service health information including DB connection status.",
"responses": {
"200": {
"description": "Health check successful",
"content": {
"application/json": {
"example": {
"status": "ok",
"db_status": "Connection Successful",
"events_service_status": "healthy",
"error": []
}
}
}
},
"500": {
"description": "Health check failed",
"content": {
"application/json": {
"example": {
"status": "ok",
"db_status": "Connection Failed",
"events_service_status": "unhealthy",
"error":["could not connect to server: Connection refused"]
}
}
}
}
}
}
}
},
"components": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"post": {
"tags": ["Authorize Refresh"],
"tags": ["AuthorizeRefresh"],
"summary": "Customer Authorize Refresh Request",
"description": "Customer Authorize Refresh Request",
"operationId": "AuthorizeRefresh",
@@ -102,6 +102,29 @@
"type": "number",
"format": "float",
"example": 30000.0
},
"summary": {
"type": "object",
"properties": {
"totalOutstandingAmount": {
"type": "number",
"format": "float",
"example": 114450.0,
"description": "Total amount still owed across all unpaid loans."
},
"totalActiveLoanAmount": {
"type": "number",
"format": "float",
"example": 40000.0,
"description": "Total principal amount of currently active loans."
},
"totalSettledAmount": {
"type": "number",
"format": "float",
"example": 80000.0,
"description": "Total amount that has been fully repaid."
}
}
}
},
"xml": {
@@ -25,6 +25,41 @@
"type": "string",
"example": "98016510058"
},
"schedule": {
"type": "array",
"description": "List of loan repayment components with due dates and amounts.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"example": 1
},
"amountDue": {
"type": "number",
"example": 2000.0
},
"componentName": {
"type": "string",
"example": "INTEREST"
},
"dueDate": {
"type": "string",
"format": "date-time",
"example": "2026-01-13T11:36:39.890747+00:00"
},
"startDate": {
"type": "string",
"format": "date-time",
"example": "2025-10-15T11:36:39.890747+00:00"
},
"loanRef": {
"type": "string",
"example": "TRX1760528156816285USSD3MPC"
}
}
}
},
"resultCode": {
"type": "string",
"example": "00"
+12 -1
View File
@@ -1,3 +1,4 @@
version: '3.8'
services:
digifi-bank-to-product-core:
build: .
@@ -11,4 +12,14 @@ services:
- DATABASE_URL=postgresql+psycopg2://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}
volumes:
- .:/app
restart: always
restart: always
networks:
- my_custom_network
networks:
my_custom_network:
driver: bridge
ipam:
config:
- subnet: 10.244.0.0/26