Compare commits

...

19 Commits

Author SHA1 Message Date
VivianDee eb7f783b18 [add]: Salary table 2025-06-18 12:34:28 +01:00
ameye 5040002c54 Merge branch 'update_repayments_data_table' of DigiFi/digifi-BankToProductCore into master 2025-06-17 15:11:06 +00:00
VivianDee dc9415ff79 [fix]: Select offer requested amount 2025-06-16 15:40:50 +01:00
ameye 4822de764a Merge branch 'update_repayments_data_table' of DigiFi/digifi-BankToProductCore into master 2025-06-16 14:34:40 +00:00
VivianDee 51995a3e02 [update]: Repayments table 2025-06-16 12:27:04 +01:00
ameye 265bba2365 Merge branch 'loan_count_fix' of DigiFi/digifi-BankToProductCore into master 2025-06-13 18:53:52 +00:00
VivianDee 9985a58b56 [fix]: Max daily loan count 2025-06-13 19:11:10 +01:00
ameye b41df3fe02 Merge branch 'rac_check_analysis' of DigiFi/digifi-BankToProductCore into master 2025-06-13 13:01:45 +00:00
VivianDee 08fe04b7b9 Update offer_analysis.py 2025-06-13 13:57:10 +01:00
vivian.d.simbrellang.com 79317632b6 Merge branch 'rac_check_analysis' of DigiFi/digifi-BankToProductCore into master 2025-06-13 12:33:10 +00:00
VivianDee 0d87036b92 Update config.py 2025-06-13 13:32:03 +01:00
ameye 1734007476 Merge branch 'rac_check_analysis' of DigiFi/digifi-BankToProductCore into master 2025-06-12 22:46:20 +00:00
VivianDee 6ef2be9625 [add]: Offer analysis update 2025-06-12 15:24:14 +01:00
VivianDee 48020f5284 Update eligibility_check.py 2025-06-12 14:35:32 +01:00
VivianDee 1a6ac6a37f Update offer_analysis.py 2025-06-12 14:00:15 +01:00
ameye bbf6953dc5 Merge branch 'rac_check_analysis' of DigiFi/digifi-BankToProductCore into master 2025-06-12 11:44:46 +00:00
VivianDee a2158a768e [update]: Rac Check analysis 2025-06-11 15:19:24 +01:00
CHIEFSOFT\ameye 0af1b7567b Rack Rules 2025-06-10 06:58:53 -04:00
vivian.d.simbrellang.com 4d08983ae3 Merge branch 'repayments_data_model' of DigiFi/digifi-BankToProductCore into master 2025-06-10 07:44:44 +00:00
14 changed files with 334 additions and 55 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ class SimbrellaIntegration:
"customerId": customer_id,
"accountId": account_id,
"transactionId": str(transaction_id),
"fbnTransactionId": f"FBN{transaction_id}",
"fbnTransactionId": str(transaction_id),
"countryCode": "NG",
"channel": "USSD"
}
+3 -4
View File
@@ -1,6 +1,5 @@
from flask import session, jsonify
from app.models.loan import Loan
from app.models.transaction_offers import TransactionOffer
from app.utils.logger import logger
from app.api.services.base_service import BaseService
from app.api.schemas.eligibility_check import EligibilityCheckSchema
@@ -81,7 +80,7 @@ class EligibilityCheckService(BaseService):
return ResponseHelper.error(result_description=f"RACCheck failed")
rack_checks_response = response['racResponse']
rack_checks_response = response['data']['racResponse']
rac_check = RACCheck.add_rac_check(
customer_id = customer_id,
@@ -187,10 +186,10 @@ class EligibilityCheckService(BaseService):
logger.error(f"Offer not found for offer_id: {offer_id} (customer_id: {customer_id})")
return False
daily_count = TransactionOffer.get_daily_loan_count(customer_id, offer_id)
daily_count = Loan.get_daily_loan_count(customer_id, offer.product_id)
logger.error(f"daily_count: {daily_count}, Max: {offer.max_daily_loans}")
logger.info(f"daily_count: {daily_count}, Max: {offer.max_daily_loans}")
if offer.max_daily_loans is not None and daily_count >= offer.max_daily_loans:
return False
+94 -9
View File
@@ -1,3 +1,4 @@
from decimal import Decimal
from app.models import Offer, TransactionOffer
from app.models.loan import Loan
import random
@@ -5,7 +6,10 @@ import logging
from app.config import Config
RAC_CHECK_RULES = Config.rac_true_rules
RAC_TRUE_CHECK_RULES = Config.rac_true_rules
RAC_FALSE_CHECK_RULES = Config.rac_false_rules
RAC_SALARY_PAYMENTS = Config.rac_salary_payments
logger = logging.getLogger(__name__)
class OfferAnalysis:
@@ -37,9 +41,86 @@ class OfferAnalysis:
return transaction_offer, offer, eligible_amount, original_transaction
@staticmethod
def _analyze_rack_checks(rack_response):
def _analyze_rack_checks(rack_response, offer):
logger.info(f"This is PayLoad for ANALYSYS ***** : {str(rack_response)}", exc_info=True)
logger.info(f"RACk RUKES {str(RAC_CHECK_RULES)}", exc_info=True)
logger.info(f"RACk TRUE RULES {str(RAC_TRUE_CHECK_RULES)}", exc_info=True)
logger.info(f"RACk FALSE RULES {str(RAC_FALSE_CHECK_RULES)}", exc_info=True)
logger.info(f"RACk SALARY PAYMENTS {str(RAC_SALARY_PAYMENTS)}", exc_info=True)
if not isinstance(rack_response, dict) or not offer :
raise ValueError("Invalid RAC response format.")
failed_true_rules = []
failed_false_rules = []
salaries = []
# Expects true
for rule in RAC_TRUE_CHECK_RULES:
if not rack_response.get(rule, False):
failed_true_rules.append(rule)
# Expects false
for rule in RAC_FALSE_CHECK_RULES:
if rack_response.get(rule, True):
failed_false_rules.append(rule)
# Salary rules
for key in RAC_SALARY_PAYMENTS:
value = rack_response.get(key)
if isinstance(value, Decimal):
# Only use values greater than 0
if value > 0:
salaries.append(value)
elif isinstance(value, (int, float, str)):
try:
value = Decimal(str(value))
if value > 0:
salaries.append(value)
except:
logger.warning(f"Could not convert value of {key} to Decimal: {value}")
if failed_true_rules or failed_false_rules or not salaries:
logger.warning(f"Failed TRUE rules: {failed_true_rules}")
logger.warning(f"Failed FALSE rules: {failed_false_rules}")
logger.warning("No salary records found in RAC response.")
raise ValueError(f"RAC analysis failed")
logger.info(f"These are the salary amounts ***** : {str(salaries)}", exc_info=True)
#Least salary in the last 6 months
min_salary = min(salaries)
# Check consistency rule
consistent_income = rack_response.get("rule7_consistent_salary_amount", False)
# Determine percentage based on offer tenor
tenor = offer.tenor
if tenor == 30 and consistent_income:
eligible_amount = min_salary * Decimal("0.5")
logger.info("Applying 50% of least salary in 6 months due to 1-month offer tenor with stable income.")
elif tenor == 90 and consistent_income:
eligible_amount = min_salary * Decimal("0.75")
logger.info("Applying 75% of least salary in 6 months due to 3-months offer tenor with stable income.")
else: # Income is not consistent
eligible_amount = 0
logger.info("Applying no percentage on least salary due unstable income.")
logger.info(f"Calculated eligible amount from RAC: {eligible_amount} based on {'stable' if consistent_income else 'unstable'} income.")
return eligible_amount.quantize(Decimal("1.00"))
# "racResponse": {
# "accountStatus": true,
# "bvnValidated": true,
@@ -72,15 +153,13 @@ class OfferAnalysis:
# if we have active offers - we have to feed off it
logger.info(f"**RACK ANALYSIS** {customer_id}")
# Analyze Rack Checks
OfferAnalysis._analyze_rack_checks(rack_checks_response) #--> We need detail analysis
# new_eligible_amount = OfferAnalysis._analyze_rack_checks(rack_checks_response) #--> We need detail analysis
# we can now find the origin transactions
# Find the last loan - it will have original_transaction
last_customer_loan = Loan.get_customer_last_loan(customer_id)
# logger.info(f"{last_customer_loan}")
new_eligible_amount = 0
if last_customer_loan:
original_transaction = last_customer_loan.original_transaction or last_customer_loan.transaction_id
logger.info(f"transaction_id |-| original_transaction === > {transaction_id} {original_transaction}")
@@ -132,12 +211,18 @@ class OfferAnalysis:
for offer in offers:
# Get approved amount
random_float = random.random() # temporary to play data
new_eligible_amount = OfferAnalysis._analyze_rack_checks(rack_checks_response, offer)
approved_amount = new_eligible_amount if new_eligible_amount > 0 else min(offer.max_amount, offer.max_amount * random_float)
approved_amount = new_eligible_amount
approved_amount = round(approved_amount, 2)
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.")
transaction_offer = TransactionOffer.create_transaction_offer(
customer_id=customer_id,
transaction_id=transaction_id,
+12 -3
View File
@@ -3,6 +3,7 @@ from marshmallow import ValidationError
from app.api.helpers.response_helper import ResponseHelper
from app.api.services.base_service import BaseService
from app.api.enums import TransactionType
from app.models.transaction_offers import TransactionOffer
from app.utils.logger import logger
from app.api.schemas.select_offer import SelectOfferSchema
from app.extensions import db
@@ -57,12 +58,20 @@ class SelectOfferService(BaseService):
# Get the offer by product ID
offer = Offer.get_offer_by_product_id(product_id)
transaction_offer = TransactionOffer.get_transaction_offer(transaction_offer_id=offer_id)
if not transaction_offer:
logger.error(f"offer {offer_id} not found for customer {customer_id} and transaction {transaction_id}.")
return ResponseHelper.error(result_description="Offer not found.")
db.session.flush()
if amount < offer.min_amount:
if amount < transaction_offer.min_amount:
logger.error(f"The amount {amount} is less than the minimum allowed offer amount {transaction_offer.min_amount}.")
return ResponseHelper.error(result_description="The amount is less than the minimum allowed offer amount.")
elif amount > offer.max_amount:
return ResponseHelper.error(result_description="The amount is greater than the maximum allowed offer amount.")
elif amount > transaction_offer.eligible_amount:
logger.error(f"The amount {amount} is greater than the eligible offer amount {transaction_offer.eligible_amount}.")
return ResponseHelper.error(result_description="The amount is greater than the eligible offer amount.")
+29 -17
View File
@@ -34,7 +34,7 @@ class Config:
# SIMBRELLA_ENDPOINT_RAC_CHECKS = os.getenv("SIMBRELLA_ENDPOINT_RAC_CHECKS", "RACCheck")
VALID_APP_ID = os.getenv("SIMBRELLA_APP_ID", "app1")
VALID_API_KEY = os.getenv("SIMBRELLA_API_KEY", "testtest-api-key-12345")
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")
@@ -49,25 +49,37 @@ class Config:
RAC_RESULT_noBouncedCheck = os.environ.get("RAC_RESULT_noBouncedCheck", "true")
rac_true_rules = [
"rule1-45day-sal",
"rule2-2m-sal",
"rule3-no-bounced-check",
"rule4-current-loan-payments",
"rule5-no-past-due-fadv-loan",
"rule6--no-past-due-other-loan",
"rule7-consistent-salary-amount",
"rule8-whitelisted",
"rule9-regular-account",
"rule10-bvn-validation",
"rule11-CRC-no-delinquency",
"rule12-CRMS-no-delinquency",
"rule13-BVN-ignore",
"rule14-no-lien",
"rule15-null-ignore"
"rule1_45day_sal",
"rule2_2m_sal",
"rule3_no_bounced_check",
"rule4_current_loan_payments",
"rule5_no_past_due_fadv_loan",
"rule6_no_past_due_other_loan",
"rule7_consistent_salary_amount",
"rule8_whitelisted",
"rule9_regular_account",
"rule10_bvn_validation",
"rule11_CRC_no_delinquency",
"rule12_CRMS_no_delinquency",
"rule13_BVN_ignore",
"rule14_no_lien",
"rule15_null_ignore"
]
rac_false_rules = [
]
rac_salary_payments = [
"salarypaymenT_1",
"salarypaymenT_2",
"salarypaymenT_3",
"salarypaymenT_4",
"salarypaymenT_5",
"salarypaymenT_6"
]
settings = Config()
+2 -1
View File
@@ -10,6 +10,7 @@ from .rac_checks import RACCheck
from .loan_repayment_schedule import LoanRepaymentSchedule
from .transaction_offers import TransactionOffer
from .repayments_data import RepaymentsData
from .salary import Salary
__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer', 'Charge', 'RACCheck', 'LoanRepaymentSchedule', 'TransactionOffer', 'RepaymentsData']
__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer', 'Charge', 'RACCheck', 'LoanRepaymentSchedule', 'TransactionOffer', 'RepaymentsData', 'Salary']
+20 -1
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from itertools import product
from app.extensions import db
from app.models.customer import Customer
@@ -215,6 +215,25 @@ class Loan(db.Model):
# Update loan status and the updated_at timestamp
loan.status = status
@classmethod
def get_daily_loan_count(cls, customer_id, product_id):
"""
Returns the count of loans created today for a customer.
"""
start_of_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
end_of_day = start_of_day + timedelta(days=1)
return cls.query.filter_by(
customer_id=customer_id,
product_id=product_id,
).filter(
cls.created_at >= start_of_day,
cls.created_at < end_of_day
).count()
def to_dict(self):
"""
Convert the Loan object to a dictionary format for JSON serialization.
+21
View File
@@ -27,6 +27,8 @@ class Repayment(db.Model):
verify_date = db.Column(db.DateTime, default=datetime.now(timezone.utc))
verify_result = db.Column(db.String(10), nullable=True)
verify_description = db.Column(db.String(100), nullable=True)
initiated_by = db.Column(db.String(50), nullable=True)
salary_amount = db.Column(db.Float, nullable=True, default=0.0)
@classmethod
def create_repayment(cls, customer_id, loan, transaction_id):
@@ -51,6 +53,25 @@ class Repayment(db.Model):
raise ValueError(f"Database integrity error: {err}")
return repayment
def to_dict(self):
return {
"id": self.id,
"loan_id": self.loan_id,
"customer_id": self.customer_id,
"product_id": self.product_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"transaction_id": self.transaction_id,
"repay_date": self.repay_date.isoformat() if self.repay_date else None,
"repay_result": self.repay_result,
"repay_description": self.repay_description,
"verify_date": self.verify_date.isoformat() if self.verify_date else None,
"verify_result": self.verify_result,
"verify_description": self.verify_description,
"initiated_by": self.initiated_by,
"salary_amount": self.salary_amount
}
def __repr__(self):
return f'<Repayment {self.id}>'
+14 -2
View File
@@ -1,24 +1,36 @@
from datetime import datetime, timezone
from app.extensions import db
class RepaymentsData(db.Model):
__tablename__ = 'repayments_data'
__tablename__ = "repayments_data"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
transaction_id = db.Column(db.String(50), nullable=False)
fbn_transaction_id = db.Column(db.String(50), nullable=True)
customer_id = db.Column(db.String(50), nullable=True)
account_id = db.Column(db.String(50), nullable=True)
repayment_amount = db.Column(db.Float, nullable=True, default=0.0)
amount_collected = db.Column(db.Float, nullable=True, default=0.0)
added_date = db.Column(db.DateTime(timezone=True), default=datetime.now(timezone.utc), nullable=False)
response_code = db.Column(db.String(10), nullable=True)
response_descr = db.Column(db.String(255), nullable=True)
balance = db.Column(db.Float, nullable=True, default=0.0)
def to_dict(self):
return {
"id": self.id,
"transaction_id": self.transaction_id,
"fbn_transaction_id": self.fbn_transaction_id,
"customer_id": self.customer_id,
"account_id": self.account_id,
"repayment_amount": self.repayment_amount,
"amount_collected": self.amount_collected,
"added_date": self.added_date.isoformat() if self.added_date else None,
"response_code": self.response_code,
"response_descr": self.response_descr,
"balance": self.balance,
}
def __repr__(self):
return f"<RepaymentsData id={self.id}, transaction_id={self.transaction_id}>"
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime, timezone
from app.extensions import db
from sqlalchemy.sql import func
class Salary(db.Model):
__tablename__ = 'salaries'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
customer_id = db.Column(db.String(50), nullable=False)
account_type = db.Column(db.String(50))
status = db.Column(db.String(20), default='active')
amount = db.Column(db.Float, nullable=False, default=0.0)
salary_date = db.Column(db.DateTime(timezone=False), server_default=func.now())
created_at = db.Column(db.DateTime(timezone=False), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=False), server_default=func.now(), onupdate=func.now())
def to_dict(self):
return {
"id": self.id,
"customer_id": self.customer_id,
"account_type": self.account_type,
"status": self.status,
"amount": self.amount,
"salary_date": self.salary_date.isoformat() if self.salary_date 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
}
def __repr__(self):
return f'<Salary {self.id} - {self.amount}>'
+2
View File
@@ -17,8 +17,10 @@ class Transaction(db.Model):
customer_id = db.Column(db.String(50), nullable=True)
type = db.Column(db.String(50), nullable=False)
channel = db.Column(db.String(50), nullable=False)
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}>'
+7 -17
View File
@@ -76,23 +76,6 @@ class TransactionOffer(db.Model):
"""
return cls.query.filter_by(customer_id=customer_id).count()
@classmethod
def get_daily_loan_count(cls, customer_id, offer_id):
"""
Returns the count of loans created today for a customer.
"""
start_of_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
end_of_day = start_of_day + timedelta(days=1)
return cls.query.filter_by(
customer_id=customer_id,
offer_id=offer_id
).filter(
cls.created_at >= start_of_day,
cls.created_at < end_of_day
).count()
@classmethod
def get_latest_transaction_offer(cls, customer_id):
@@ -102,6 +85,13 @@ class TransactionOffer(db.Model):
return cls.query.filter_by(customer_id=customer_id) \
.order_by(cls.created_at.desc()) \
.first()
@classmethod
def get_transaction_offer(cls, transaction_offer_id):
"""
Returns a transaction offer by its ID.
"""
return cls.query.get(transaction_offer_id)
+52
View File
@@ -0,0 +1,52 @@
"""empty message
Revision ID: 1696ee63c28a
Revises: b54422fb31e0
Create Date: 2025-06-18 12:28:23.942143
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1696ee63c28a'
down_revision = 'b54422fb31e0'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('salaries',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('customer_id', sa.String(length=50), nullable=False),
sa.Column('account_type', sa.String(length=50), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('salary_date', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('repayments', schema=None) as batch_op:
batch_op.add_column(sa.Column('initiated_by', sa.String(length=50), nullable=True))
batch_op.add_column(sa.Column('salary_amount', sa.Float(), nullable=True))
with op.batch_alter_table('repayments_data', schema=None) as batch_op:
batch_op.add_column(sa.Column('balance', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('repayments_data', schema=None) as batch_op:
batch_op.drop_column('balance')
with op.batch_alter_table('repayments', schema=None) as batch_op:
batch_op.drop_column('salary_amount')
batch_op.drop_column('initiated_by')
op.drop_table('salaries')
# ### end Alembic commands ###
+46
View File
@@ -0,0 +1,46 @@
"""empty message
Revision ID: b54422fb31e0
Revises: 0acd553309a1
Create Date: 2025-06-16 12:24:09.159498
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b54422fb31e0'
down_revision = '0acd553309a1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('repayments_data', schema=None) as batch_op:
batch_op.add_column(sa.Column('fbn_transaction_id', sa.String(length=50), nullable=True))
batch_op.add_column(sa.Column('customer_id', sa.String(length=50), nullable=True))
batch_op.add_column(sa.Column('account_id', sa.String(length=50), nullable=True))
batch_op.add_column(sa.Column('repayment_amount', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('amount_collected', sa.Float(), nullable=True))
with op.batch_alter_table('transactions', schema=None) as batch_op:
batch_op.add_column(sa.Column('phone_number', sa.String(length=50), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('transactions', schema=None) as batch_op:
batch_op.drop_column('phone_number')
with op.batch_alter_table('repayments_data', schema=None) as batch_op:
batch_op.drop_column('amount_collected')
batch_op.drop_column('repayment_amount')
batch_op.drop_column('account_id')
batch_op.drop_column('customer_id')
batch_op.drop_column('fbn_transaction_id')
# ### end Alembic commands ###