From 0995f08aeadedf27682911f3885ef2b446164250 Mon Sep 17 00:00:00 2001 From: Vivian Dee Date: Fri, 25 Apr 2025 13:31:11 +0100 Subject: [PATCH 1/5] [update]: Loan and Offers --- app/models/loan.py | 3 +++ app/models/offer.py | 4 +--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/models/loan.py b/app/models/loan.py index 93bdb1b..b8f9c1a 100644 --- a/app/models/loan.py +++ b/app/models/loan.py @@ -143,6 +143,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, diff --git a/app/models/offer.py b/app/models/offer.py index f9a782c..5050623 100644 --- a/app/models/offer.py +++ b/app/models/offer.py @@ -71,9 +71,7 @@ class Offer(db.Model): "productId": self.product_id, "minAmount": self.min_amount, "maxAmount": self.max_amount, - "tenor": self.tenor, - "schedule": self.schedule, - "list_order": self.list_order + "tenor": self.tenor } def __repr__(self): From c216c55928e5e795fadc83f017fd68d3cf8e2ac7 Mon Sep 17 00:00:00 2001 From: Vivian Dee Date: Fri, 25 Apr 2025 14:29:13 +0100 Subject: [PATCH 2/5] [add]: Lona repayment schedule --- app/api/services/base_service.py | 2 +- app/api/services/provide_loan.py | 29 +++++++---- app/models/__init__.py | 3 +- app/models/loan.py | 12 ++++- app/models/loan_repayment_schedule.py | 74 +++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 app/models/loan_repayment_schedule.py diff --git a/app/api/services/base_service.py b/app/api/services/base_service.py index b3a657e..fa1a77e 100644 --- a/app/api/services/base_service.py +++ b/app/api/services/base_service.py @@ -101,7 +101,7 @@ class BaseService: # 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: (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) diff --git a/app/api/services/provide_loan.py b/app/api/services/provide_loan.py index 74a521a..232466c 100644 --- a/app/api/services/provide_loan.py +++ b/app/api/services/provide_loan.py @@ -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 @@ -85,19 +89,26 @@ class ProvideLoanService(BaseService): 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 - - + + db.session.flush() + + schedule = LoanRepaymentSchedule.add_repayment_schedule(loan = loan, Offer = offer, charges = charges) + + + 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}") + # logger.error(f"{charges}") loan_id = loan.id @@ -152,8 +163,4 @@ class ProvideLoanService(BaseService): db.session.rollback() return jsonify({ "message": "Internal Server Error" - }) , 500 - - - - + }) , 500 \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py index 9cb9074..9fa2f1e 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -7,6 +7,7 @@ from .loan_charge import LoanCharge from .offer import Offer from .charge import Charge from .rac_checks import RACCheck +from .loan_repayment_schedule import LoanRepaymentSchedule -__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer', 'Charge', 'RACCheck'] \ No newline at end of file +__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer', 'Charge', 'RACCheck', 'LoanRepaymentSchedule'] \ No newline at end of file diff --git a/app/models/loan.py b/app/models/loan.py index b8f9c1a..79c86b8 100644 --- a/app/models/loan.py +++ b/app/models/loan.py @@ -5,6 +5,9 @@ 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 +from app.models.loan_repayment_schedule import LoanRepaymentSchedule # Make sure this import exists + class Loan(db.Model): @@ -47,6 +50,13 @@ class Loan(db.Model): back_populates="loan", ) + loan_repayent_schedules = relationship( + "LoanRepaymentSchedule", + primaryjoin="Loan.id == LoanRepaymentSchedule.loan_id", + foreign_keys="LoanRepaymentSchedule.loan_id", + back_populates="loan", + ) + @classmethod def create_loan( cls, @@ -60,6 +70,7 @@ class Loan(db.Model): upfront_fee, repayment_amount, installment_amount, + tenor, status="pending", ): # Check if customer exists @@ -92,7 +103,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( diff --git a/app/models/loan_repayment_schedule.py b/app/models/loan_repayment_schedule.py new file mode 100644 index 0000000..2523964 --- /dev/null +++ b/app/models/loan_repayment_schedule.py @@ -0,0 +1,74 @@ +from datetime import datetime, timezone +from app.extensions import db +from sqlalchemy.orm import relationship + +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) + installment_number = db.Column(db.Integer, nullable=False) + due_date = db.Column(db.DateTime, nullable=False) + principal_amount = db.Column(db.Float, default=0.0) + interest_amount = db.Column(db.Float, default=0.0) + total_installment = 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="LoanCharge.loan_id == Loan.id", + foreign_keys=[loan_id], + back_populates="loan_charges", + ) + + @classmethod + def add_repayment_schedule(cls, loan, Offer, charges): + """ + Add repayment schedules for a given loan. + """ + if not loan.amount or not loan.installment_amount: + raise ValueError("Loan must have amount and installment_amount set.") + + now = datetime.now(timezone.utc) + schedules = [] + interest_fee = charges["interest"] + + tenor = Offer.tenor // 30 + + principal = loan.amount / tenor + interest = interest_fee["fee"] / tenor + + 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, + principal_amount=round(principal, 2), + interest_amount=round(interest, 2), + total_installment=round(loan.installment_amount, 2) + ) + + 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'' From ddbabcaca985e0722df5d549e3f9213250334e2a Mon Sep 17 00:00:00 2001 From: VivianDee <115420678+VivianDee@users.noreply.github.com> Date: Fri, 25 Apr 2025 15:10:07 +0100 Subject: [PATCH 3/5] [fix]: Repayment schedule model --- app/models/loan_repayment_schedule.py | 4 +- ...4_migration_on_thu_apr_24_17_42_25_utc_.py | 6 --- ...2_migration_on_fri_apr_25_14_02_01_utc_.py | 41 +++++++++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 migrations/versions/2cf0c177ca02_migration_on_fri_apr_25_14_02_01_utc_.py diff --git a/app/models/loan_repayment_schedule.py b/app/models/loan_repayment_schedule.py index 2523964..aeb1923 100644 --- a/app/models/loan_repayment_schedule.py +++ b/app/models/loan_repayment_schedule.py @@ -19,9 +19,9 @@ class LoanRepaymentSchedule(db.Model): loan = relationship( "Loan", - primaryjoin="LoanCharge.loan_id == Loan.id", + primaryjoin="LoanRepaymentSchedule.loan_id == Loan.id", foreign_keys=[loan_id], - back_populates="loan_charges", + back_populates="loan_repayment_schedules", ) @classmethod diff --git a/migrations/versions/1b2339f43824_migration_on_thu_apr_24_17_42_25_utc_.py b/migrations/versions/1b2339f43824_migration_on_thu_apr_24_17_42_25_utc_.py index c72a985..c66d8a4 100644 --- a/migrations/versions/1b2339f43824_migration_on_thu_apr_24_17_42_25_utc_.py +++ b/migrations/versions/1b2339f43824_migration_on_thu_apr_24_17_42_25_utc_.py @@ -33,12 +33,6 @@ def upgrade(): 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 ### diff --git a/migrations/versions/2cf0c177ca02_migration_on_fri_apr_25_14_02_01_utc_.py b/migrations/versions/2cf0c177ca02_migration_on_fri_apr_25_14_02_01_utc_.py new file mode 100644 index 0000000..6876ba6 --- /dev/null +++ b/migrations/versions/2cf0c177ca02_migration_on_fri_apr_25_14_02_01_utc_.py @@ -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 ### From 851422c33581c423a7721428a38d85bd0b21fa74 Mon Sep 17 00:00:00 2001 From: VivianDee <115420678+VivianDee@users.noreply.github.com> Date: Fri, 25 Apr 2025 15:33:58 +0100 Subject: [PATCH 4/5] [fix]: loan amount --- app/models/loan.py | 13 +++++-------- app/models/loan_repayment_schedule.py | 6 ++++-- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/app/models/loan.py b/app/models/loan.py index 79c86b8..e625bee 100644 --- a/app/models/loan.py +++ b/app/models/loan.py @@ -4,10 +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 -from app.models.loan_repayment_schedule import LoanRepaymentSchedule # Make sure this import exists - +from dateutil.relativedelta import relativedelta class Loan(db.Model): @@ -45,18 +42,19 @@ 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", ) - loan_repayent_schedules = relationship( + loan_repayment_schedules = relationship( "LoanRepaymentSchedule", - primaryjoin="Loan.id == LoanRepaymentSchedule.loan_id", + primaryjoin="LoanRepaymentSchedule.loan_id == Loan.id", foreign_keys="LoanRepaymentSchedule.loan_id", back_populates="loan", ) + @classmethod def create_loan( cls, @@ -70,7 +68,6 @@ class Loan(db.Model): upfront_fee, repayment_amount, installment_amount, - tenor, status="pending", ): # Check if customer exists diff --git a/app/models/loan_repayment_schedule.py b/app/models/loan_repayment_schedule.py index aeb1923..eee9e23 100644 --- a/app/models/loan_repayment_schedule.py +++ b/app/models/loan_repayment_schedule.py @@ -1,6 +1,7 @@ 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' @@ -24,12 +25,13 @@ class LoanRepaymentSchedule(db.Model): back_populates="loan_repayment_schedules", ) + @classmethod def add_repayment_schedule(cls, loan, Offer, charges): """ Add repayment schedules for a given loan. """ - if not loan.amount or not loan.installment_amount: + if not loan.initial_loan_amount or not loan.installment_amount: raise ValueError("Loan must have amount and installment_amount set.") now = datetime.now(timezone.utc) @@ -38,7 +40,7 @@ class LoanRepaymentSchedule(db.Model): tenor = Offer.tenor // 30 - principal = loan.amount / tenor + principal = loan.initial_loan_amount / tenor interest = interest_fee["fee"] / tenor for i in range(tenor): From cf0502459b4f35f51b4515c43878c1a2534f27cb Mon Sep 17 00:00:00 2001 From: VivianDee <115420678+VivianDee@users.noreply.github.com> Date: Fri, 25 Apr 2025 15:34:46 +0100 Subject: [PATCH 5/5] [chore]: remove redundant code --- app/models/loan_repayment_schedule.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/models/loan_repayment_schedule.py b/app/models/loan_repayment_schedule.py index eee9e23..1eafb46 100644 --- a/app/models/loan_repayment_schedule.py +++ b/app/models/loan_repayment_schedule.py @@ -31,9 +31,7 @@ class LoanRepaymentSchedule(db.Model): """ Add repayment schedules for a given loan. """ - if not loan.initial_loan_amount or not loan.installment_amount: - raise ValueError("Loan must have amount and installment_amount set.") - + now = datetime.now(timezone.utc) schedules = [] interest_fee = charges["interest"]