diff --git a/app/api/services/provide_loan.py b/app/api/services/provide_loan.py index aeeb575..b34ccd0 100644 --- a/app/api/services/provide_loan.py +++ b/app/api/services/provide_loan.py @@ -8,7 +8,7 @@ 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 import Loan, Offer +from app.models import Loan, Offer, Charge from app.api.enums import LoanStatus from app.extensions import db @@ -81,62 +81,16 @@ class ProvideLoanService(BaseService): "message": "Failed to save loan details." }), 400 + + charges = Charge.get_offer_charges(offer.id) - 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"}, - ] + logger.error(f"{charges}") + loan_id = loan.id loan_charges = LoanCharge.create_charges_for_loan(loan_id = loan_id, transaction_id = transaction_id, referenced_amount = 800, 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 - # transaction = ProvideLoanService.log_transaction(validated_data = validated_data) - # - # if not transaction: - # logger.error(f"Failed to log transaction") - # return jsonify({ - # "message": "Failed to log transaction." - # }), 400 else: return jsonify({ diff --git a/app/models/__init__.py b/app/models/__init__.py index 1d7fc97..3d16094 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,6 +5,7 @@ from .transaction import Transaction from .repayment import Repayment from .loan_charge import LoanCharge from .offer import Offer +from .charge import Charge -__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer'] \ No newline at end of file +__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer', 'Charge'] \ No newline at end of file diff --git a/app/models/charge.py b/app/models/charge.py new file mode 100644 index 0000000..cd79824 --- /dev/null +++ b/app/models/charge.py @@ -0,0 +1,95 @@ +from datetime import datetime, timezone, timedelta +from app.extensions import db +from sqlalchemy.orm import relationship + + +class Charge(db.Model): + __tablename__ = 'charges' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + offer_id = db.Column(db.String(50), nullable=False) + code = db.Column(db.String(50), nullable=False) + 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)) + + offer = relationship( + "Offer", + primaryjoin="Charge.offer_id == Offer.id", + foreign_keys=[offer_id], + back_populates="charges", + ) + + @classmethod + def add_charges(cls, offer_id, charges): + """ + Add charges to an offer. + + Args: + offer_id (int): ID of the offer 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 offer_id is None: + raise ValueError("offer_id cannot be None") + + offer_charges = [] + + + for charge in charges: + code = charge.get("code") + percent = charge.get("percent", 0.0) + description = charge.get("description", "") + due_days = charge.get("due", 0) + + existing = cls.query.filter_by(offer_id=offer_id, code=code).first() + + if existing: + continue + + charge_obj = cls( + offer_id = offer_id, + code = code, + percent = percent, + description = description, + due = due_days + ) + + db.session.add(charge_obj) + offer_charges.append(charge_obj) + + return offer_charges + + @classmethod + def get_offer_charges(cls, offer_id): + """ + Get all charges for a particular offer as a dictionary + + Args: + offer_id (str): The offer ID. + """ + if not offer_id: + raise ValueError("offer_id not found") + + charges = cls.query.filter_by(offer_id=offer_id).all() + + return charges + + + def to_dict(self): + return { + 'id': self.id, + 'offerId': self.offer_id, + 'code': self.code, + 'percent': self.percent, + 'description': self.description, + 'due': self.due + } + + def __repr__(self): + return f"" diff --git a/app/models/loan_charge.py b/app/models/loan_charge.py index a9ed283..5f7787e 100644 --- a/app/models/loan_charge.py +++ b/app/models/loan_charge.py @@ -45,9 +45,9 @@ class LoanCharge(db.Model): now = datetime.now(timezone.utc) for charge in charges: - due_days = charge.get("due", 0) - amount = charge.get("amount", 0.0) - percent = charge.get("percent", 0.0) + 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 @@ -55,10 +55,10 @@ class LoanCharge(db.Model): charge_obj = cls( loan_id = loan_id, transaction_id = transaction_id, - code = charge.get("code"), + code = getattr(charge, "code"), amount = amount, percent = percent, - description = charge.get("description", ""), + description = getattr(charge, "description", ""), due = due_days, due_date = now + timedelta(days=due_days) ) diff --git a/app/models/offer.py b/app/models/offer.py index 2590f46..b51dc25 100644 --- a/app/models/offer.py +++ b/app/models/offer.py @@ -1,5 +1,7 @@ from datetime import datetime, timezone from app.extensions import db +from app.models.charge import Charge +from sqlalchemy.orm import relationship class Offer(db.Model): __tablename__ = 'offers' @@ -12,6 +14,13 @@ 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)) + charges = relationship( + "Charge", + primaryjoin="Offer.id == Charge.offer_id", + foreign_keys="Charge.offer_id", + back_populates="offer", + ) + @classmethod def get_all_offers(cls): """ diff --git a/migrations/versions/de9ad96ba34e_migration_on_thu_apr_17_14_15_36_utc_.py b/migrations/versions/de9ad96ba34e_migration_on_thu_apr_17_14_15_36_utc_.py new file mode 100644 index 0000000..a4f4276 --- /dev/null +++ b/migrations/versions/de9ad96ba34e_migration_on_thu_apr_17_14_15_36_utc_.py @@ -0,0 +1,38 @@ +"""Migration on Thu Apr 17 14:15:36 UTC 2025 + +Revision ID: de9ad96ba34e +Revises: ec8d97f9b584 +Create Date: 2025-04-17 14:16:16.537466 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'de9ad96ba34e' +down_revision = 'ec8d97f9b584' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('charges', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('offer_id', sa.String(length=50), nullable=False), + sa.Column('code', sa.String(length=50), nullable=False), + 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') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('charges') + # ### end Alembic commands ### diff --git a/tests/jmeter/digifi-BankToProductCore.jmx b/tests/jmeter/digifi-BankToProductCore.jmx new file mode 100644 index 0000000..6722498 --- /dev/null +++ b/tests/jmeter/digifi-BankToProductCore.jmx @@ -0,0 +1,651 @@ + + + + + true + + + + baseUrl + http://localhost:4500 + = + + + username + user + = + + + password + password + = + + + + false + false + + + + 1 + 1 + true + stopthread + + 1 + false + + + + + localhost + 4500 + http + /Authorize + true + POST + true + true + + + + false + { + "username":"${username}", + "password":"${password}" +} + = + + + + + + + + + Content-Type + application/json + + + Accept + application/json + + + + + + access_token + $.data.access_token + 1 + NOT_FOUND + variable + + all + + + + refresh_token + $.data.refresh_token + 1 + NOT_FOUND + variable + + all + + + + true + + + props.put("GLOBAL_ACCESS_TOKEN", vars.get("access_token")); +props.put("GLOBAL_REFRESH_TOKEN", vars.get("refresh_token")); + groovy + + + + + + 200 + + Assertion.response_code + false + 16 + + + + + + false + true + false + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + true + + + + + + + + 1 + 1 + true + continue + + 1 + false + + + + + localhost + 4500 + http + /AuthorizeRefresh + true + POST + true + true + + + + false + { + +} + + = + + + + + + + + + Content-Type + application/json + + + Accept + application/json + + + Authorization + Bearer ${__P(GLOBAL_REFRESH_TOKEN)} + + + + + + + + 200 + + Assertion.response_code + false + 16 + + + + + groovy + + + true + // Generate random IDs and store them in JMeter variables +def transactionId = "TR" + org.apache.commons.lang3.RandomStringUtils.randomAlphanumeric(12) +def customerId = "CN" + org.apache.commons.lang3.RandomUtils.nextInt(1000000, 9999999) +def accountId = "ACN" + org.apache.commons.lang3.RandomUtils.nextInt(1000000, 9999999) +def msisdn = "809" + org.apache.commons.lang3.RandomUtils.nextInt(1000000, 9999999) + +// Generate requestId: current timestamp + 6-digit random number +def timestamp = new Date().format("yyyyMMddHHmmssSSS") // e.g., 20250414161243123 +def randomSuffix = org.apache.commons.lang3.RandomStringUtils.randomNumeric(6) +def requestId = timestamp + randomSuffix + +vars.put("transactionId", transactionId) +vars.put("customerId", customerId.toString()) +vars.put("accountId", accountId.toString()) +vars.put("msisdn", msisdn.toString()) +vars.put("requestId", requestId) + + + + + + localhost + 4500 + http + /EligibilityCheck + true + POST + true + true + + + + false + { + "transactionId":"${transactionId}", + "countryCode":"NGR", + "customerId":"${customerId}", + "msisdn":"${msisdn}", + "channel":"100", + "accountId":"${accountId}" +} + = + + + + + + + + + Content-Type + application/json + + + Accept + application/json + + + Authorization + Bearer ${__P(GLOBAL_ACCESS_TOKEN)} + + + + + + productId + $.eligibleOffers[0].productId + 0 + NOT_FOUND + variable + + + + + + 200 + + Assertion.response_code + false + 16 + + + + + + localhost + 4500 + http + /SelectOffer + true + POST + true + true + + + + false + { + "requestId": "${requestId}", + "transactionId": "${transactionId}", + "customerId":"${customerId}", + "msisdn": "${msisdn}", + "requestedAmount": ${__Random(500000,1000000,)}.00, + "accountId":"${accountId}", + "productId": "${productId}", + "channel": "100" +} + = + + + + + + + + + Content-Type + application/json + + + Accept + application/json + + + Authorization + Bearer ${__P(GLOBAL_ACCESS_TOKEN)} + + + + + + offerId + $.loan[0].offerId + 0 + variable + NOT_FOUND + all + + + + amount + $.loan[0].amount + 0 + 800 + variable + all + + + + + + 200 + + Assertion.response_code + false + 16 + + + + + + localhost + 4500 + http + /ProvideLoan + true + POST + true + true + + + + false + { + "requestId":"${requestId}", + "transactionId":"${transactionId}", + "customerId":"${customerId}", + "accountId":"${accountId}", + "msisdn":"${msisdn}", + "requestedAmount":${amount}, + "collectionType":1, + "offerId":"${offerId}", + "channel":"100" +} + = + + + + + + + + + Content-Type + application/json + + + Accept + application/json + + + Authorization + Bearer ${__P(GLOBAL_ACCESS_TOKEN)} + + + + + + + + 200 + + Assertion.response_code + false + 16 + + + + + + localhost + 4500 + http + /LoanStatus + true + POST + true + true + + + + false + { + "transactionId":"${transactionId}", + "customerId":"${customerId}", + "msisdn":"${msisdn}", + "channel":"100", + "accountId":"${accountId}" +} + = + + + + + + + + + Content-Type + application/json + + + Accept + application/json + + + Authorization + Bearer ${__P(GLOBAL_ACCESS_TOKEN)} + + + + + + debtId + $.loans[0].debtId + 0 + 800 + variable + all + + + + + + 200 + + Assertion.response_code + false + 16 + + + + + + localhost + 4500 + http + /Repayment + true + POST + true + true + + + + false + { + "debtId":"${debtId}", + "transactionId":"${transactionId}", + "customerId":"${customerId}", + "msisdn":"${msisdn}", + "channel":"100", + "accountId":"${accountId}", + "productId": "${productId}" +} + = + + + + + + + + + Content-Type + application/json + + + Accept + application/json + + + Authorization + Bearer ${__P(GLOBAL_ACCESS_TOKEN)} + + + + + + + + 200 + + Assertion.response_code + false + 16 + + + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + true + + + + + + + false + true + false + + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + true + + + + + + + +