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

This commit is contained in:
2025-04-25 11:33:54 +00:00
committed by Gogs
9 changed files with 248 additions and 69 deletions
+88
View File
@@ -60,3 +60,91 @@ class BaseService:
def async_send_to_kafka(cls, loan_data, request_id, topic): def async_send_to_kafka(cls, loan_data, request_id, topic):
KafkaIntegration.send_loan_request(loan_data = loan_data, request_id = request_id, topic = topic) KafkaIntegration.send_loan_request(loan_data = loan_data, request_id = request_id, topic = topic)
KafkaIntegration.flush() KafkaIntegration.flush()
@classmethod
def calculate_charges(cls, offer, amount):
"""
Calculates and returns the charges for the given offer and amount.
Args:
offer (Offer): The offer object that contains the charges.
amount (float): The requested loan amount.
Returns:
dict: A dictionary containing the calculated charges.
"""
if not offer or not offer.charges:
logger.error(f"No charges found for offer ID {offer.id}")
return {"error": "No charges found for the offer"}
loan_charges = offer.charges
tenor = offer.tenor // 30 # Convert to months
interest = cls.get_charge_detail(charges = loan_charges, code = "INTEREST", amount = amount)
management = cls.get_charge_detail(charges = loan_charges, code = "MGTFEE", amount = amount)
insurance = cls.get_charge_detail(charges = loan_charges, code = "INSURANCE", amount = amount)
vat = cls.get_charge_detail(charges = loan_charges, code = "VAT", amount = amount, management_fee = management["fee"])
# Separate fees into upfront and postpaid
upfront_fees = [
fee["fee"]
for fee in [interest, management, insurance, vat]
if fee["due_days"] == 0
]
postpaid_fees = [
fee["fee"]
for fee in [interest, management, insurance, vat]
if fee["due_days"] != 0
]
# Up-front payment: (only those fees due immediately i.e due_days == 0)
upfront_payment = sum(upfront_fees)
# Repayment amount: (principal + only those fees not due immediately i.e due_days != 0)
repayment_amount = amount + (sum(postpaid_fees) * tenor)
# Total amount: (upfront_payment + repayment_amount)
total_amount = upfront_payment + repayment_amount
# Calculate the installment amount
installment_amount = repayment_amount / tenor
return {
"interest": interest,
"management": management,
"insurance": insurance,
"vat": vat,
"upfront_payment": round(upfront_payment, 2),
"repayment_amount": round(repayment_amount, 2),
"installment_amount": round(installment_amount, 2),
"total_amount": round(total_amount, 2)
}
@classmethod
def get_charge_detail(cls, charges, code, amount, management_fee=None):
"""
Get details for a specific charge code from a list of loan charges.
Returns default values if not found.
"""
for charge in charges:
if charge.code == code:
fee = (
management_fee * charge.percent / 100
if code == "VAT" and management_fee is not None
else amount * charge.percent / 100
)
return {
"rate": charge.percent,
"fee": round(fee, 2),
"due_days": charge.due
}
return {"rate": 0, "fee": 0, "due_days": 0}
+15 -2
View File
@@ -36,6 +36,8 @@ class ProvideLoanService(BaseService):
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') offer_id = validated_data.get('offerId')
amount = validated_data.get("requestedAmount")
product_id = validated_data.get("productId")
customer = Customer.is_valid_customer(customer_id) customer = Customer.is_valid_customer(customer_id)
@@ -48,8 +50,7 @@ class ProvideLoanService(BaseService):
return jsonify({ return jsonify({
"message": "Invalid Offer." "message": "Invalid Offer."
}), 400 }), 400
# Log Transaction # Log Transaction
transaction = ProvideLoanService.log_transaction(validated_data=validated_data) transaction = ProvideLoanService.log_transaction(validated_data=validated_data)
@@ -58,6 +59,15 @@ class ProvideLoanService(BaseService):
return jsonify({ return jsonify({
"message": "Failed to log transaction." "message": "Failed to log transaction."
}), 400 }), 400
db.session.flush()
charges = ProvideLoanService.calculate_charges(offer, amount)
upfront_fee = charges["upfront_payment"]
repayment_amount = charges["repayment_amount"]
installment_amount = charges["installment_amount"]
# Save the loan details # Save the loan details
@@ -69,6 +79,9 @@ class ProvideLoanService(BaseService):
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'),
upfront_fee = upfront_fee,
repayment_amount = repayment_amount,
installment_amount = installment_amount,
status= LoanStatus.ACTIVE status= LoanStatus.ACTIVE
) )
+9 -54
View File
@@ -51,38 +51,16 @@ class SelectOfferService(BaseService):
# Get the offer by product ID # Get the offer by product ID
offer = Offer.get_offer_by_product_id(product_id) offer = Offer.get_offer_by_product_id(product_id)
if not offer:
logger.error(f"Offer with product ID {product_id} not found")
return jsonify({"message": "Offer not found"}), 404
# Get the loan charges for the offer
loan_charges = offer.charges
if not loan_charges:
logger.error(f"No charges found for offer ID {offer.id}")
return jsonify({"message": "No charges found for the offer"}), 404
logger.error(f"{loan_charges}")
db.session.flush() db.session.flush()
interest = SelectOfferService.get_charge_detail(loan_charges, "INTEREST", amount)
management = SelectOfferService.get_charge_detail(loan_charges, "MGTFEE", amount)
insurance = SelectOfferService.get_charge_detail(loan_charges, "INSURANCE", amount)
vat = SelectOfferService.get_charge_detail(loan_charges, "VAT", amount)
charges = SelectOfferService.calculate_charges(offer, amount)
# Up-front payment: (principal + only those fees due immediately i.e due_days == 0) upfront_payment = charges["upfront_payment"]
upfront_payment = amount + sum( total_amount = charges["total_amount"]
amount * charge.percent / 100 installment_amount = charges["installment_amount"]
for charge in loan_charges interest = charges["interest"]
if charge.due == 0 management = charges["management"]
) insurance = charges["insurance"]
vat = charges["vat"]
# Total amount (principal + all fees)
total_amount = amount + sum(
amount * charge.percent / 100
for charge in loan_charges
)
# Calculate the repayment dates # Calculate the repayment dates
@@ -97,8 +75,6 @@ class SelectOfferService(BaseService):
for i in range(months) for i in range(months)
] ]
# Calculate the installment amount
installment_amount = total_amount / tenor
offers = [ offers = [
@@ -150,25 +126,4 @@ class SelectOfferService(BaseService):
logger.error(f"An error occurred: {str(e)}", exc_info=True) logger.error(f"An error occurred: {str(e)}", exc_info=True)
db.session.rollback() db.session.rollback()
return jsonify({"message": "Internal Server Error"}), 500 return jsonify({"message": "Internal Server Error"}), 500
@staticmethod
def get_charge_detail(charges, code, amount):
"""
Get details for a specific charge code from a list of loan charges.
Returns default values if not found.
"""
for charge in charges:
if charge.code == code:
return {
"rate": charge.percent,
"fee": amount * charge.percent / 100,
"due_days": charge.due,
}
return {"rate": 0, "fee": 0, "due_days": 0}
+2 -1
View File
@@ -6,6 +6,7 @@ from .repayment import Repayment
from .loan_charge import LoanCharge from .loan_charge import LoanCharge
from .offer import Offer from .offer import Offer
from .charge import Charge from .charge import Charge
from .rac_checks import RACCheck
__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer', 'Charge'] __all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer', 'Charge', 'RACCheck']
+20 -2
View File
@@ -25,6 +25,9 @@ class Loan(db.Model):
initial_loan_amount = db.Column(db.Float, nullable=False) initial_loan_amount = db.Column(db.Float, nullable=False)
default_penalty_fee = db.Column(db.Float, default=0) default_penalty_fee = db.Column(db.Float, default=0)
continuous_fee = db.Column(db.Float, default=0) continuous_fee = db.Column(db.Float, default=0)
upfront_fee = db.Column(db.Float, nullable=True, default=0.0)
repayment_amount = db.Column(db.Float, nullable=True, default=0.0)
installment_amount = db.Column(db.Float, nullable=True, default=0.0)
status = db.Column(db.String(20), default='pending') status = db.Column(db.String(20), default='pending')
due_date = db.Column(db.DateTime, nullable=True) due_date = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
@@ -45,8 +48,20 @@ class Loan(db.Model):
) )
@classmethod @classmethod
def create_loan(cls, customer_id, account_id, offer_id, product_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,
upfront_fee,
repayment_amount,
installment_amount,
status="pending",
):
# Check if customer exists # Check if customer exists
customer = Customer.is_valid_customer(customer_id) customer = Customer.is_valid_customer(customer_id)
if not customer: if not customer:
@@ -64,6 +79,9 @@ class Loan(db.Model):
transaction_id = transaction_id, transaction_id = transaction_id,
initial_loan_amount = initial_loan_amount, initial_loan_amount = initial_loan_amount,
current_loan_amount = initial_loan_amount, current_loan_amount = initial_loan_amount,
upfront_fee = upfront_fee,
repayment_amount = repayment_amount,
installment_amount = installment_amount,
due_date=now, due_date=now,
status = status status = status
) )
+5 -1
View File
@@ -11,6 +11,8 @@ class Offer(db.Model):
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)
tenor = db.Column(db.Integer, nullable=False) tenor = db.Column(db.Integer, nullable=False)
schedule = db.Column(db.Integer, nullable=True)
list_order = db.Column(db.Integer, nullable=True)
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))
@@ -69,7 +71,9 @@ class Offer(db.Model):
"productId": self.product_id, "productId": self.product_id,
"minAmount": self.min_amount, "minAmount": self.min_amount,
"maxAmount": self.max_amount, "maxAmount": self.max_amount,
"tenor": self.tenor "tenor": self.tenor,
"schedule": self.schedule,
"list_order": self.list_order
} }
def __repr__(self): def __repr__(self):
+49 -8
View File
@@ -1,12 +1,53 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from app.extensions import db from app.extensions import db
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
from uuid import uuid4
from sqlalchemy.types import JSON
class RACCheck(Base): class RACCheck(db.Model):
__tablename__ = "rac_checks" __tablename__ = 'rac_checks'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) id = db.Column(db.String, primary_key=True)
transaction_id = Column(UUID, ForeignKey('transactions.id'), nullable=False) transaction_id = db.Column(db.String(50), nullable=False)
customer_id = Column(String, nullable=False) customer_id = db.Column(db.String, nullable=False)
account_id = Column(String, nullable=False) account_id = db.Column(db.String, nullable=False)
rac_response = Column(JSON, nullable=False) rac_response = db.Column(db.JSON, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow) 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))
@classmethod
def get_all_rac_checks(cls):
"""
Return all RAC checks in dictionary format.
"""
rac_checks = cls.query.all()
if not rac_checks:
raise ValueError("No available RAC checks")
return rac_checks
@classmethod
def get_rac_check_by_id(cls, check_id):
"""
Return a RAC check by its ID.
"""
rac_check = cls.query.filter_by(id=check_id).first()
if not rac_check:
raise ValueError(f"RAC Check with ID {check_id} not found")
return rac_check
def to_dict(self):
return {
"id": str(self.id),
"transactionId": str(self.transaction_id),
"customerId": self.customer_id,
"accountId": self.account_id,
"racResponse": self.rac_response,
"createdAt": self.created_at.isoformat(),
"updatedAt": self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<RACCheck {self.id}>'
@@ -0,0 +1,59 @@
"""Migration on Thu Apr 24 17:42:25 UTC 2025
Revision ID: 1b2339f43824
Revises: de9ad96ba34e
Create Date: 2025-04-24 17:43:09.589626
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1b2339f43824'
down_revision = 'de9ad96ba34e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('rac_checks',
sa.Column('id', sa.String(), nullable=False),
sa.Column('transaction_id', sa.String(length=50), nullable=False),
sa.Column('customer_id', sa.String(), nullable=False),
sa.Column('account_id', sa.String(), nullable=False),
sa.Column('rac_response', sa.JSON(), 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('loan_charges', schema=None) as batch_op:
batch_op.alter_column('amount',
existing_type=sa.NUMERIC(precision=10, scale=2),
type_=sa.Float(),
existing_nullable=True)
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.add_column(sa.Column('upfront_fee', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('repayment_amount', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('installment_amount', sa.Float(), 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('installment_amount')
batch_op.drop_column('repayment_amount')
batch_op.drop_column('upfront_fee')
with op.batch_alter_table('loan_charges', schema=None) as batch_op:
batch_op.alter_column('amount',
existing_type=sa.Float(),
type_=sa.NUMERIC(precision=10, scale=2),
existing_nullable=True)
op.drop_table('rac_checks')
# ### end Alembic commands ###
+1 -1
View File
@@ -37,5 +37,5 @@ confluent-kafka==1.9.2
python-dateutil>=2.8.0 python-dateutil