diff --git a/app/api/integrations/simbrella.py b/app/api/integrations/simbrella.py index 1bfae3e..5db7012 100644 --- a/app/api/integrations/simbrella.py +++ b/app/api/integrations/simbrella.py @@ -1,8 +1,9 @@ -import requests +import httpx import json -from requests.auth import HTTPBasicAuth from app.utils.logger import logger from app.config import settings +import logging + class SimbrellaIntegration: BASE_URL = settings.SIMBRELLA_BASE_URL @@ -13,43 +14,41 @@ class SimbrellaIntegration: Calls the RACCheck endpoit """ url = f"{SimbrellaIntegration.BASE_URL}/RACCheck" - + payload = { "customerId": customer_id, "accountId": account_id, - "transactionId": transaction_id, + "transactionId": str(transaction_id), + "fbnTransactionId": f"FBN{transaction_id}", "RAC_Array": [ - { - "salaryAccount": True, - "bvn": "12345678901", - "crc": False, - "crms": True, - "accountStatus": "active", - "lien": False, - "noBouncedCheck": True, - "existingLoan": False, - "whitelist": True, - "noPastDueSalaryLoan": True, - "noPastDueOtherLoans": False - } - ] + "SalaryAccount", + "BVN", + "BVNAttachedtoAccount", + "CRC", + "CRMS", + "AccountStatus", + "Lien", + "NoBouncedCheck", + "Whitelist", + "NoPastDueSalaryLoan", + "NoPastDueOtherLoan", + ], } - logger.error(f"This is PayLoad: {str(payload)}",exc_info=True) + logger.error(f"This is PayLoad: {str(payload)}", exc_info=True) + headers = { - 'Content-Type': 'application/json', - 'x-api-key': f'{settings.VALID_API_KEY}', - 'App-Id': f'{settings.VALID_APP_ID}' + "Content-Type": "application/json", + "x-api-key": f"{settings.VALID_API_KEY}", + "App-Id": f"{settings.VALID_APP_ID}", } try: - response = requests.post(url, json=payload, timeout=10, headers=headers) - logger.error(f"This is Response: {str(response)}", exc_info=True) - # Raise an error for non-200 responses - if response.status_code != 200: - response.raise_for_status() + response = httpx.post(url, json=payload, headers=headers, timeout=10.0) - return response.json() - except requests.exceptions.RequestException as err: - logger.error(f"RACCheck API call failed: {str(err)}", exc_info=True) - return {"error": "RACCheck API error"} + 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)}") diff --git a/app/api/schemas/provide_loan.py b/app/api/schemas/provide_loan.py index 5959e29..ad6c8b1 100644 --- a/app/api/schemas/provide_loan.py +++ b/app/api/schemas/provide_loan.py @@ -12,5 +12,5 @@ class ProvideLoanSchema(Schema): # lienAmount = fields.Float(required=True) requestedAmount = fields.Float(required=True) collectionType = fields.Int(required=True) - offerId = fields.Int(required=True) + offerId = fields.Str(required=True) channel = fields.Str(required=True) \ No newline at end of file diff --git a/app/api/services/eligibility_check.py b/app/api/services/eligibility_check.py index bf0d0aa..6cdf075 100644 --- a/app/api/services/eligibility_check.py +++ b/app/api/services/eligibility_check.py @@ -6,6 +6,7 @@ from marshmallow import ValidationError from app.api.enums import TransactionType from app.api.integrations import SimbrellaIntegration from app.extensions import db +from app.models import Offer class EligibilityCheckService(BaseService): TRANSACTION_TYPE = TransactionType.ELIGIBILITY_CHECK @@ -47,35 +48,20 @@ class EligibilityCheckService(BaseService): "message": "Invalid Customer or Account" }), 400 + db.session.flush() + # Call RACCheck response = SimbrellaIntegration.rac_check( customer_id = customer_id, account_id = account_id, transaction_id = transaction.id, ) - logger.error(f"This is Response Returned ****** : {str(response)}") # this chck for error is not valid - logger.error(f"Check for ERROR is not valid ****** FIX THIS !!!!!") - #if "error" in response or response.get("status") != 200: - # return jsonify({"message": "RACCheck failed"}), 400 + if response.status_code != 200: + return jsonify({"message": "RACCheck failed"}), 400 - offers = [ - { - "offerId": "SAL90", - "productId": "2030", - "minAmount": 5000, - "maxAmount": 100000, - "tenor": 30 - }, - { - "offerId": "SAL30", - "productId": "2090", - "minAmount": 5000, - "maxAmount": 500000, - "tenor": 90 - } - ] + offers = [offer.to_dict() for offer in Offer.get_all_offers()] # Simulate processing response_data = { diff --git a/app/api/services/provide_loan.py b/app/api/services/provide_loan.py index 108bbac..30da2ca 100644 --- a/app/api/services/provide_loan.py +++ b/app/api/services/provide_loan.py @@ -3,10 +3,12 @@ from marshmallow import ValidationError from app.api.integrations.kafka import KafkaIntegration from app.api.services.base_service import BaseService from app.api.enums import TransactionType +from app.models.customer import Customer +from app.models.loan_charge import LoanCharge from app.utils.logger import logger from app.api.schemas.provide_loan import ProvideLoanSchema from threading import Thread -from app.models.loan import Loan +from app.models import Loan, Offer from app.api.enums import LoanStatus from app.extensions import db @@ -33,9 +35,21 @@ class ProvideLoanService(BaseService): request_id = validated_data.get('requestId') collection_type = validated_data.get('collectionType') transaction_id = validated_data.get('transactionId') + offer_id = validated_data.get('offerId') + + customer = Customer.is_valid_customer(customer_id) if (ProvideLoanService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): + offer = Offer.is_valid_offer(offer_id) + + if not offer: + logger.error(f"Invalid Offer") + return jsonify({ + "message": "Invalid Offer." + }), 400 + + # Log Transaction transaction = ProvideLoanService.log_transaction(validated_data=validated_data) @@ -50,52 +64,69 @@ class ProvideLoanService(BaseService): loan = Loan.create_loan( customer_id = customer_id, account_id = account_id, - offer_id = validated_data.get('offerId'), + offer_id = offer_id, + product_id = offer.product_id, collection_type = collection_type, transaction_id = validated_data.get('transactionId'), initial_loan_amount = validated_data.get('requestedAmount'), 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 + - logger.error(f"********* We need to develop the fee array here") - loan_def = { - "offers": [ - { - "offerId": "SAL90", - "productId": "2030", - "minAmount": 5000, - "maxAmount": 100000, - "tenor": 30 - }, - { - "offerId": "SAL30", - "productId": "2090", - "minAmount": 3000, - "maxAmount": 500000, - "tenor": 90 - } - ], - "loan_fee": { - "SAL30": [ - {"code": "INTEREST", "percent": 1.1, "due": 0, "description": "This is fee 000"}, - {"code": "MGTFEE", "percent": 2.5, "due": 0, "description": "This is fee 001"}, - {"code": "INSURANCE", "percent": 3.5, "due": 0, "description": "This is fee 001"}, - {"code": "VAT", "percent": 1.0, "due": 0, "description": "This is fee 001"}, - ], - "SAL90": [ + charges = [ {"code": "INTEREST", "percent": 1.1, "due": 0, "description": "This is fee 9000"}, {"code": "MGTFEE", "percent": 1.5, "due": 0, "description": "This is fee 90002"}, {"code": "INSURANCE", "percent": 1.5, "due": 30, "description": "This is fee 90003"}, {"code": "VAT", "percent": 1.5, "due": 60, "description": "This is fee 90004"}, ] - } - } + loan_id = loan.id + + loan_charges = LoanCharge.create_charges_for_loan(loan_id = loan_id, charges = charges) + + + # logger.error(f"********* We need to develop the fee array here") + + # loan_def = { + # "offers": [ + # { + # "offerId": "SAL90", + # "productId": "2030", + # "minAmount": 5000, + # "maxAmount": 100000, + # "tenor": 30 + # }, + # { + # "offerId": "SAL30", + # "productId": "2090", + # "minAmount": 3000, + # "maxAmount": 500000, + # "tenor": 90 + # } + # ], + # "loan_fee": { + # "SAL30": [ + # {"code": "INTEREST", "percent": 1.1, "due": 0, "description": "This is fee 000"}, + # {"code": "MGTFEE", "percent": 2.5, "due": 0, "description": "This is fee 001"}, + # {"code": "INSURANCE", "percent": 3.5, "due": 0, "description": "This is fee 001"}, + # {"code": "VAT", "percent": 1.0, "due": 0, "description": "This is fee 001"}, + # ], + # "SAL90": [ + # {"code": "INTEREST", "percent": 1.1, "due": 0, "description": "This is fee 9000"}, + # {"code": "MGTFEE", "percent": 1.5, "due": 0, "description": "This is fee 90002"}, + # {"code": "INSURANCE", "percent": 1.5, "due": 30, "description": "This is fee 90003"}, + # {"code": "VAT", "percent": 1.5, "due": 60, "description": "This is fee 90004"}, + # ] + # } + # } # Log Transaction @@ -118,7 +149,7 @@ class ProvideLoanService(BaseService): "transactionId": transaction_id, "customerId": customer_id, "accountId": account_id, - "msisdn": "3451342", + "msisdn": customer.msisdn, "resultCode": "00", "resultDescription": "Successful" } diff --git a/app/api/services/select_offer.py b/app/api/services/select_offer.py index 6a0a914..6f70aeb 100644 --- a/app/api/services/select_offer.py +++ b/app/api/services/select_offer.py @@ -44,7 +44,7 @@ class SelectOfferService(BaseService): offers = [ { - "offerId": "14451", + "offerId": "SAL90", "productId": "2030", "amount": 10000.0, "upfrontPayment": 1000.0, diff --git a/app/models/__init__.py b/app/models/__init__.py index af5353a..1d7fc97 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -3,5 +3,8 @@ from .account import Account from .loan import Loan from .transaction import Transaction from .repayment import Repayment +from .loan_charge import LoanCharge +from .offer import Offer -__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment'] \ No newline at end of file + +__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer'] \ No newline at end of file diff --git a/app/models/account.py b/app/models/account.py index e8a90ce..d136d58 100644 --- a/app/models/account.py +++ b/app/models/account.py @@ -42,7 +42,7 @@ class Account(db.Model): return False if account.lien_amount > 0: return False - return True + return account def __repr__(self): return f'' diff --git a/app/models/customer.py b/app/models/customer.py index 7692f5a..c601efe 100644 --- a/app/models/customer.py +++ b/app/models/customer.py @@ -32,7 +32,7 @@ class Customer(db.Model): customer = cls.query.filter_by(id=customer_id).first() if not customer: return False - return True + return customer @classmethod def create_customer(cls, id, msisdn, country_code, account_id, account_type='savings'): diff --git a/app/models/loan.py b/app/models/loan.py index ab8e89f..6a96b51 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 import Customer +from app.models.loan_charge import LoanCharge class Loan(db.Model): @@ -19,6 +19,7 @@ class Loan(db.Model): transaction_id = 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) collection_type = db.Column(db.String(20), nullable=True) current_loan_amount = db.Column(db.Float, nullable=True) initial_loan_amount = db.Column(db.Float, nullable=False) @@ -36,12 +37,19 @@ class Loan(db.Model): back_populates="loans", ) + loan_charges = relationship( + "LoanCharge", + primaryjoin="Loan.id == LoanCharge.loan_id", + foreign_keys="LoanCharge.loan_id", + back_populates="loan", + ) + @classmethod - def create_loan(cls, customer_id, account_id, offer_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, status='pending'): # Check if customer exists - is_valid = Customer.is_valid_customer(customer_id) - if not is_valid: + customer = Customer.is_valid_customer(customer_id) + if not customer: raise ValueError("Customer does not exist") now = datetime.now(timezone.utc) @@ -51,6 +59,7 @@ class Loan(db.Model): customer_id = customer_id, account_id = account_id, offer_id = offer_id, + product_id = product_id, collection_type = collection_type, transaction_id = transaction_id, initial_loan_amount = initial_loan_amount, @@ -58,7 +67,7 @@ class Loan(db.Model): due_date=now, status = status ) - + try: db.session.add(loan) except IntegrityError as err: diff --git a/app/models/loan_charge.py b/app/models/loan_charge.py new file mode 100644 index 0000000..3e71030 --- /dev/null +++ b/app/models/loan_charge.py @@ -0,0 +1,72 @@ +from datetime import datetime, timezone +from app.extensions import db +from sqlalchemy.orm import relationship + + +class LoanCharge(db.Model): + __tablename__ = 'loan_charges' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + loan_id = db.Column(db.Integer, nullable=False) + code = db.Column(db.String(50), nullable=False) + amount = db.Column(db.Float, default=0.0) + percent = db.Column(db.Float, default=0.0) + description = db.Column(db.Text, nullable=True) + due = db.Column(db.Integer, 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)) + + loan = relationship( + "Loan", + primaryjoin="LoanCharge.loan_id == Loan.id", + foreign_keys=[loan_id], + back_populates="loan_charges", + ) + + @classmethod + def create_charges_for_loan(cls, loan_id, charges): + """ + Create loan charges for a given loan. + + Args: + loan_id (int): ID of the loan to associate charges with. + 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 loan_id is None: + raise ValueError("loan_id cannot be None") + + loan_charges = [] + for charge in charges: + charge_obj = cls( + loan_id=loan_id, + code=charge.get("code"), + amount=charge.get("amount", 0.0), + percent=charge.get("percent", 0.0), + description=charge.get("description", ""), + due=charge.get("due", 0) + ) + db.session.add(charge_obj) + loan_charges.append(charge_obj) + + return loan_charges + + + + def to_dict(self): + return { + 'id': self.id, + 'loanId': self.loan_id, + 'transactionId': self.transaction_id, + 'code': self.code, + 'amount': self.amount, + 'percent': self.percent, + 'description': self.description, + 'due': self.due, + } + + def __repr__(self): + return f"" diff --git a/app/models/offer.py b/app/models/offer.py index ce62fa2..2590f46 100644 --- a/app/models/offer.py +++ b/app/models/offer.py @@ -4,7 +4,7 @@ from app.extensions import db class Offer(db.Model): __tablename__ = 'offers' - id = db.Column(db.Integer, primary_key=True) + id = db.Column(db.String, primary_key=True) product_id = db.Column(db.String, nullable=False) min_amount = db.Column(db.Float, nullable=False) max_amount = db.Column(db.Float, nullable=False) @@ -12,5 +12,34 @@ class Offer(db.Model): 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_offers(cls): + """ + Return all offers in dictionary format. + """ + offers = cls.query.all() + + if not offers: + raise ValueError(f"No available offers") + return offers + + @classmethod + def is_valid_offer(cls, offer_id): + offer = cls.query.filter_by(id=str(offer_id)).first() + + + if not offer: + return False + return offer + + def to_dict(self): + return { + "offerId": self.id, + "productId": self.product_id, + "minAmount": self.min_amount, + "maxAmount": self.max_amount, + "tenor": self.tenor + } + def __repr__(self): return f'' \ No newline at end of file diff --git a/migrations/versions/287ecb02d3d7_migration_on_wed_apr_16_18_35_18_utc_.py b/migrations/versions/287ecb02d3d7_migration_on_wed_apr_16_18_35_18_utc_.py new file mode 100644 index 0000000..583c616 --- /dev/null +++ b/migrations/versions/287ecb02d3d7_migration_on_wed_apr_16_18_35_18_utc_.py @@ -0,0 +1,32 @@ +"""Migration on Wed Apr 16 18:35:18 UTC 2025 + +Revision ID: 287ecb02d3d7 +Revises: a4847b997191 +Create Date: 2025-04-16 18:36:04.632791 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '287ecb02d3d7' +down_revision = 'a4847b997191' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('loan_charges', schema=None) as batch_op: + batch_op.drop_column('transaction_id') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('loan_charges', schema=None) as batch_op: + batch_op.add_column(sa.Column('transaction_id', sa.VARCHAR(length=50), autoincrement=False, nullable=True)) + + # ### end Alembic commands ### diff --git a/migrations/versions/a4847b997191_migration_on_wed_apr_16_17_42_49_utc_.py b/migrations/versions/a4847b997191_migration_on_wed_apr_16_17_42_49_utc_.py new file mode 100644 index 0000000..a41dc5c --- /dev/null +++ b/migrations/versions/a4847b997191_migration_on_wed_apr_16_17_42_49_utc_.py @@ -0,0 +1,57 @@ +"""Migration on Wed Apr 16 17:42:49 UTC 2025 + +Revision ID: a4847b997191 +Revises: 783a023a477f +Create Date: 2025-04-16 17:43:22.509659 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a4847b997191' +down_revision = '783a023a477f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('loan_charges', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('loan_id', sa.Integer(), nullable=False), + sa.Column('transaction_id', sa.String(length=50), nullable=True), + sa.Column('code', sa.String(length=50), nullable=False), + sa.Column('amount', sa.Float(), nullable=True), + sa.Column('percent', sa.Float(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('due', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('offers', + sa.Column('id', sa.String(), nullable=False), + sa.Column('product_id', sa.String(), nullable=False), + sa.Column('min_amount', sa.Float(), nullable=False), + sa.Column('max_amount', sa.Float(), nullable=False), + sa.Column('tenor', sa.Integer(), 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('loans', schema=None) as batch_op: + batch_op.add_column(sa.Column('product_id', sa.String(length=20), 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('product_id') + + op.drop_table('offers') + op.drop_table('loan_charges') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 9d5c945..95d8b87 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,8 @@ flask-swagger-ui python-dotenv # Requests -requests +httpx + # JWT flask-jwt-extended diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 3e73752..9a12075 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -1,8 +1,8 @@ #!/bin/sh -echo "Running DB migrations..." -flask db migrate -m "Migration on $(date)" -flask db upgrade +# echo "Running DB migrations..." +# flask db migrate -m "Migration on $(date)" +# flask db upgrade echo "Starting Gunicorn server..." exec gunicorn -w 4 -b 0.0.0.0:5000 wsgi:wsgi_app