Compare commits

...

33 Commits

Author SHA1 Message Date
VivianDee 8aa2c86ea2 [fix]: transaction id 2025-04-29 16:52:51 +01:00
VivianDee 92eadbfa16 [update]: Eligibility check 2025-04-29 08:26:41 +01:00
ameye 0fbdebceb3 Merge branch 'DIG-loan-linking-001' of DigiFi/digifi-BankToProductCore into master 2025-04-26 19:40:29 +00:00
ameye 488a1b4bdd First step to linked loans 2025-04-26 15:39:37 -04:00
ameye cdc74d05c4 Merge branch 'DIG-rework-rates-fee-calulations-001' of DigiFi/digifi-BankToProductCore into master 2025-04-26 18:34:27 +00:00
ameye 1b92ede296 Reworked the fee calculations structure 2025-04-26 14:31:06 -04:00
ameye 7de4e3651f Merge branch 'DIG-move-data-new-columns-001' of DigiFi/digifi-BankToProductCore into master 2025-04-26 12:56:10 +00:00
ameye 5f9b1f4cb8 Added rates with offers 2025-04-26 08:53:06 -04:00
ameye ed95865834 Merge branch 'loan_repayment_schedules' of DigiFi/digifi-BankToProductCore into master 2025-04-25 15:58:25 +00:00
VivianDee 6973630845 Update loan_repayment_schedule.py 2025-04-25 16:14:44 +01:00
VivianDee 5d37ba30fb [update]: repayment schedule table 2025-04-25 16:04:44 +01:00
ameye e8044d8fed Merge branch 'loan_repayment_schedules' of DigiFi/digifi-BankToProductCore into master 2025-04-25 14:42:09 +00:00
VivianDee cf0502459b [chore]: remove redundant code 2025-04-25 15:34:46 +01:00
VivianDee 851422c335 [fix]: loan amount 2025-04-25 15:33:58 +01:00
VivianDee ddbabcaca9 [fix]: Repayment schedule model 2025-04-25 15:10:07 +01:00
Vivian Dee c216c55928 [add]: Lona repayment schedule 2025-04-25 14:29:13 +01:00
Vivian Dee 0995f08aea [update]: Loan and Offers 2025-04-25 13:31:11 +01:00
ameye e034c0ff9d Merge branch 'loan_repayment_dates' of DigiFi/digifi-BankToProductCore into master 2025-04-25 11:33:54 +00:00
VivianDee 4d4e4fcd3e Update base_service.py 2025-04-25 11:33:05 +01:00
VivianDee 1cce111d1f Update offer.py 2025-04-25 11:30:43 +01:00
VivianDee b9b7988877 Update base_service.py 2025-04-25 11:12:23 +01:00
VivianDee 841393c470 [fix]: Loan fees 2025-04-25 10:59:24 +01:00
VivianDee bbb903b27c [update]: RACCheck 2025-04-24 18:44:31 +01:00
VivianDee c895cc36e0 [add]: Loan table extention 2025-04-24 18:29:38 +01:00
CHIEFSOFT\ameye 67c6d909f8 Adjusted the respose 2025-04-24 12:44:18 -04:00
ameye e08dfe9894 Merge branch 'loan_repayment_dates' of DigiFi/digifi-BankToProductCore into master 2025-04-24 10:28:10 +00:00
VivianDee 7d691db7a5 [update]: Select Offer 2025-04-23 20:59:07 +01:00
Vivian Dee 4b92c33d5a [fix]: loan charges and instalment amount 2025-04-23 18:57:22 +01:00
Vivian Dee 8cfa957cc0 [update]: Select Offer 2025-04-23 18:35:47 +01:00
ameye 5768b537b1 Merge branch 'charges_model' of DigiFi/digifi-BankToProductCore into master 2025-04-17 16:12:09 +00:00
ameye bc894c7856 Merge branch 'charges_model' of DigiFi/digifi-BankToProductCore into master 2025-04-17 15:13:48 +00:00
ameye 829bd976b2 Merge branch 'loan_charges_on_loans' of DigiFi/digifi-BankToProductCore into master 2025-04-17 10:45:18 +00:00
ameye e04f54bf83 Merge branch 'loan_charges_on_loans' of DigiFi/digifi-BankToProductCore into master 2025-04-17 10:03:16 +00:00
24 changed files with 845 additions and 55 deletions
+35
View File
@@ -0,0 +1,35 @@
# Environment Variables
BASIC_AUTH_USERNAME=user
BASIC_AUTH_PASSWORD=password
#swagger Configuration
SWAGGER_URL="/documentation"
API_URL="/swagger.json"
# Database Configuration
DATABASE_USER=firstadvance
DATABASE_PASSWORD=FirstAdvance!
DATABASE_HOST=dev-data.simbrellang.net
DATABASE_PORT=10532
DATABASE_NAME=firstadvancedev
# DATABASE_HOST=10.20.30.60
# DATABASE_USER=firstadvance
# DATABASE_PASSWORD=firstadvance
# DATABASE_NAME=firstadvancedev
# DATABASE_PORT=5432
# Flask Configuration
FLASK_APP=wsgi.py
FLASK_ENV=development
APP_PORT=4500
# Bank Call Service Connection
SIMBRELLA_BASE_URL="https://bank-emulator.dev.simbrellang.net"
VALID_APP_ID=app1
VALID_API_KEY=test-api-key-12345
# Event Bus Broker Configuration
KAFKA_BROKER="10.0.0.246:9092"
+2
View File
@@ -49,6 +49,8 @@ class SimbrellaIntegration:
logger.error(f"This is Response: {str(response)}", exc_info=True)
return response
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)}")
+110
View File
@@ -60,3 +60,113 @@ class BaseService:
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.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.schedule # offer.tenor // 30 # Convert to months
interest = cls.get_charge_detail(rates = offer.interest_rate, charges = loan_charges, code = "INTEREST", amount = amount)
management = cls.get_charge_detail(rates = offer.management_rate, charges = loan_charges, code = "MGTFEE", amount = amount)
insurance = cls.get_charge_detail(rates = offer.insurance_rate, charges = loan_charges, code = "INSURANCE", amount = amount)
vat = cls.get_charge_detail(rates = offer.vat_rate, 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
]
vat_test = vat["fee"]
logger.info(f"VAT fee == *************** : {vat_test}")
# Up-front payment: (only those fees due immediately i.e due_days == 0)
# upfront_payment = sum(upfront_fees)
if offer.schedule == 1:
upfront_payment = vat["fee"] + management["fee"] + insurance["fee"] + interest["fee"]
interest_amount = interest["fee"]
repayment_amount = amount
else:
upfront_payment = vat["fee"] + insurance["fee"]+management["fee"]
interest_amount = interest["fee"]*offer.schedule
repayment_amount = amount + interest_amount
# 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 / offer.schedule
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, rates, charges, code, amount, management_fee= 0.0):
"""
Get details for a specific charge code from a list of loan charges.
Returns default values if not found.
"""
fee = 0.0
if code == "VAT" and management_fee > 0:
fee = management_fee * rates / 100
else:
fee = amount * rates / 100
return {
"rate": rates,
"fee": round(fee, 2),
"due_days": 30,
"code": code,
"description" : "have no idea how to get this yet"
}
# if charge.code == code:
# if code == "VAT" and management_fee > 0:
# fee = management_fee * rates / 100
# else:
# fee = amount * rates / 100
#
# return {
# "rate": rates,
# "fee": round(fee, 2),
# "due_days": charge.due
# }
# return {"rate": 0, "fee": 0, "due_days": 0}
+32 -2
View File
@@ -1,4 +1,5 @@
from flask import session, jsonify
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
@@ -61,7 +62,36 @@ class EligibilityCheckService(BaseService):
if response.status_code != 200:
return jsonify({"message": "RACCheck failed"}), 400
offers = [offer.to_dict() for offer in Offer.get_all_offers()]
offers = Offer.get_all_offers()
eligible_offers = []
for offer in offers:
# Determine an approved amount
approved_amount = min(offer.max_amount, 5000)
transaction_offer = TransactionOffer.create_transaction_offer(
customer_id = customer.id,
transaction_id = transaction.transaction_id,
offer_id = offer.id,
min_amount = offer.min_amount,
max_amount = offer.max_amount,
eligible_amount = approved_amount,
product_id = offer.product_id,
tenor = offer.tenor
)
# Visible offer ID: offer_id + padded(transaction_offer.id)
padded_id = str(transaction_offer.id).zfill(6)
public_offer_id = f"{offer.id}{padded_id}"
eligible_offers.append({
"offerId": public_offer_id,
"product_id": offer.product_id,
"min_amount": offer.min_amount,
"max_amount": approved_amount,
"tenor": offer.tenor
})
# Simulate processing
response_data = {
@@ -69,7 +99,7 @@ class EligibilityCheckService(BaseService):
"transactionId": transactionId,
"countryCode": "NG",
"msisdn": msisdn,
"eligibleOffers": offers,
"eligibleOffers": eligible_offers,
"resultDescription": "Successful",
"resultCode": "00",
"accountId": account_id
+42 -13
View File
@@ -11,6 +11,10 @@ from threading import Thread
from app.models import Loan, Offer, Charge
from app.api.enums import LoanStatus
from app.extensions import db
from datetime import datetime, timezone
from dateutil.relativedelta import relativedelta
from app.models import LoanRepaymentSchedule
class ProvideLoanService(BaseService):
TRANSACTION_TYPE = TransactionType.PROVIDE_LOAN
@@ -36,6 +40,8 @@ class ProvideLoanService(BaseService):
collection_type = validated_data.get('collectionType')
transaction_id = validated_data.get('transactionId')
offer_id = validated_data.get('offerId')
amount = validated_data.get("requestedAmount")
product_id = validated_data.get("productId")
customer = Customer.is_valid_customer(customer_id)
@@ -48,8 +54,7 @@ class ProvideLoanService(BaseService):
return jsonify({
"message": "Invalid Offer."
}), 400
# Log Transaction
transaction = ProvideLoanService.log_transaction(validated_data=validated_data)
@@ -58,6 +63,24 @@ class ProvideLoanService(BaseService):
return jsonify({
"message": "Failed to log transaction."
}), 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"]
tenor = offer.tenor // 30 # Convert to months
upfront_payment = charges["upfront_payment"]
total_amount = charges["total_amount"]
installment_amount = charges["installment_amount"]
interest = charges["interest"]
management = charges["management"]
insurance = charges["insurance"]
vat = charges["vat"]
# Save the loan details
@@ -69,20 +92,30 @@ class ProvideLoanService(BaseService):
collection_type = collection_type,
transaction_id = validated_data.get('transactionId'),
initial_loan_amount = validated_data.get('requestedAmount'),
upfront_fee = upfront_fee,
repayment_amount = repayment_amount,
installment_amount = installment_amount,
status= LoanStatus.ACTIVE
)
db.session.flush()
if not loan:
logger.error(f"Failed to save loan details")
return jsonify({
"message": "Failed to save loan details."
}), 400
charges = Charge.get_offer_charges(offer.id)
db.session.flush()
schedule = LoanRepaymentSchedule.add_repayment_schedule(loan = loan, tenor = tenor)
if not schedule:
logger.error(f"Failed to create repayment schedule for loan ID {loan.id}")
return jsonify({
"message": "Failed to generate loan repayment schedule."
}), 400
# charges = Charge.get_offer_charges(offer.id)
logger.error(f"{charges}")
@@ -139,8 +172,4 @@ class ProvideLoanService(BaseService):
db.session.rollback()
return jsonify({
"message": "Internal Server Error"
}) , 500
}) , 500
+70 -17
View File
@@ -5,7 +5,9 @@ from app.api.enums import TransactionType
from app.utils.logger import logger
from app.api.schemas.select_offer import SelectOfferSchema
from app.extensions import db
from app.models import Offer
from datetime import date
from dateutil.relativedelta import relativedelta
class SelectOfferService(BaseService):
TRANSACTION_TYPE = TransactionType.SELECT_OFFER
@@ -28,6 +30,10 @@ class SelectOfferService(BaseService):
)
account_id = validated_data.get("accountId")
customer_id = validated_data.get("customerId")
amount = validated_data.get("requestedAmount")
product_id = validated_data.get("productId")
transaction_id = validated_data.get("transactionId")
request_id = validated_data.get("requestId")
if SelectOfferService.validate_account_ownership(
account_id=account_id, customer_id=customer_id
@@ -42,30 +48,76 @@ class SelectOfferService(BaseService):
else:
return jsonify({"message": "Invalid Customer or Account"}), 400
# Get the offer by product ID
offer = Offer.get_offer_by_product_id(product_id)
db.session.flush()
charges = SelectOfferService.calculate_charges(offer, amount)
upfront_payment = charges["upfront_payment"]
total_amount = charges["total_amount"]
installment_amount = charges["installment_amount"]
interest = charges["interest"]
management = charges["management"]
insurance = charges["insurance"]
vat = charges["vat"]
repayment_amount = charges["repayment_amount"]
# Calculate the repayment dates
tenor = offer.tenor
start_date = date.today()
# Convert tenor to months
months = offer.schedule # tenor // 30
recommended_repayment_dates = [
(start_date + relativedelta(months=i + 1)).isoformat()
for i in range(months)
]
offers = [
{
"offerId": "SAL90",
"productId": "2030",
"amount": 10000.0,
"upfrontPayment": 1000.0,
"interestRate": 3.0,
"managementRate": 1.0,
"managementFee": 1.0,
"insuranceRate": 1.0,
"insuranceFee": 100.0,
"VATRate": 7.5,
"VATAmount": 100.0,
"recommendedRepaymentDates": ["2022-11-30"],
"installmentAmount": 11000.0,
"totalRepaymentAmount": 11000.0,
"offerId": offer.id,
"productId": product_id,
"amount": amount,
"upfrontPayment": upfront_payment,
"interestRate": offer.interest_rate,
"managementRate": offer.management_rate,
"managementFee": management["fee"],
"insuranceRate": offer.insurance_rate,
"insuranceFee": insurance["fee"],
"VATRate": offer.vat_rate,
"VATAmount": vat["fee"],
"recommendedRepaymentDates": recommended_repayment_dates,
"repaymentAmount": repayment_amount,
"installmentAmount": installment_amount,
"totalRepaymentAmount": total_amount,
}
]
# "offerId": offer.id,
# "productId": product_id,
# "amount": amount,
# "upfrontPayment": upfront_payment,
# "interestRate": interest["rate"],
# "managementRate": management["rate"],
# "managementFee": management["fee"],
# "insuranceRate": insurance["rate"],
# "insuranceFee": insurance["fee"],
# "VATRate": vat["rate"],
# "VATAmount": vat["fee"],
# "recommendedRepaymentDates": recommended_repayment_dates,
# "installmentAmount": installment_amount,
# "totalRepaymentAmount": total_amount,
#
# Business logic - selecting an offer
response_data = {
"outstandingDebtAmount": 0,
"requestId": "202111170001371256908",
"transactionId": transaction.id,
"requestId": request_id,
"transactionId": transaction_id,
"customerId": customer_id,
"accountId": account_id,
"loan": offers,
@@ -91,3 +143,4 @@ class SelectOfferService(BaseService):
logger.error(f"An error occurred: {str(e)}", exc_info=True)
db.session.rollback()
return jsonify({"message": "Internal Server Error"}), 500
+2 -1
View File
@@ -31,7 +31,8 @@ class Config:
"JWT_REFRESH_TOKEN_EXPIRES", timedelta(days=30)
)
KAFKA_BROKER = 'dev-events.simbrellang.net:9085'
# KAFKA_BROKER = 'dev-events.simbrellang.net:9085'
KAFKA_BROKER = os.getenv("KAFKA_BROKER", "dev-events.simbrellang.net:9085")
settings = Config()
+4 -1
View File
@@ -6,6 +6,9 @@ from .repayment import Repayment
from .loan_charge import LoanCharge
from .offer import Offer
from .charge import Charge
from .rac_checks import RACCheck
from .loan_repayment_schedule import LoanRepaymentSchedule
from .transaction_offers import TransactionOffer
__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer', 'Charge']
__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer', 'Charge', 'RACCheck', 'LoanRepaymentSchedule', 'TransactionOffer']
+7
View File
@@ -27,6 +27,13 @@ class Customer(db.Model):
back_populates="customer",
)
transaction_offers = relationship(
"TransactionOffer",
primaryjoin="Customer.id == TransactionOffer.customer_id",
foreign_keys="TransactionOffer.customer_id",
back_populates="customer",
)
@classmethod
def is_valid_customer(cls, customer_id):
customer = cls.query.filter_by(id=customer_id).first()
+35 -5
View File
@@ -4,7 +4,7 @@ from app.models.customer import Customer
from app.models.account import Account
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import relationship
from app.models.loan_charge import LoanCharge
from dateutil.relativedelta import relativedelta
class Loan(db.Model):
@@ -17,6 +17,7 @@ class Loan(db.Model):
)
customer_id = db.Column(db.String(50), nullable=False)
transaction_id = db.Column(db.String(50), nullable=True)
original_transaction = db.Column(db.String(50), nullable=True)
account_id = db.Column(db.String(50), nullable=False)
offer_id = db.Column(db.String(20), nullable=False)
product_id = db.Column(db.String(20), nullable=True)
@@ -25,6 +26,9 @@ class Loan(db.Model):
initial_loan_amount = db.Column(db.Float, nullable=False)
default_penalty_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')
due_date = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
@@ -39,14 +43,34 @@ class Loan(db.Model):
loan_charges = relationship(
"LoanCharge",
primaryjoin="Loan.id == LoanCharge.loan_id",
primaryjoin="LoanCharge.loan_id == Loan.id",
foreign_keys="LoanCharge.loan_id",
back_populates="loan",
)
@classmethod
def create_loan(cls, customer_id, account_id, offer_id, product_id, initial_loan_amount, collection_type, transaction_id, status='pending'):
loan_repayment_schedules = relationship(
"LoanRepaymentSchedule",
primaryjoin="LoanRepaymentSchedule.loan_id == Loan.id",
foreign_keys="LoanRepaymentSchedule.loan_id",
back_populates="loan",
)
@classmethod
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
customer = Customer.is_valid_customer(customer_id)
if not customer:
@@ -62,8 +86,12 @@ class Loan(db.Model):
product_id = product_id,
collection_type = collection_type,
transaction_id = transaction_id,
original_transaction = transaction_id,
initial_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,
status = status
)
@@ -74,7 +102,6 @@ class Loan(db.Model):
raise ValueError(f"Database integrity error: {err}")
return loan
@classmethod
def has_active_loans(cls, customer_id):
active_loans = cls.query.filter_by(
@@ -125,6 +152,9 @@ class Loan(db.Model):
'defaultPenaltyFee': self.default_penalty_fee,
'continuousFee': self.continuous_fee,
'collectionType': self.collection_type,
'upfrontFee': self.upfront_fee,
'repaymentAmount': self.repayment_amount,
'installmentAmount': self.installment_amount,
'status': self.status,
'dueDate': self.due_date.isoformat() if self.due_date else None,
'loanDate': self.created_at.isoformat() if self.created_at else None,
+13 -10
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timezone, timedelta
from app.extensions import db
from sqlalchemy.orm import relationship
from app.utils.logger import logger
class LoanCharge(db.Model):
@@ -35,8 +36,8 @@ class LoanCharge(db.Model):
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 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")
@@ -44,21 +45,23 @@ class LoanCharge(db.Model):
loan_charges = []
now = datetime.now(timezone.utc)
for charge in charges:
due_days = getattr(charge, "due", 0)
amount = getattr(charge, "amount", 0.0)
percent = getattr(charge, "percent", 0.0)
if amount == 0.0:
amount = (percent / 100.0) * referenced_amount
subset_keys = ['interest', 'management', 'insurance', 'vat']
for item in subset_keys:
charge = charges[item]
due_days = charge['due_days'] # getattr(charge, "due_days", 0)
amount = charge['fee'] # getattr(charge, "fee", 0.0)
percent = charge['rate'] # getattr(charge, "rate", 0.0)
code = charge['code'] # getattr(charge, "code","")
description = charge['description'] # getattr(charge, "description", "")
charge_obj = cls(
loan_id = loan_id,
transaction_id = transaction_id,
code = getattr(charge, "code"),
code = code,
amount = round(amount, 2),
percent = percent,
description = getattr(charge, "description", ""),
description = description,
due = due_days,
due_date = now + timedelta(days=due_days)
)
+68
View File
@@ -0,0 +1,68 @@
from datetime import datetime, timezone
from app.extensions import db
from sqlalchemy.orm import relationship
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)
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)
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="LoanRepaymentSchedule.loan_id == Loan.id",
foreign_keys=[loan_id],
back_populates="loan_repayment_schedules",
)
@classmethod
def add_repayment_schedule(cls, loan, tenor):
"""
Add repayment schedules for a given loan.
"""
now = datetime.now(timezone.utc)
schedules = []
for i in range(tenor):
due_date = now + relativedelta(months=i + 1)
schedule = LoanRepaymentSchedule(
loan_id=loan.id,
installment_number=i + 1,
due_date=due_date,
total_repayment_amount = round(loan.repayment_amount, 2),
installment_amount=round(loan.installment_amount, 2),
product_id = loan.product_id
)
db.session.add(schedule)
schedules.append(schedule)
return schedules
def to_dict(self):
return {
'id': self.id,
'loanId': self.loan_id,
'installmentNumber': self.installment_number,
'dueDate': self.due_date.isoformat(),
'principalAmount': self.principal_amount,
'interestAmount': self.interest_amount,
'totalInstallment': self.total_installment,
'paid': self.paid,
'paidAt': self.paid_at.isoformat() if self.paid_at else None
}
def __repr__(self):
return f'<LoanRepaymentSchedule Loan:{self.loan_id} Installment:{self.installment_number}>'
+33 -1
View File
@@ -11,6 +11,12 @@ class Offer(db.Model):
min_amount = db.Column(db.Float, nullable=False)
max_amount = db.Column(db.Float, nullable=False)
tenor = db.Column(db.Integer, nullable=False)
schedule = db.Column(db.Integer, nullable=True)
interest_rate = db.Column(db.Float, default=3.0)
management_rate = db.Column(db.Float, default=1.0)
insurance_rate = db.Column(db.Float, default=1.0)
vat_rate = db.Column(db.Float, default=7.5)
list_order = db.Column(db.Integer, nullable=True)
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))
@@ -40,6 +46,28 @@ class Offer(db.Model):
if not offer:
return False
return offer
@classmethod
def get_offer_by_id(cls, offer_id):
"""
Return an offer by its ID.
"""
offer = cls.query.filter_by(id=str(offer_id)).first()
if not offer:
raise ValueError(f"Offer with ID {offer_id} not found")
return offer
@classmethod
def get_offer_by_product_id(cls, product_id):
"""
Return an offer by its product ID.
"""
offer = cls.query.filter_by(product_id=str(product_id)).first()
if not offer:
raise ValueError(f"Offer with Product ID {product_id} not found")
return offer
def to_dict(self):
return {
@@ -47,7 +75,11 @@ class Offer(db.Model):
"productId": self.product_id,
"minAmount": self.min_amount,
"maxAmount": self.max_amount,
"tenor": self.tenor
"tenor": self.tenor,
"interest_rate": self.interest_rate,
"management_rate": self.management_rate,
"insurance_rate": self.insurance_rate,
"vat_rate": self.vat_rate
}
def __repr__(self):
+53
View File
@@ -0,0 +1,53 @@
from datetime import datetime, timezone
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(db.Model):
__tablename__ = 'rac_checks'
id = db.Column(db.String, primary_key=True)
transaction_id = db.Column(db.String(50), nullable=False)
customer_id = db.Column(db.String, nullable=False)
account_id = db.Column(db.String, nullable=False)
rac_response = db.Column(db.JSON, 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))
@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}>'
+66
View File
@@ -0,0 +1,66 @@
from datetime import datetime, timezone
from app.extensions import db
from sqlalchemy.orm import relationship
class TransactionOffer(db.Model):
__tablename__ = 'transaction_offers'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
customer_id = db.Column(db.String(50), nullable=False)
transaction_id = db.Column(db.String(50), nullable=False)
offer_id = db.Column(db.String(20), nullable=False)
product_id = db.Column(db.String(20), nullable=True)
min_amount = db.Column(db.Float, nullable=False)
max_amount = db.Column(db.Float, nullable=False)
eligible_amount = db.Column(db.Float, nullable=True)
tenor = db.Column(db.Integer, nullable=True) # tenor in months, typically
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))
customer = relationship(
"Customer",
primaryjoin="Customer.id == TransactionOffer.customer_id",
foreign_keys=[customer_id],
back_populates="transaction_offers",
)
@classmethod
def create_transaction_offer(cls, customer_id, transaction_id, offer_id, min_amount, max_amount, eligible_amount=None, product_id=None, tenor=None):
"""
Class method to create and save a TransactionOffer.
"""
transaction_offer = cls(
customer_id=customer_id,
transaction_id=transaction_id,
offer_id=offer_id,
min_amount=min_amount,
max_amount=max_amount,
eligible_amount=eligible_amount,
product_id=product_id,
tenor=tenor
)
db.session.add(transaction_offer)
db.session.flush()
return transaction_offer
def to_dict(self):
return {
'id': self.id,
'customerId': self.customer_id,
'transactionId': self.transaction_id,
'offerId': self.offer_id,
'productId': self.product_id,
'minAmount': self.min_amount,
'maxAmount': self.max_amount,
'eligibleAmount': self.eligible_amount,
'tenor': self.tenor,
'createdAt': self.created_at.isoformat() if self.created_at else None,
'updatedAt': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<TransactionOffer {self.id}>'
+17 -4
View File
@@ -47,13 +47,26 @@
"productId": {
"type": "string",
"example": "101"
},
"installment": {
"type": "array",
"items": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"format": "float",
"example": 10000.0
},
"repaymentDate": {
"type": "string",
"example": "2025-04-24 10:31:"
}
}
}
},
"totalDebtAmount": {
"type": "integer",
"example": 8500
}
}
}
},
"resultCode": {
"type": "string",
+5 -1
View File
@@ -35,6 +35,10 @@
"format": "float",
"example": 10000.0
},
"dueDate": {
"type": "string",
"example": "2025-04-24 10:31:"
},
"upfrontPayment": {
"type": "number",
"format": "float",
@@ -75,7 +79,7 @@
"format": "float",
"example": 100.0
},
"recommendedRepaymentDates": {
"installmentRepaymentDates": {
"type": "array",
"items": {
"type": "string"
@@ -0,0 +1,53 @@
"""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)
# ### 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 ###
@@ -0,0 +1,42 @@
"""Migration on Fri Apr 25 15:01:00 UTC 2025
Revision ID: 2a45dd99c9cb
Revises: 2cf0c177ca02
Create Date: 2025-04-25 15:01:51.129681
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2a45dd99c9cb'
down_revision = '2cf0c177ca02'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loan_repayment_schedules', schema=None) as batch_op:
batch_op.add_column(sa.Column('product_id', sa.String(length=20), nullable=True))
batch_op.add_column(sa.Column('installment_amount', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('total_repayment_amount', sa.Float(), nullable=True))
batch_op.drop_column('principal_amount')
batch_op.drop_column('interest_amount')
batch_op.drop_column('total_installment')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loan_repayment_schedules', schema=None) as batch_op:
batch_op.add_column(sa.Column('total_installment', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
batch_op.add_column(sa.Column('interest_amount', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
batch_op.add_column(sa.Column('principal_amount', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
batch_op.drop_column('total_repayment_amount')
batch_op.drop_column('installment_amount')
batch_op.drop_column('product_id')
# ### end Alembic commands ###
@@ -0,0 +1,41 @@
"""Migration on Fri Apr 25 14:02:01 UTC 2025
Revision ID: 2cf0c177ca02
Revises: 1b2339f43824
Create Date: 2025-04-25 14:02:42.244146
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2cf0c177ca02'
down_revision = '1b2339f43824'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('loan_repayment_schedules',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('loan_id', sa.Integer(), nullable=False),
sa.Column('installment_number', sa.Integer(), nullable=False),
sa.Column('due_date', sa.DateTime(), nullable=False),
sa.Column('principal_amount', sa.Float(), nullable=True),
sa.Column('interest_amount', sa.Float(), nullable=True),
sa.Column('total_installment', sa.Float(), nullable=True),
sa.Column('paid', sa.Boolean(), nullable=True),
sa.Column('paid_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('loan_repayment_schedules')
# ### end Alembic commands ###
+41
View File
@@ -0,0 +1,41 @@
"""empty message
Revision ID: 86e701febdda
Revises: eb99c7fb9e09
Create Date: 2025-04-29 07:59:33.305967
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '86e701febdda'
down_revision = 'eb99c7fb9e09'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('transaction_offers',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('customer_id', sa.String(length=50), nullable=False),
sa.Column('transaction_id', sa.String(length=50), nullable=False),
sa.Column('offer_id', sa.String(length=20), nullable=False),
sa.Column('product_id', sa.String(length=20), nullable=True),
sa.Column('min_amount', sa.Float(), nullable=False),
sa.Column('max_amount', sa.Float(), nullable=False),
sa.Column('eligible_amount', sa.Float(), nullable=True),
sa.Column('tenor', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('transaction_offers')
# ### end Alembic commands ###
@@ -0,0 +1,38 @@
"""Migration on Sat Apr 26 12:50:46 UTC 2025
Revision ID: 89759cebb9c6
Revises: 2a45dd99c9cb
Create Date: 2025-04-26 12:50:49.771355
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '89759cebb9c6'
down_revision = '2a45dd99c9cb'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('offers', schema=None) as batch_op:
batch_op.add_column(sa.Column('interest_rate', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('management_rate', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('insurance_rate', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('vat_rate', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('offers', schema=None) as batch_op:
batch_op.drop_column('vat_rate')
batch_op.drop_column('insurance_rate')
batch_op.drop_column('management_rate')
batch_op.drop_column('interest_rate')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""Migration on Sat Apr 26 19:02:17 UTC 2025
Revision ID: eb99c7fb9e09
Revises: 89759cebb9c6
Create Date: 2025-04-26 19:02:20.443678
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'eb99c7fb9e09'
down_revision = '89759cebb9c6'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.add_column(sa.Column('original_transaction', sa.String(length=50), 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('original_transaction')
# ### end Alembic commands ###
+4
View File
@@ -35,3 +35,7 @@ flask-jwt-extended
# Kafka
confluent-kafka==1.9.2
python-dateutil