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 93bdb1b..e625bee 100644 --- a/app/models/loan.py +++ b/app/models/loan.py @@ -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): @@ -42,11 +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_repayment_schedules = relationship( + "LoanRepaymentSchedule", + primaryjoin="LoanRepaymentSchedule.loan_id == Loan.id", + foreign_keys="LoanRepaymentSchedule.loan_id", + back_populates="loan", + ) + + @classmethod def create_loan( cls, @@ -92,7 +100,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( @@ -143,6 +150,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/loan_repayment_schedule.py b/app/models/loan_repayment_schedule.py new file mode 100644 index 0000000..1eafb46 --- /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 +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) + 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="LoanRepaymentSchedule.loan_id == Loan.id", + foreign_keys=[loan_id], + back_populates="loan_repayment_schedules", + ) + + + @classmethod + def add_repayment_schedule(cls, loan, Offer, charges): + """ + Add repayment schedules for a given loan. + """ + + now = datetime.now(timezone.utc) + schedules = [] + interest_fee = charges["interest"] + + tenor = Offer.tenor // 30 + + principal = loan.initial_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'' 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): 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 ###