diff --git a/app/api/services/base_service.py b/app/api/services/base_service.py index a9532af..b3a657e 100644 --- a/app/api/services/base_service.py +++ b/app/api/services/base_service.py @@ -60,3 +60,91 @@ 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.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} + + diff --git a/app/api/services/provide_loan.py b/app/api/services/provide_loan.py index b34ccd0..74a521a 100644 --- a/app/api/services/provide_loan.py +++ b/app/api/services/provide_loan.py @@ -36,6 +36,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 +50,7 @@ class ProvideLoanService(BaseService): return jsonify({ "message": "Invalid Offer." }), 400 - - + # Log Transaction transaction = ProvideLoanService.log_transaction(validated_data=validated_data) @@ -58,6 +59,15 @@ 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"] + # Save the loan details @@ -69,6 +79,9 @@ 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 ) diff --git a/app/api/services/select_offer.py b/app/api/services/select_offer.py index a207f2b..ddf6bd1 100644 --- a/app/api/services/select_offer.py +++ b/app/api/services/select_offer.py @@ -51,38 +51,16 @@ class SelectOfferService(BaseService): # Get the offer by 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() - 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) - - # Up-front payment: (principal + only those fees due immediately i.e due_days == 0) - upfront_payment = amount + sum( - amount * charge.percent / 100 - for charge in loan_charges - if charge.due == 0 - ) - - # Total amount (principal + all fees) - total_amount = amount + sum( - amount * charge.percent / 100 - for charge in loan_charges - ) + 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"] # Calculate the repayment dates @@ -97,8 +75,6 @@ class SelectOfferService(BaseService): for i in range(months) ] - # Calculate the installment amount - installment_amount = total_amount / tenor offers = [ @@ -150,25 +126,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 - - - - @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} - + \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py index 3d16094..9cb9074 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -6,6 +6,7 @@ from .repayment import Repayment from .loan_charge import LoanCharge from .offer import Offer from .charge import Charge +from .rac_checks import RACCheck -__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer', 'Charge'] \ No newline at end of file +__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer', 'Charge', 'RACCheck'] \ No newline at end of file diff --git a/app/models/loan.py b/app/models/loan.py index 6a96b51..93bdb1b 100644 --- a/app/models/loan.py +++ b/app/models/loan.py @@ -25,6 +25,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)) @@ -45,8 +48,20 @@ class Loan(db.Model): ) @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 customer = Customer.is_valid_customer(customer_id) if not customer: @@ -64,6 +79,9 @@ class Loan(db.Model): transaction_id = 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 ) diff --git a/app/models/offer.py b/app/models/offer.py index a2009e7..f9a782c 100644 --- a/app/models/offer.py +++ b/app/models/offer.py @@ -11,6 +11,8 @@ 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) + 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)) @@ -69,7 +71,9 @@ class Offer(db.Model): "productId": self.product_id, "minAmount": self.min_amount, "maxAmount": self.max_amount, - "tenor": self.tenor + "tenor": self.tenor, + "schedule": self.schedule, + "list_order": self.list_order } def __repr__(self): diff --git a/app/models/rac_checks.py b/app/models/rac_checks.py index 9e33a84..59d0816 100644 --- a/app/models/rac_checks.py +++ b/app/models/rac_checks.py @@ -1,12 +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(Base): - __tablename__ = "rac_checks" +class RACCheck(db.Model): + __tablename__ = 'rac_checks' - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) - transaction_id = Column(UUID, ForeignKey('transactions.id'), nullable=False) - customer_id = Column(String, nullable=False) - account_id = Column(String, nullable=False) - rac_response = Column(JSON, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow) + 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'' 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 new file mode 100644 index 0000000..c72a985 --- /dev/null +++ b/migrations/versions/1b2339f43824_migration_on_thu_apr_24_17_42_25_utc_.py @@ -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 ### diff --git a/requirements.txt b/requirements.txt index 124ae97..820a23d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,5 +37,5 @@ confluent-kafka==1.9.2 -python-dateutil>=2.8.0 +python-dateutil