Merge branch 'loan_charges_on_loans' of DigiFi/digifi-BankToProductCore into master

This commit is contained in:
2025-04-17 10:03:16 +00:00
committed by Gogs
15 changed files with 316 additions and 97 deletions
+30 -31
View File
@@ -1,8 +1,9 @@
import requests import httpx
import json import json
from requests.auth import HTTPBasicAuth
from app.utils.logger import logger from app.utils.logger import logger
from app.config import settings from app.config import settings
import logging
class SimbrellaIntegration: class SimbrellaIntegration:
BASE_URL = settings.SIMBRELLA_BASE_URL BASE_URL = settings.SIMBRELLA_BASE_URL
@@ -13,43 +14,41 @@ class SimbrellaIntegration:
Calls the RACCheck endpoit Calls the RACCheck endpoit
""" """
url = f"{SimbrellaIntegration.BASE_URL}/RACCheck" url = f"{SimbrellaIntegration.BASE_URL}/RACCheck"
payload = { payload = {
"customerId": customer_id, "customerId": customer_id,
"accountId": account_id, "accountId": account_id,
"transactionId": transaction_id, "transactionId": str(transaction_id),
"fbnTransactionId": f"FBN{transaction_id}",
"RAC_Array": [ "RAC_Array": [
{ "SalaryAccount",
"salaryAccount": True, "BVN",
"bvn": "12345678901", "BVNAttachedtoAccount",
"crc": False, "CRC",
"crms": True, "CRMS",
"accountStatus": "active", "AccountStatus",
"lien": False, "Lien",
"noBouncedCheck": True, "NoBouncedCheck",
"existingLoan": False, "Whitelist",
"whitelist": True, "NoPastDueSalaryLoan",
"noPastDueSalaryLoan": True, "NoPastDueOtherLoan",
"noPastDueOtherLoans": False ],
}
]
} }
logger.error(f"This is PayLoad: {str(payload)}",exc_info=True) logger.error(f"This is PayLoad: {str(payload)}", exc_info=True)
headers = { headers = {
'Content-Type': 'application/json', "Content-Type": "application/json",
'x-api-key': f'{settings.VALID_API_KEY}', "x-api-key": f"{settings.VALID_API_KEY}",
'App-Id': f'{settings.VALID_APP_ID}' "App-Id": f"{settings.VALID_APP_ID}",
} }
try: try:
response = requests.post(url, json=payload, timeout=10, headers=headers) response = httpx.post(url, json=payload, headers=headers, timeout=10.0)
logger.error(f"This is Response: {str(response)}", exc_info=True)
# Raise an error for non-200 responses
if response.status_code != 200:
response.raise_for_status()
return response.json() logger.error(f"This is Response: {str(response)}", exc_info=True)
except requests.exceptions.RequestException as err:
logger.error(f"RACCheck API call failed: {str(err)}", exc_info=True) return response
return {"error": "RACCheck API error"} 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)}")
+1 -1
View File
@@ -12,5 +12,5 @@ class ProvideLoanSchema(Schema):
# lienAmount = fields.Float(required=True) # lienAmount = fields.Float(required=True)
requestedAmount = fields.Float(required=True) requestedAmount = fields.Float(required=True)
collectionType = fields.Int(required=True) collectionType = fields.Int(required=True)
offerId = fields.Int(required=True) offerId = fields.Str(required=True)
channel = fields.Str(required=True) channel = fields.Str(required=True)
+6 -20
View File
@@ -6,6 +6,7 @@ from marshmallow import ValidationError
from app.api.enums import TransactionType from app.api.enums import TransactionType
from app.api.integrations import SimbrellaIntegration from app.api.integrations import SimbrellaIntegration
from app.extensions import db from app.extensions import db
from app.models import Offer
class EligibilityCheckService(BaseService): class EligibilityCheckService(BaseService):
TRANSACTION_TYPE = TransactionType.ELIGIBILITY_CHECK TRANSACTION_TYPE = TransactionType.ELIGIBILITY_CHECK
@@ -47,35 +48,20 @@ class EligibilityCheckService(BaseService):
"message": "Invalid Customer or Account" "message": "Invalid Customer or Account"
}), 400 }), 400
db.session.flush()
# Call RACCheck # Call RACCheck
response = SimbrellaIntegration.rac_check( response = SimbrellaIntegration.rac_check(
customer_id = customer_id, customer_id = customer_id,
account_id = account_id, account_id = account_id,
transaction_id = transaction.id, transaction_id = transaction.id,
) )
logger.error(f"This is Response Returned ****** : {str(response)}")
# this chck for error is not valid # this chck for error is not valid
logger.error(f"Check for ERROR is not valid ****** FIX THIS !!!!!") if response.status_code != 200:
#if "error" in response or response.get("status") != 200: return jsonify({"message": "RACCheck failed"}), 400
# return jsonify({"message": "RACCheck failed"}), 400
offers = [ offers = [offer.to_dict() for offer in Offer.get_all_offers()]
{
"offerId": "SAL90",
"productId": "2030",
"minAmount": 5000,
"maxAmount": 100000,
"tenor": 30
},
{
"offerId": "SAL30",
"productId": "2090",
"minAmount": 5000,
"maxAmount": 500000,
"tenor": 90
}
]
# Simulate processing # Simulate processing
response_data = { response_data = {
+62 -31
View File
@@ -3,10 +3,12 @@ from marshmallow import ValidationError
from app.api.integrations.kafka import KafkaIntegration from app.api.integrations.kafka import KafkaIntegration
from app.api.services.base_service import BaseService from app.api.services.base_service import BaseService
from app.api.enums import TransactionType from app.api.enums import TransactionType
from app.models.customer import Customer
from app.models.loan_charge import LoanCharge
from app.utils.logger import logger from app.utils.logger import logger
from app.api.schemas.provide_loan import ProvideLoanSchema from app.api.schemas.provide_loan import ProvideLoanSchema
from threading import Thread from threading import Thread
from app.models.loan import Loan from app.models import Loan, Offer
from app.api.enums import LoanStatus from app.api.enums import LoanStatus
from app.extensions import db from app.extensions import db
@@ -33,9 +35,21 @@ class ProvideLoanService(BaseService):
request_id = validated_data.get('requestId') request_id = validated_data.get('requestId')
collection_type = validated_data.get('collectionType') collection_type = validated_data.get('collectionType')
transaction_id = validated_data.get('transactionId') transaction_id = validated_data.get('transactionId')
offer_id = validated_data.get('offerId')
customer = Customer.is_valid_customer(customer_id)
if (ProvideLoanService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): if (ProvideLoanService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
offer = Offer.is_valid_offer(offer_id)
if not offer:
logger.error(f"Invalid Offer")
return jsonify({
"message": "Invalid Offer."
}), 400
# Log Transaction # Log Transaction
transaction = ProvideLoanService.log_transaction(validated_data=validated_data) transaction = ProvideLoanService.log_transaction(validated_data=validated_data)
@@ -50,52 +64,69 @@ class ProvideLoanService(BaseService):
loan = Loan.create_loan( loan = Loan.create_loan(
customer_id = customer_id, customer_id = customer_id,
account_id = account_id, account_id = account_id,
offer_id = validated_data.get('offerId'), offer_id = offer_id,
product_id = offer.product_id,
collection_type = collection_type, collection_type = collection_type,
transaction_id = validated_data.get('transactionId'), transaction_id = validated_data.get('transactionId'),
initial_loan_amount = validated_data.get('requestedAmount'), initial_loan_amount = validated_data.get('requestedAmount'),
status= LoanStatus.ACTIVE status= LoanStatus.ACTIVE
) )
db.session.flush()
if not loan: if not loan:
logger.error(f"Failed to save loan details") logger.error(f"Failed to save loan details")
return jsonify({ return jsonify({
"message": "Failed to save loan details." "message": "Failed to save loan details."
}), 400 }), 400
logger.error(f"********* We need to develop the fee array here") charges = [
loan_def = {
"offers": [
{
"offerId": "SAL90",
"productId": "2030",
"minAmount": 5000,
"maxAmount": 100000,
"tenor": 30
},
{
"offerId": "SAL30",
"productId": "2090",
"minAmount": 3000,
"maxAmount": 500000,
"tenor": 90
}
],
"loan_fee": {
"SAL30": [
{"code": "INTEREST", "percent": 1.1, "due": 0, "description": "This is fee 000"},
{"code": "MGTFEE", "percent": 2.5, "due": 0, "description": "This is fee 001"},
{"code": "INSURANCE", "percent": 3.5, "due": 0, "description": "This is fee 001"},
{"code": "VAT", "percent": 1.0, "due": 0, "description": "This is fee 001"},
],
"SAL90": [
{"code": "INTEREST", "percent": 1.1, "due": 0, "description": "This is fee 9000"}, {"code": "INTEREST", "percent": 1.1, "due": 0, "description": "This is fee 9000"},
{"code": "MGTFEE", "percent": 1.5, "due": 0, "description": "This is fee 90002"}, {"code": "MGTFEE", "percent": 1.5, "due": 0, "description": "This is fee 90002"},
{"code": "INSURANCE", "percent": 1.5, "due": 30, "description": "This is fee 90003"}, {"code": "INSURANCE", "percent": 1.5, "due": 30, "description": "This is fee 90003"},
{"code": "VAT", "percent": 1.5, "due": 60, "description": "This is fee 90004"}, {"code": "VAT", "percent": 1.5, "due": 60, "description": "This is fee 90004"},
] ]
} loan_id = loan.id
}
loan_charges = LoanCharge.create_charges_for_loan(loan_id = loan_id, charges = charges)
# logger.error(f"********* We need to develop the fee array here")
# loan_def = {
# "offers": [
# {
# "offerId": "SAL90",
# "productId": "2030",
# "minAmount": 5000,
# "maxAmount": 100000,
# "tenor": 30
# },
# {
# "offerId": "SAL30",
# "productId": "2090",
# "minAmount": 3000,
# "maxAmount": 500000,
# "tenor": 90
# }
# ],
# "loan_fee": {
# "SAL30": [
# {"code": "INTEREST", "percent": 1.1, "due": 0, "description": "This is fee 000"},
# {"code": "MGTFEE", "percent": 2.5, "due": 0, "description": "This is fee 001"},
# {"code": "INSURANCE", "percent": 3.5, "due": 0, "description": "This is fee 001"},
# {"code": "VAT", "percent": 1.0, "due": 0, "description": "This is fee 001"},
# ],
# "SAL90": [
# {"code": "INTEREST", "percent": 1.1, "due": 0, "description": "This is fee 9000"},
# {"code": "MGTFEE", "percent": 1.5, "due": 0, "description": "This is fee 90002"},
# {"code": "INSURANCE", "percent": 1.5, "due": 30, "description": "This is fee 90003"},
# {"code": "VAT", "percent": 1.5, "due": 60, "description": "This is fee 90004"},
# ]
# }
# }
# Log Transaction # Log Transaction
@@ -118,7 +149,7 @@ class ProvideLoanService(BaseService):
"transactionId": transaction_id, "transactionId": transaction_id,
"customerId": customer_id, "customerId": customer_id,
"accountId": account_id, "accountId": account_id,
"msisdn": "3451342", "msisdn": customer.msisdn,
"resultCode": "00", "resultCode": "00",
"resultDescription": "Successful" "resultDescription": "Successful"
} }
+1 -1
View File
@@ -44,7 +44,7 @@ class SelectOfferService(BaseService):
offers = [ offers = [
{ {
"offerId": "14451", "offerId": "SAL90",
"productId": "2030", "productId": "2030",
"amount": 10000.0, "amount": 10000.0,
"upfrontPayment": 1000.0, "upfrontPayment": 1000.0,
+4 -1
View File
@@ -3,5 +3,8 @@ from .account import Account
from .loan import Loan from .loan import Loan
from .transaction import Transaction from .transaction import Transaction
from .repayment import Repayment from .repayment import Repayment
from .loan_charge import LoanCharge
from .offer import Offer
__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment']
__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer']
+1 -1
View File
@@ -42,7 +42,7 @@ class Account(db.Model):
return False return False
if account.lien_amount > 0: if account.lien_amount > 0:
return False return False
return True return account
def __repr__(self): def __repr__(self):
return f'<Account {self.id}>' return f'<Account {self.id}>'
+1 -1
View File
@@ -32,7 +32,7 @@ class Customer(db.Model):
customer = cls.query.filter_by(id=customer_id).first() customer = cls.query.filter_by(id=customer_id).first()
if not customer: if not customer:
return False return False
return True return customer
@classmethod @classmethod
def create_customer(cls, id, msisdn, country_code, account_id, account_type='savings'): def create_customer(cls, id, msisdn, country_code, account_id, account_type='savings'):
+14 -5
View File
@@ -4,7 +4,7 @@ from app.models.customer import Customer
from app.models.account import Account from app.models.account import Account
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.models import Customer from app.models.loan_charge import LoanCharge
class Loan(db.Model): class Loan(db.Model):
@@ -19,6 +19,7 @@ class Loan(db.Model):
transaction_id = db.Column(db.String(50), nullable=True) transaction_id = db.Column(db.String(50), nullable=True)
account_id = db.Column(db.String(50), nullable=False) account_id = db.Column(db.String(50), nullable=False)
offer_id = db.Column(db.String(20), nullable=False) offer_id = db.Column(db.String(20), nullable=False)
product_id = db.Column(db.String(20), nullable=True)
collection_type = db.Column(db.String(20), nullable=True) collection_type = db.Column(db.String(20), nullable=True)
current_loan_amount = db.Column(db.Float, nullable=True) current_loan_amount = db.Column(db.Float, nullable=True)
initial_loan_amount = db.Column(db.Float, nullable=False) initial_loan_amount = db.Column(db.Float, nullable=False)
@@ -36,12 +37,19 @@ class Loan(db.Model):
back_populates="loans", back_populates="loans",
) )
loan_charges = relationship(
"LoanCharge",
primaryjoin="Loan.id == LoanCharge.loan_id",
foreign_keys="LoanCharge.loan_id",
back_populates="loan",
)
@classmethod @classmethod
def create_loan(cls, customer_id, account_id, offer_id, initial_loan_amount, collection_type, transaction_id, status='pending'): def create_loan(cls, customer_id, account_id, offer_id, product_id, initial_loan_amount, collection_type, transaction_id, status='pending'):
# Check if customer exists # Check if customer exists
is_valid = Customer.is_valid_customer(customer_id) customer = Customer.is_valid_customer(customer_id)
if not is_valid: if not customer:
raise ValueError("Customer does not exist") raise ValueError("Customer does not exist")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -51,6 +59,7 @@ class Loan(db.Model):
customer_id = customer_id, customer_id = customer_id,
account_id = account_id, account_id = account_id,
offer_id = offer_id, offer_id = offer_id,
product_id = product_id,
collection_type = collection_type, collection_type = collection_type,
transaction_id = transaction_id, transaction_id = transaction_id,
initial_loan_amount = initial_loan_amount, initial_loan_amount = initial_loan_amount,
@@ -58,7 +67,7 @@ class Loan(db.Model):
due_date=now, due_date=now,
status = status status = status
) )
try: try:
db.session.add(loan) db.session.add(loan)
except IntegrityError as err: except IntegrityError as err:
+72
View File
@@ -0,0 +1,72 @@
from datetime import datetime, timezone
from app.extensions import db
from sqlalchemy.orm import relationship
class LoanCharge(db.Model):
__tablename__ = 'loan_charges'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
loan_id = db.Column(db.Integer, nullable=False)
code = db.Column(db.String(50), nullable=False)
amount = db.Column(db.Float, default=0.0)
percent = db.Column(db.Float, default=0.0)
description = db.Column(db.Text, nullable=True)
due = db.Column(db.Integer, nullable=False)
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))
loan = relationship(
"Loan",
primaryjoin="LoanCharge.loan_id == Loan.id",
foreign_keys=[loan_id],
back_populates="loan_charges",
)
@classmethod
def create_charges_for_loan(cls, loan_id, charges):
"""
Create loan charges for a given loan.
Args:
loan_id (int): ID of the loan to associate charges with.
charges (list): A list of dictionaries with keys:
code (str), amount (float), percent (float), description (str), due (int)
"""
if not charges or not isinstance(charges, list):
raise ValueError("Charges must be a non-empty list of dictionaries")
if loan_id is None:
raise ValueError("loan_id cannot be None")
loan_charges = []
for charge in charges:
charge_obj = cls(
loan_id=loan_id,
code=charge.get("code"),
amount=charge.get("amount", 0.0),
percent=charge.get("percent", 0.0),
description=charge.get("description", ""),
due=charge.get("due", 0)
)
db.session.add(charge_obj)
loan_charges.append(charge_obj)
return loan_charges
def to_dict(self):
return {
'id': self.id,
'loanId': self.loan_id,
'transactionId': self.transaction_id,
'code': self.code,
'amount': self.amount,
'percent': self.percent,
'description': self.description,
'due': self.due,
}
def __repr__(self):
return f"<LoanCharge {self.id} - Loan {self.loan_id} - {self.code}>"
+30 -1
View File
@@ -4,7 +4,7 @@ from app.extensions import db
class Offer(db.Model): class Offer(db.Model):
__tablename__ = 'offers' __tablename__ = 'offers'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.String, primary_key=True)
product_id = db.Column(db.String, nullable=False) product_id = db.Column(db.String, nullable=False)
min_amount = db.Column(db.Float, nullable=False) min_amount = db.Column(db.Float, nullable=False)
max_amount = db.Column(db.Float, nullable=False) max_amount = db.Column(db.Float, nullable=False)
@@ -12,5 +12,34 @@ class Offer(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 get_all_offers(cls):
"""
Return all offers in dictionary format.
"""
offers = cls.query.all()
if not offers:
raise ValueError(f"No available offers")
return offers
@classmethod
def is_valid_offer(cls, offer_id):
offer = cls.query.filter_by(id=str(offer_id)).first()
if not offer:
return False
return offer
def to_dict(self):
return {
"offerId": self.id,
"productId": self.product_id,
"minAmount": self.min_amount,
"maxAmount": self.max_amount,
"tenor": self.tenor
}
def __repr__(self): def __repr__(self):
return f'<LoanOffer {self.id}>' return f'<LoanOffer {self.id}>'
@@ -0,0 +1,32 @@
"""Migration on Wed Apr 16 18:35:18 UTC 2025
Revision ID: 287ecb02d3d7
Revises: a4847b997191
Create Date: 2025-04-16 18:36:04.632791
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '287ecb02d3d7'
down_revision = 'a4847b997191'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loan_charges', schema=None) as batch_op:
batch_op.drop_column('transaction_id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loan_charges', schema=None) as batch_op:
batch_op.add_column(sa.Column('transaction_id', sa.VARCHAR(length=50), autoincrement=False, nullable=True))
# ### end Alembic commands ###
@@ -0,0 +1,57 @@
"""Migration on Wed Apr 16 17:42:49 UTC 2025
Revision ID: a4847b997191
Revises: 783a023a477f
Create Date: 2025-04-16 17:43:22.509659
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a4847b997191'
down_revision = '783a023a477f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('loan_charges',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('loan_id', sa.Integer(), nullable=False),
sa.Column('transaction_id', sa.String(length=50), nullable=True),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('amount', sa.Float(), nullable=True),
sa.Column('percent', sa.Float(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('due', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('offers',
sa.Column('id', sa.String(), nullable=False),
sa.Column('product_id', sa.String(), nullable=False),
sa.Column('min_amount', sa.Float(), nullable=False),
sa.Column('max_amount', sa.Float(), nullable=False),
sa.Column('tenor', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.add_column(sa.Column('product_id', sa.String(length=20), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.drop_column('product_id')
op.drop_table('offers')
op.drop_table('loan_charges')
# ### end Alembic commands ###
+2 -1
View File
@@ -25,7 +25,8 @@ flask-swagger-ui
python-dotenv python-dotenv
# Requests # Requests
requests httpx
# JWT # JWT
flask-jwt-extended flask-jwt-extended
+3 -3
View File
@@ -1,8 +1,8 @@
#!/bin/sh #!/bin/sh
echo "Running DB migrations..." # echo "Running DB migrations..."
flask db migrate -m "Migration on $(date)" # flask db migrate -m "Migration on $(date)"
flask db upgrade # flask db upgrade
echo "Starting Gunicorn server..." echo "Starting Gunicorn server..."
exec gunicorn -w 4 -b 0.0.0.0:5000 wsgi:wsgi_app exec gunicorn -w 4 -b 0.0.0.0:5000 wsgi:wsgi_app