commit af9a6148a1bad5885c2c5c355fbf9cd8534c1069 Author: Azeez Muibi Date: Thu Mar 20 13:35:44 2025 +0100 Initial commit diff --git a/.env b/.env new file mode 100644 index 0000000..bb2ec8f --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +DEBUG=True +PORT=5000 +API_USERNAME=admin +API_PASSWORD=password +SIMBRELLA_APP_ID=your_app_id +SIMBRELLA_API_KEY=your_api_key \ No newline at end of file diff --git a/.idea/FirstBankSimbrellaApi.iml b/.idea/FirstBankSimbrellaApi.iml new file mode 100644 index 0000000..1dceaec --- /dev/null +++ b/.idea/FirstBankSimbrellaApi.iml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b1b05a8 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..0d1ba17 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..57cd243 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + "associatedIndex": 8 +} + + + + + + + { + "keyToString": { + "Flask server.FirstBankSimbrellaApi.executor": "Run", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.git.unshallow": "true", + "git-widget-placeholder": "master", + "last_opened_file_path": "C:/Users/amuibi/PycharmProjects/FirstBankSimbrellaApi", + "node.js.detected.package.eslint": "true", + "node.js.detected.package.tslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "vue.rearranger.settings.migration": "true" + } +} + + + + + + + + + + + + + + + + + + + + + + + 1742456795887 + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e67cc9 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Simbrella FirstAdvance API Flask Implementation + +This project implements the Simbrella FirstAdvance API as defined in the OpenAPI 3.0 specification. + +## Features + +- Complete implementation of all API endpoints +- Authentication middleware for both Basic Auth and API Key auth +- Request/response validation +- Comprehensive error handling +- Logging + +## Setup + +1. Clone the repository +2. Create a virtual environment: \ No newline at end of file diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000..352eec1 Binary files /dev/null and b/__pycache__/app.cpython-313.pyc differ diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..82b34d3 Binary files /dev/null and b/__pycache__/config.cpython-313.pyc differ diff --git a/api/__pycache__/middleware.cpython-313.pyc b/api/__pycache__/middleware.cpython-313.pyc new file mode 100644 index 0000000..bcf09c8 Binary files /dev/null and b/api/__pycache__/middleware.cpython-313.pyc differ diff --git a/api/__pycache__/models.cpython-313.pyc b/api/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..c25b24e Binary files /dev/null and b/api/__pycache__/models.cpython-313.pyc differ diff --git a/api/__pycache__/routes.cpython-313.pyc b/api/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000..9e3aea0 Binary files /dev/null and b/api/__pycache__/routes.cpython-313.pyc differ diff --git a/api/controllers/__pycache__/collection.cpython-313.pyc b/api/controllers/__pycache__/collection.cpython-313.pyc new file mode 100644 index 0000000..bacad77 Binary files /dev/null and b/api/controllers/__pycache__/collection.cpython-313.pyc differ diff --git a/api/controllers/__pycache__/consent.cpython-313.pyc b/api/controllers/__pycache__/consent.cpython-313.pyc new file mode 100644 index 0000000..0519532 Binary files /dev/null and b/api/controllers/__pycache__/consent.cpython-313.pyc differ diff --git a/api/controllers/__pycache__/disbursement.cpython-313.pyc b/api/controllers/__pycache__/disbursement.cpython-313.pyc new file mode 100644 index 0000000..e39ea38 Binary files /dev/null and b/api/controllers/__pycache__/disbursement.cpython-313.pyc differ diff --git a/api/controllers/__pycache__/eligibility.cpython-313.pyc b/api/controllers/__pycache__/eligibility.cpython-313.pyc new file mode 100644 index 0000000..617f47b Binary files /dev/null and b/api/controllers/__pycache__/eligibility.cpython-313.pyc differ diff --git a/api/controllers/__pycache__/lien.cpython-313.pyc b/api/controllers/__pycache__/lien.cpython-313.pyc new file mode 100644 index 0000000..5a7f18f Binary files /dev/null and b/api/controllers/__pycache__/lien.cpython-313.pyc differ diff --git a/api/controllers/__pycache__/loan.cpython-313.pyc b/api/controllers/__pycache__/loan.cpython-313.pyc new file mode 100644 index 0000000..90b24f0 Binary files /dev/null and b/api/controllers/__pycache__/loan.cpython-313.pyc differ diff --git a/api/controllers/__pycache__/notification.cpython-313.pyc b/api/controllers/__pycache__/notification.cpython-313.pyc new file mode 100644 index 0000000..2b28ce2 Binary files /dev/null and b/api/controllers/__pycache__/notification.cpython-313.pyc differ diff --git a/api/controllers/__pycache__/offers.cpython-313.pyc b/api/controllers/__pycache__/offers.cpython-313.pyc new file mode 100644 index 0000000..5a5fcda Binary files /dev/null and b/api/controllers/__pycache__/offers.cpython-313.pyc differ diff --git a/api/controllers/__pycache__/penal.cpython-313.pyc b/api/controllers/__pycache__/penal.cpython-313.pyc new file mode 100644 index 0000000..3ae8699 Binary files /dev/null and b/api/controllers/__pycache__/penal.cpython-313.pyc differ diff --git a/api/controllers/__pycache__/rac.cpython-313.pyc b/api/controllers/__pycache__/rac.cpython-313.pyc new file mode 100644 index 0000000..f9f33ee Binary files /dev/null and b/api/controllers/__pycache__/rac.cpython-313.pyc differ diff --git a/api/controllers/__pycache__/repayment.cpython-313.pyc b/api/controllers/__pycache__/repayment.cpython-313.pyc new file mode 100644 index 0000000..48d7a04 Binary files /dev/null and b/api/controllers/__pycache__/repayment.cpython-313.pyc differ diff --git a/api/controllers/__pycache__/sms.cpython-313.pyc b/api/controllers/__pycache__/sms.cpython-313.pyc new file mode 100644 index 0000000..de78dcc Binary files /dev/null and b/api/controllers/__pycache__/sms.cpython-313.pyc differ diff --git a/api/controllers/__pycache__/token.cpython-313.pyc b/api/controllers/__pycache__/token.cpython-313.pyc new file mode 100644 index 0000000..88cacf5 Binary files /dev/null and b/api/controllers/__pycache__/token.cpython-313.pyc differ diff --git a/api/controllers/__pycache__/transaction.cpython-313.pyc b/api/controllers/__pycache__/transaction.cpython-313.pyc new file mode 100644 index 0000000..fb4023b Binary files /dev/null and b/api/controllers/__pycache__/transaction.cpython-313.pyc differ diff --git a/api/controllers/collection.py b/api/controllers/collection.py new file mode 100644 index 0000000..743afc1 --- /dev/null +++ b/api/controllers/collection.py @@ -0,0 +1,81 @@ +""" +Controller for loan collection endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import api_key_required +from api.models import CollectLoanRequest, CollectLoanResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +collection_bp = Blueprint('collection', __name__) + +@collection_bp.route('/CollectLoan', methods=['POST']) +@api_key_required +def collect_loan(): + """ + Endpoint to process loan collection requests from Simbrella. + + This method handles requests to collect money from user accounts. + When a request is received, FirstBank should check all user accounts + and collect as much money as possible to cover the existing loan + either partially or fully. + + Returns: + JSON response with collection status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['transactionId', 'fbnTransactionId', 'debtId', 'customerId', + 'accountId', 'productId', 'collectAmount', 'collectionMethod', + 'lienAmount', 'countryId'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = CollectLoanRequest.from_dict(data) + + # Process collection request (this would connect to the business logic) + # For demonstration, we'll return a mock response with partial collection + + # Assume we collected 75% of the requested amount + collected_amount = req.collectAmount * 0.75 + remaining_lien = req.lienAmount - collected_amount + + # Create response + response = CollectLoanResponse( + transactionId=req.transactionId, + debtId=req.debtId, + customerId=req.customerId, + accountId=req.accountId, + productId=req.productId, + collectAmount=collected_amount, + lienAmount=remaining_lien, + resultCode="00", + resultDescription="Loan Collection Successful", + penalCharge=req.penalCharge if hasattr(req, 'penalCharge') else 0.0 + ) + + logger.info(f"Processed collection for customer {req.customerId}, collected {collected_amount}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing collection: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/consent.py b/api/controllers/consent.py new file mode 100644 index 0000000..27d8d2e --- /dev/null +++ b/api/controllers/consent.py @@ -0,0 +1,123 @@ +""" +Controller for customer consent endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import basic_auth_required, api_key_required +from api.models import ( + CustomerConsentRequest, CustomerConsentResponse, + RevokeEnableConsentRequest, RevokeEnableConsentResponse +) +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +consent_bp = Blueprint('consent', __name__) + +@consent_bp.route('/CustomerConsent', methods=['POST']) +@basic_auth_required +def customer_consent(): + """ + Endpoint to process customer consent requests. + + This method handles customer consent for loan services. + + Returns: + JSON response with consent status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['$type', 'transactionId', 'customerId', 'accountId', + 'requestTime', 'consentType', 'channel'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = CustomerConsentRequest.from_dict(data) + + # Process consent request (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = CustomerConsentResponse( + resultCode="00", + resultDescription="Request is received" + ) + + logger.info(f"Processed consent request for customer {req.customerId}, type {req.consentType}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing consent request: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 + +@consent_bp.route('/RevokeEnableConsent', methods=['POST']) +@api_key_required +def revoke_enable_consent(): + """ + Endpoint to process consent revocation or enablement. + + This method handles requests from Simbrella to revoke or enable customer consent. + + Returns: + JSON response with operation status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['transactionId', 'fbnTransactionId', 'customerId', 'accountId', + 'processTime', 'consentType', 'countryId'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = RevokeEnableConsentRequest.from_dict(data) + + # Process revoke/enable consent request (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = RevokeEnableConsentResponse( + type="RevokeEnableConsentResponse", + customerId=req.customerId, + accountId=req.accountId, + resultCode="00", + resultDescription="Success" + ) + + logger.info(f"Processed revoke/enable consent for customer {req.customerId}, type {req.consentType}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing revoke/enable consent: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/disbursement.py b/api/controllers/disbursement.py new file mode 100644 index 0000000..6dbc8b2 --- /dev/null +++ b/api/controllers/disbursement.py @@ -0,0 +1,78 @@ +""" +Controller for loan disbursement endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import api_key_required +from api.models import DisbursementRequest, DisbursementResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +disbursement_bp = Blueprint('disbursement', __name__) + +@disbursement_bp.route('/Disbursement', methods=['POST']) +@api_key_required +def disbursement(): + """ + Endpoint to process loan disbursement requests from Simbrella. + + This method handles requests to disburse loans to customer accounts. + The operation should be executed atomically, providing the loan and + collecting upfront fees within the same transaction. + + Returns: + JSON response with disbursement status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['requestId', 'debtId', 'transactionId', 'customerId', + 'accountId', 'productId', 'provideAmount', 'countryId'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = DisbursementRequest.from_dict(data) + + # Process disbursement request (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = DisbursementResponse( + requestId=req.requestId, + debtId=req.debtId, + transactionId=req.transactionId, + customerId=req.customerId, + accountId=req.accountId, + productId=req.productId, + provideAmount=req.provideAmount, + resultCode="00", + resultDescription="Loan Request Completed Successfully!", + collectAmountInterest=req.collectAmountInterest, + collectAmountMgtFee=req.collectAmountMgtFee, + collectAmountInsurance=req.collectAmountInsurance, + collectAmountVAT=req.collectAmountVAT + ) + + logger.info(f"Processed disbursement for customer {req.customerId}, amount {req.provideAmount}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing disbursement: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/eligibility.py b/api/controllers/eligibility.py new file mode 100644 index 0000000..20e3ac2 --- /dev/null +++ b/api/controllers/eligibility.py @@ -0,0 +1,91 @@ +""" +Controller for eligibility check endpoints. +""" +from flask import Blueprint, request, jsonify, current_app +from flask.typing import ResponseReturnValue +from api.middleware import basic_auth_required +from api.models import EligibilityCheckRequest, EligibilityCheckResponse, EligibleOffer +import logging +from typing import Dict, Any, List + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +eligibility_bp = Blueprint('eligibility', __name__) + +@eligibility_bp.route('/EligibilityCheck', methods=['POST']) +@basic_auth_required +def eligibility_check() -> ResponseReturnValue: + """ + Endpoint to check customer eligibility for loans. + + This endpoint initiates the eligibility check process and performs RAC checks. + + Returns: + JSON response with eligibility status and available offers + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + logger.warning("Invalid JSON payload received") + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['$type', 'transactionId', 'countryCode', 'customerId', + 'accountId', 'lienAmount', 'channel'] + missing_fields = [field for field in required_fields if field not in data] + if missing_fields: + logger.warning(f"Missing required fields: {', '.join(missing_fields)}") + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required fields: {", ".join(missing_fields)}' + }), 422 + + # Create request model + req = EligibilityCheckRequest.from_dict(data) + + # Process eligibility check (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create sample offers + offers: List[EligibleOffer] = [ + EligibleOffer( + minamount=5000.0, + maxamount=20000.0, + productId=101, + offerid=101, + Tenor=30 + ), + EligibleOffer( + minamount=10000.0, + maxamount=50000.0, + productId=102, + offerid=102, + Tenor=60 + ) + ] + + # Create response + response = EligibilityCheckResponse( + customerId=req.customerId, + transactionId=req.transactionId, + eligibleOffers=[offer.to_dict() for offer in offers], + resultCode="00", + resultDescription="Successful", + msisdn=req.msisdn + ) + + logger.info(f"Processed eligibility check for customer {req.customerId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.exception(f"Error processing eligibility check: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': f'Internal server error: {str(e)}' + }), 500 \ No newline at end of file diff --git a/api/controllers/lien.py b/api/controllers/lien.py new file mode 100644 index 0000000..dae3ca0 --- /dev/null +++ b/api/controllers/lien.py @@ -0,0 +1,65 @@ +""" +Controller for lien check endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import api_key_required +from api.models import LienCheckRequest, LienCheckResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +lien_bp = Blueprint('lien', __name__) + +@lien_bp.route('/LienCheck', methods=['POST']) +@api_key_required +def lien_check(): + """ + Endpoint to check lien amount on an account. + + This method is used to get the applied lien amount for a specific account. + + Returns: + JSON response with lien amount details + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['transactionId', 'customerId', 'accountId', 'countryId'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = LienCheckRequest.from_dict(data) + + # Process lien check (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = LienCheckResponse( + lienAmount=20000.0, + resultCode="00", + resultDescription="Successful" + ) + + logger.info(f"Processed lien check for customer {req.customerId}, account {req.accountId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing lien check: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/loan.py b/api/controllers/loan.py new file mode 100644 index 0000000..7a7d57e --- /dev/null +++ b/api/controllers/loan.py @@ -0,0 +1,147 @@ +""" +Controller for loan-related endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import basic_auth_required +from api.models import ( + ProvideLoanRequest, ProvideLoanResponse, + LoanInformationRequest, LoanInformationResponse, Loan +) +from datetime import datetime, timedelta +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +loan_bp = Blueprint('loan', __name__) + +@loan_bp.route('/ProvideLoan', methods=['POST']) +@basic_auth_required +def provide_loan(): + """ + Endpoint to process loan provision requests. + + This method handles the request to provide a loan to a customer. + + Returns: + JSON response with loan provision status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['$type', 'requestId', 'transactionId', 'customerId', + 'accountId', 'productId', 'lienAmount', 'requestedAmount', + 'collectionType', 'loanType', 'channel'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = ProvideLoanRequest.from_dict(data) + + # Process loan provision (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = ProvideLoanResponse( + requestId=req.requestId, + transactionId=req.transactionId, + customerId=req.customerId, + accountId=req.accountId, + resultCode="00", + resultDescription="Loan provided successfully", + msisdn=req.msisdn if hasattr(req, 'msisdn') else None + ) + + logger.info(f"Processed loan provision for customer {req.customerId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing loan provision: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 + +@loan_bp.route('/LoanInformation', methods=['POST']) +@basic_auth_required +def loan_information(): + """ + Endpoint to retrieve loan information. + + This method provides information about a customer's existing loans. + + Returns: + JSON response with loan information + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['$type', 'transactionId', 'customerId', 'channel'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = LoanInformationRequest.from_dict(data) + + # Process loan information request (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create sample loans + now = datetime.now() + loan_date = now - timedelta(days=15) + due_date = now + timedelta(days=15) + + loans = [ + Loan( + debtId="123456789", + loanDate=loan_date.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3], + dueDate=due_date.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3], + currentLoanAmount=8500.0, + initialLoanAmount=10000.0, + defaultFee=0.0, + continiousFee=0.0, + productId="101" + ) + ] + + # Create response + response = LoanInformationResponse( + customerId=req.customerId, + loans=[loan.to_dict() for loan in loans], + resultCode="00", + resultDescription="Successful", + totalDebtAmount=8500.0 + ) + + logger.info(f"Processed loan information request for customer {req.customerId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing loan information request: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/notification.py b/api/controllers/notification.py new file mode 100644 index 0000000..4e812c1 --- /dev/null +++ b/api/controllers/notification.py @@ -0,0 +1,70 @@ +""" +Controller for notification callback endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import basic_auth_required +from api.models import NotificationCallbackRequest, NotificationCallbackResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +notification_bp = Blueprint('notification', __name__) + +@notification_bp.route('/NotificationCallback', methods=['POST']) +@basic_auth_required +def notification_callback(): + """ + Endpoint to receive transaction status notifications. + + This method is used for informing Simbrella about the status of transactions + that FirstBank has processed. + + Returns: + JSON response acknowledging receipt of notification + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['fbnTransactionId', 'transactionId', 'customerId', 'accountId', + 'debtId', 'transactionType', 'amountProvided', 'amountCollected', + 'responseCode', 'responseDescription'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = NotificationCallbackRequest.from_dict(data) + + # Process notification (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Log the notification details + logger.info(f"Received notification for transaction {req.transactionId}, " + f"type {req.transactionType}, status {req.responseCode}") + + # Create response + response = NotificationCallbackResponse( + resultCode="00", + resultDescription="Successful" + ) + + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing notification callback: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/offers.py b/api/controllers/offers.py new file mode 100644 index 0000000..20fee3a --- /dev/null +++ b/api/controllers/offers.py @@ -0,0 +1,92 @@ +""" +Controller for offer selection endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import basic_auth_required +from api.models import SelectOffersRequest, SelectOffersResponse, Offer +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +offers_bp = Blueprint('offers', __name__) + +@offers_bp.route('/SelectOffer', methods=['POST']) +@basic_auth_required +def select_offer(): + """ + Endpoint to send the offer the customer selected to Simbrella. + + This method processes the customer's selected offer and returns detailed offer information. + + Returns: + JSON response with detailed offer information + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['requestId', 'transactionId', 'customerId', 'accountId', + 'msisdn', 'requestedAmount', 'productid', 'channel'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = SelectOffersRequest.from_dict(data) + + # Process offer selection (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create sample offers + offers = [ + Offer( + offerId="14451", + productId=req.productid, + amount=req.requestedAmount, + upfrontPayment=req.requestedAmount * 0.1, # 10% upfront + interestRate=3.0, + Interest=req.requestedAmount * 0.03, # 3% interest + ManagementRate=1.0, + ManagementFee=req.requestedAmount * 0.01, # 1% management fee + InsuranceRate=1.0, + InsuranceFee=req.requestedAmount * 0.01, # 1% insurance + VATRate=7.5, + VATamount=(req.requestedAmount * 0.01) * 0.075, # VAT on management fee + recommendedRepaymentDates=["2023-04-30", "2023-05-30", "2023-06-29"], + installmentAmount=req.requestedAmount * 1.05 / 3, # Split into 3 payments with 5% total fees + totalRepaymentAmount=req.requestedAmount * 1.05 # Total with 5% fees + ) + ] + + # Create response + response = SelectOffersResponse( + requestId=req.requestId, + transactionId=req.transactionId, + customerId=req.customerId, + accountId=req.accountId, + offers=[offer.to_dict() for offer in offers], + resultCode="00", + resultDescription="Successful", + outstandingDebtAmount=0.0 + ) + + logger.info(f"Processed offer selection for customer {req.customerId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing offer selection: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/penal.py b/api/controllers/penal.py new file mode 100644 index 0000000..a9321ba --- /dev/null +++ b/api/controllers/penal.py @@ -0,0 +1,67 @@ +""" +Controller for penal charge endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import api_key_required +from api.models import PenalChargeRequest, PenalChargeResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +penal_bp = Blueprint('penal', __name__) + +@penal_bp.route('/PenalCharge', methods=['POST']) +@api_key_required +def penal_charge(): + """ + Endpoint to process penalty charge requests. + + This method handles requests to charge customers for penalties + as per existing debt. Results of these requests will be received + from the NotificationCallback endpoint. + + Returns: + JSON response acknowledging the penalty charge request + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['transactionId', 'fbnTransactionId', 'debtId', 'customerId', + 'accountId', 'penalCharge', 'lienAmount', 'countryId'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = PenalChargeRequest.from_dict(data) + + # Process penal charge request (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = PenalChargeResponse( + resultCode="00", + resultDescription="Penal charge debited successfully" + ) + + logger.info(f"Processed penal charge for customer {req.customerId}, amount {req.penalCharge}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing penal charge: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/rac.py b/api/controllers/rac.py new file mode 100644 index 0000000..8cc45c6 --- /dev/null +++ b/api/controllers/rac.py @@ -0,0 +1,81 @@ +""" +Controller for Risk Acceptance Criteria check endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import api_key_required +from api.models import RACCheckRequest, RACCheckResponse, RACResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +rac_bp = Blueprint('rac', __name__) + +@rac_bp.route('/RACCheck', methods=['POST']) +@api_key_required +def rac_check(): + """ + Endpoint to check if a customer passes the Risk Acceptance Criteria. + + This method evaluates a customer against the bank's risk criteria. + + Returns: + JSON response with RAC check results + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['transactionId', 'fbnTransactionId', 'customerId', + 'accountId', 'RAC_Array'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = RACCheckRequest.from_dict(data) + + # Process RAC check (this would connect to the business logic) + # For demonstration, we'll return a mock response with all checks passing + + # Create RAC response object + rac_response = RACResponse( + SalaryAccount="1", + BVN="1", + BVNAttachedtoAccount="1", + CRMS="1", + CRC="1", + AccountStatus="1", + Lien="1", + NoBounchedCheck="1", + Whitelist="1", + NoPastDueSalaryLoan="1", + NoPastDueOtherLoan="1" + ) + + # Create response + response = RACCheckResponse( + RACResponse=rac_response.to_dict(), + resultCode="00", + resultDescription="RAC Check Successful" + ) + + logger.info(f"Processed RAC check for customer {req.customerId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing RAC check: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/repayment.py b/api/controllers/repayment.py new file mode 100644 index 0000000..7dbffd8 --- /dev/null +++ b/api/controllers/repayment.py @@ -0,0 +1,68 @@ +""" +Controller for repayment endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import basic_auth_required +from api.models import RepaymentRequest, RepaymentResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +repayment_bp = Blueprint('repayment', __name__) + +@repayment_bp.route('/Repayment', methods=['POST']) +@basic_auth_required +def repayment(): + """ + Endpoint to process loan repayment requests. + + This method handles customer repayment of loans. + + Returns: + JSON response with repayment status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['$type', 'transactionId', 'customerId', 'debtId', + 'productId', 'channel'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = RepaymentRequest.from_dict(data) + + # Process repayment request (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = RepaymentResponse( + customerId=req.customerId, + productId=req.productId, + debtId=req.debtId, + resultCode="00", + resultDescription="Repayment processed successfully" + ) + + logger.info(f"Processed repayment for customer {req.customerId}, debt {req.debtId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing repayment: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/sms.py b/api/controllers/sms.py new file mode 100644 index 0000000..580a5ff --- /dev/null +++ b/api/controllers/sms.py @@ -0,0 +1,132 @@ +""" +Controller for SMS notification endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import api_key_required +from api.models import SMSRequest, SMSResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +sms_bp = Blueprint('sms', __name__) + +@sms_bp.route('/SMS', methods=['POST']) +@api_key_required +def send_sms(): + """ + Endpoint to send SMS notifications. + + This method handles requests to send SMS messages to customers. + + Returns: + JSON response with SMS sending status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['text', 'dest', 'unicode'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = SMSRequest.from_dict(data) + + # Process SMS request (this would connect to your business logic) + # For demonstration, we'll return a mock response + + # Create response + response = SMSResponse( + data="", + statusCode=200, + IsSuccessful=True, + errorMessage=None + ) + + logger.info(f"Processed SMS request to {req.dest}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing SMS request: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 + +@sms_bp.route('/BulkSMS', methods=['POST']) +@api_key_required +def send_bulk_sms(): + """ + Endpoint to send bulk SMS notifications. + + This method handles requests to send multiple SMS messages (up to 20) + in a single request. + + Returns: + JSON response with bulk SMS sending status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate that data is an array + if not isinstance(data, list): + return jsonify({ + 'resultCode': '422', + 'resultDescription': 'Request must be an array of SMS messages' + }), 422 + + # Validate array length + if len(data) > 20: + return jsonify({ + 'resultCode': '422', + 'resultDescription': 'Maximum of 20 SMS messages allowed per request' + }), 422 + + # Validate each SMS in the array + for i, sms in enumerate(data): + required_fields = ['text', 'dest', 'unicode'] + for field in required_fields: + if field not in sms: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field} in SMS at index {i}' + }), 422 + + # Process bulk SMS request (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response + response = SMSResponse( + data="", + statusCode=200, + IsSuccessful=True, + errorMessage=None + ) + + logger.info(f"Processed bulk SMS request with {len(data)} messages") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing bulk SMS request: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/token.py b/api/controllers/token.py new file mode 100644 index 0000000..2787dd8 --- /dev/null +++ b/api/controllers/token.py @@ -0,0 +1,68 @@ +""" +Controller for token validation endpoints. +""" +from flask import Blueprint, request, jsonify +from api.middleware import api_key_required +from api.models import ValidateTokenRequest, ValidateTokenResponse +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +token_bp = Blueprint('token', __name__) + +@token_bp.route('/ValidateToken', methods=['POST']) +@api_key_required +def validate_token(): + """ + Endpoint to validate user authentication tokens. + + This method is used when users from FirstBank access the Customer Care Portal. + It validates the soft/hard token code entered by the user. + + Returns: + JSON response with token validation results + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['RequestId', 'UserId', 'CountryId', 'TokenCode'] + for field in required_fields: + if field not in data: + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required field: {field}' + }), 422 + + # Create request model + req = ValidateTokenRequest.from_dict(data) + + # Process token validation (this would connect to the business logic) + # For demonstration, we'll return a mock response with successful validation + + # Create response + response = ValidateTokenResponse( + Authenticated=True, + AuthenticatedMessage=f"The user with ID {req.UserId} has successfully authenticated!", + ResponseCode="00", + ResponseMessage="Successful", + RequestId=req.RequestId + ) + + logger.info(f"Processed token validation for user {req.UserId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.error(f"Error processing token validation: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': 'Internal server error' + }), 500 \ No newline at end of file diff --git a/api/controllers/transaction.py b/api/controllers/transaction.py new file mode 100644 index 0000000..13bca95 --- /dev/null +++ b/api/controllers/transaction.py @@ -0,0 +1,169 @@ +""" +Controller for transaction check endpoints. +""" +from flask import Blueprint, request, jsonify +from flask.typing import ResponseReturnValue +from api.middleware import api_key_required +from api.models import ( + TransactionCheckRequest, TransactionCheckResponse, + NewTransactionCheckRequest, NewTransactionCheckResponse, + TransactionData +) +import logging +from typing import Dict, Any + +# Configure logger +logger = logging.getLogger(__name__) + +# Create blueprint +transaction_bp = Blueprint('transaction', __name__) + +@transaction_bp.route('/TransactionCheck', methods=['POST']) +@api_key_required +def transaction_check() -> ResponseReturnValue: + """ + Endpoint to check transaction status. + + This method is used to double-check the response received from DisburseLoan + and CollectLoan Synchronous APIs. It verifies transaction results on FirstBank. + + Returns: + JSON response with transaction status details + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + logger.warning("Invalid JSON payload received") + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['counter', 'TransactionId', 'requestID', 'customerId', + 'accountId', 'countryId', 'transactionType'] + missing_fields = [field for field in required_fields if field not in data] + if missing_fields: + logger.warning(f"Missing required fields: {', '.join(missing_fields)}") + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required fields: {", ".join(missing_fields)}' + }), 422 + + # Create request model + req = TransactionCheckRequest.from_dict(data) + + # Process transaction check (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create response based on transaction type + provided_amount = 0.0 + collected_amount = 0.0 + + if req.transactionType == "Disbursement": + provided_amount = 10000.0 + elif req.transactionType == "Collection" or req.transactionType == "Penalty": + collected_amount = 7.50 + + # Create response + response = TransactionCheckResponse( + type_field="TransactionCheckResponse", # This will be converted to $type in JSON + nativeId=f"FBN20191031104405{req.customerId}", + customerId=req.customerId, + accountId=req.accountId, + providedAmount=provided_amount, + collectedAmount=collected_amount, + resultCode="00", + resultDescription=f"{req.transactionType} Status retrieved successfully." + ) + + logger.info(f"Processed transaction check for {req.transactionType}, ID {req.TransactionId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.exception(f"Error processing transaction check: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': f'Internal server error: {str(e)}' + }), 500 + +@transaction_bp.route('/NewTransactionCheck', methods=['POST']) +@api_key_required +def new_transaction_check() -> ResponseReturnValue: + """ + Endpoint to check status of transactions in asynchronous requests. + + This method is used for checking the status of transactions when Simbrella + doesn't receive a callback notification within 5 minutes of the initial request. + + Returns: + JSON response with detailed transaction status + """ + try: + # Parse and validate request + data = request.get_json() + if not data: + logger.warning("Invalid JSON payload received") + return jsonify({ + 'resultCode': '400', + 'resultDescription': 'Invalid JSON payload' + }), 400 + + # Validate required fields + required_fields = ['transactionId', 'debtId', 'transactionType', + 'fbnTransactionId', 'origTransactionId', 'customerId'] + missing_fields = [field for field in required_fields if field not in data] + if missing_fields: + logger.warning(f"Missing required fields: {', '.join(missing_fields)}") + return jsonify({ + 'resultCode': '422', + 'resultDescription': f'Missing required fields: {", ".join(missing_fields)}' + }), 422 + + # Create request model + req = NewTransactionCheckRequest.from_dict(data) + + # Process new transaction check (this would connect to the business logic) + # For demonstration, we'll return a mock response + + # Create transaction data based on transaction type + provided_amount = 0.0 + collected_amount = 0.0 + + if req.transactionType == "Disbursement": + provided_amount = 1000.0 + result_description = "Loan Provision is successful" + elif req.transactionType == "Collection": + collected_amount = 500.0 + result_description = "Loan Collection is successful" + else: # PenalCharge + collected_amount = 50.0 + result_description = "Penal Charge is successful" + + # Create transaction data + transaction_data = TransactionData( + transactionId=req.origTransactionId, + providedAmount=provided_amount, + collectedAmount=collected_amount, + resultCode="00", + resultDescription=result_description + ) + + # Create response + response = NewTransactionCheckResponse( + transactionId=req.transactionId, + data=transaction_data.to_dict(), + resultCode="00", + resultDescription="SUCCESS" + ) + + logger.info(f"Processed new transaction check for {req.transactionType}, ID {req.transactionId}") + return jsonify(response.to_dict()) + + except Exception as e: + logger.exception(f"Error processing new transaction check: {str(e)}") + return jsonify({ + 'resultCode': '500', + 'resultDescription': f'Internal server error: {str(e)}' + }), 500 \ No newline at end of file diff --git a/api/middleware.py b/api/middleware.py new file mode 100644 index 0000000..ab2e52b --- /dev/null +++ b/api/middleware.py @@ -0,0 +1,115 @@ +""" +Middleware module for the Flask application. +""" +from flask import Flask, request, jsonify, g, current_app +from flask.typing import ResponseReturnValue +from functools import wraps +import base64 +from typing import Callable, Any, TypeVar, cast +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +F = TypeVar('F', bound=Callable[..., Any]) + +def register_middleware(app: Flask) -> None: + """ + Register middleware with the Flask application. + + Args: + app: Flask application instance + """ + # Register CORS if needed + try: + from flask_cors import CORS + CORS(app, resources={r"/*": {"origins": app.config.get('CORS_ORIGINS', '*')}}) + except ImportError: + logger.warning("flask-cors not installed. CORS support disabled.") + + @app.before_request + def before_request() -> None: + """Process request before it reaches the view function.""" + # Log incoming requests + logger.debug(f"Received {request.method} request to {request.path}") + + # You can add more global middleware here if needed + +def basic_auth_required(f: F) -> F: + """ + Decorator for endpoints that require basic authentication. + + Args: + f: Function to decorate + + Returns: + Decorated function + """ + @wraps(f) + def decorated(*args: Any, **kwargs: Any) -> ResponseReturnValue: + auth = request.headers.get('Authorization') + if not auth or not auth.startswith('Basic '): + logger.warning("Authentication failed: No Basic Auth header") + return jsonify({ + 'resultCode': '01', + 'resultDescription': 'Authentication required' + }), 401 + + try: + auth_decoded = base64.b64decode(auth[6:]).decode('utf-8') + username, password = auth_decoded.split(':', 1) + + if username != current_app.config['API_USERNAME'] or password != current_app.config['API_PASSWORD']: + logger.warning(f"Authentication failed: Invalid credentials for user {username}") + return jsonify({ + 'resultCode': '01', + 'resultDescription': 'Invalid credentials' + }), 401 + + g.user = username + logger.debug(f"Authentication successful for user {username}") + return f(*args, **kwargs) + except Exception as e: + logger.error(f"Authentication error: {str(e)}") + return jsonify({ + 'resultCode': '01', + 'resultDescription': 'Invalid authentication format' + }), 401 + + return cast(F, decorated) + +def api_key_required(f: F) -> F: + """ + Decorator for endpoints that require API key authentication. + + Args: + f: Function to decorate + + Returns: + Decorated function + """ + @wraps(f) + def decorated(*args: Any, **kwargs: Any) -> ResponseReturnValue: + app_id = request.headers.get('appID') + api_key = request.headers.get('apiKey') + + if not app_id or not api_key: + logger.warning("API key authentication failed: Missing appID or apiKey") + return jsonify({ + 'resultCode': '01', + 'resultDescription': 'API key authentication required' + }), 401 + + # Validate against configured API keys + if app_id != current_app.config['SIMBRELLA_APP_ID'] or api_key != current_app.config['SIMBRELLA_API_KEY']: + logger.warning(f"API key authentication failed: Invalid appID or apiKey") + return jsonify({ + 'resultCode': '01', + 'resultDescription': 'Invalid API keys' + }), 401 + + g.api_client = 'simbrella' + logger.debug(f"API key authentication successful") + return f(*args, **kwargs) + + return cast(F, decorated) \ No newline at end of file diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..a986655 --- /dev/null +++ b/api/models.py @@ -0,0 +1,459 @@ +""" +Data models for request and response validation. +""" +from dataclasses import dataclass, field, asdict +from typing import List, Dict, Any, Optional, TypeVar, Type, cast +from datetime import datetime +import json + +T = TypeVar('T', bound='BaseModel') + +class BaseModel: + """Base model with serialization capabilities.""" + + def __init__(self): + self.type_field = None + + def to_dict(self) -> Dict[str, Any]: + """Convert model to dictionary.""" + result = {k: v for k, v in asdict(self).items() if v is not None} + # Handle special case for type_field + if hasattr(self, 'type_field') and self.type_field is not None: + result['$type'] = self.type_field + del result['type_field'] + return result + + def to_json(self) -> str: + """Convert model to JSON string.""" + return json.dumps(self.to_dict()) + + @classmethod + def from_dict(cls: Type[T], data: Dict[str, Any]) -> T: + """Create model instance from dictionary.""" + # Handle special case for $type field + if '$type' in data: + data_copy = data.copy() + data_copy['type_field'] = data_copy.pop('$type') + data = data_copy + + return cls(**{k: v for k, v in data.items() if k in cls.__annotations__}) + +@dataclass +class EligibilityCheckRequest(BaseModel): + """Model for eligibility check request.""" + type_field: str # This will be mapped to $type in JSON + transactionId: str + countryCode: str + customerId: str + accountId: str + lienAmount: float + channel: str + msisdn: Optional[str] = None + +@dataclass +class EligibleOffer(BaseModel): + """Model for eligible offer.""" + minamount: float + maxamount: float + productId: int + offerid: int + Tenor: int + +@dataclass +class EligibilityCheckResponse(BaseModel): + """Model for eligibility check response.""" + customerId: str + transactionId: str + eligibleOffers: List[Dict[str, Any]] + resultCode: str + resultDescription: str + msisdn: Optional[str] = None + +@dataclass +class SelectOffersRequest(BaseModel): + """Model for select offers request.""" + requestId: str + transactionId: str + customerId: str + accountId: str + msisdn: str + requestedAmount: float + productid: str + channel: str + +@dataclass +class Offer(BaseModel): + """Model for offer details.""" + offerId: str + productId: str + amount: float + upfrontPayment: float + interestRate: float + Interest: float + ManagementRate: float + ManagementFee: float + InsuranceRate: float + InsuranceFee: float + VATRate: float + VATamount: float + recommendedRepaymentDates: List[str] + installmentAmount: float + totalRepaymentAmount: float + +@dataclass +class SelectOffersResponse(BaseModel): + """Model for select offers response.""" + requestId: str + transactionId: str + customerId: str + accountId: str + offers: List[Dict[str, Any]] + resultCode: str + resultDescription: str + outstandingDebtAmount: Optional[float] = 0 + +@dataclass +class ProvideLoanRequest(BaseModel): + """Model for provide loan request.""" + type_field: str # This will be mapped to $type in JSON + requestId: str + transactionId: str + customerId: str + accountId: str + productId: str + lienAmount: float + requestedAmount: float + collectionType: int + loanType: int + channel: str + msisdn: Optional[str] = None + +@dataclass +class ProvideLoanResponse(BaseModel): + """Model for provide loan response.""" + requestId: str + transactionId: str + customerId: str + accountId: str + resultCode: str + resultDescription: str + msisdn: Optional[str] = None + +@dataclass +class LoanInformationRequest(BaseModel): + """Model for loan information request.""" + type_field: str # This will be mapped to $type in JSON + transactionId: str + customerId: str + channel: str + msisdn: Optional[str] = None + +@dataclass +class Loan(BaseModel): + """Model for loan details.""" + debtId: str + loanDate: str + dueDate: str + currentLoanAmount: float + initialLoanAmount: float + defaultFee: float + continiousFee: float + productId: str + +@dataclass +class LoanInformationResponse(BaseModel): + """Model for loan information response.""" + customerId: str + loans: List[Dict[str, Any]] + resultCode: str + resultDescription: str + totalDebtAmount: Optional[float] = None + +@dataclass +class RepaymentRequest(BaseModel): + """Model for repayment request.""" + type_field: str # This will be mapped to $type in JSON + transactionId: str + customerId: str + debtId: str + productId: str + channel: str + msisdn: Optional[str] = None + +@dataclass +class RepaymentResponse(BaseModel): + """Model for repayment response.""" + customerId: str + productId: str + debtId: str + resultCode: str + resultDescription: str + +@dataclass +class CustomerConsentRequest(BaseModel): + """Model for customer consent request.""" + type_field: str # This will be mapped to $type in JSON + transactionId: str + customerId: str + accountId: str + requestTime: str + consentType: str + channel: str + +@dataclass +class CustomerConsentResponse(BaseModel): + """Model for customer consent response.""" + resultCode: str + resultDescription: str + +@dataclass +class NotificationCallbackRequest(BaseModel): + """Model for notification callback request.""" + fbnTransactionId: str + transactionId: str + customerId: str + accountId: str + debtId: str + transactionType: str + amountProvided: float + amountCollected: float + responseCode: str + responseDescription: str + +@dataclass +class NotificationCallbackResponse(BaseModel): + """Model for notification callback response.""" + resultCode: str + resultDescription: str + +@dataclass +class RACCheckRequest(BaseModel): + """Model for RAC check request.""" + transactionId: str + fbnTransactionId: str + customerId: str + accountId: str + RAC_Array: List[str] + +@dataclass +class RACResponse(BaseModel): + """Model for RAC response details.""" + SalaryAccount: Optional[str] = None + BVN: Optional[str] = None + BVNAttachedtoAccount: Optional[str] = None + CRMS: Optional[str] = None + CRC: Optional[str] = None + AccountStatus: Optional[str] = None + Lien: Optional[str] = None + NoBounchedCheck: Optional[str] = None + Whitelist: Optional[str] = None + NoPastDueSalaryLoan: Optional[str] = None + NoPastDueOtherLoan: Optional[str] = None + +@dataclass +class RACCheckResponse(BaseModel): + """Model for RAC check response.""" + RACResponse: Dict[str, Any] + resultCode: str + resultDescription: str + +@dataclass +class DisbursementRequest(BaseModel): + """Model for disbursement request.""" + requestId: str + debtId: str + transactionId: str + customerId: str + accountId: str + productId: str + provideAmount: float + countryId: str + collectAmountInterest: Optional[float] = None + collectAmountMgtFee: Optional[float] = None + collectAmountInsurance: Optional[float] = None + collectAmountVAT: Optional[float] = None + comment: Optional[str] = None + +@dataclass +class DisbursementResponse(BaseModel): + """Model for disbursement response.""" + requestId: str + debtId: str + transactionId: str + customerId: str + accountId: str + productId: str + provideAmount: float + resultCode: str + resultDescription: str + collectAmountInterest: Optional[float] = None + collectAmountMgtFee: Optional[float] = None + collectAmountInsurance: Optional[float] = None + collectAmountVAT: Optional[float] = None + +@dataclass +class CollectLoanRequest(BaseModel): + """Model for collect loan request.""" + transactionId: str + fbnTransactionId: str + debtId: str + customerId: str + accountId: str + productId: str + collectAmount: float + collectionMethod: int + lienAmount: float + countryId: str + penalCharge: Optional[float] = 0.0 + comment: Optional[str] = None + +@dataclass +class CollectLoanResponse(BaseModel): + """Model for collect loan response.""" + transactionId: str + debtId: str + customerId: str + accountId: str + productId: str + collectAmount: float + lienAmount: float + resultCode: str + resultDescription: str + penalCharge: Optional[float] = 0.0 + +@dataclass +class TransactionCheckRequest(BaseModel): + """Model for transaction check request.""" + counter: str + TransactionId: str + requestID: str + customerId: str + accountId: str + countryId: str + transactionType: str + +@dataclass +class TransactionCheckResponse(BaseModel): + """Model for transaction check response.""" + type_field: str # This will be mapped to $type in JSON + nativeId: str + customerId: str + accountId: str + providedAmount: float + collectedAmount: float + resultCode: str + resultDescription: str + +@dataclass +class PenalChargeRequest(BaseModel): + """Model for penal charge request.""" + transactionId: str + fbnTransactionId: str + debtId: str + customerId: str + accountId: str + penalCharge: float + lienAmount: float + countryId: str + comment: Optional[str] = None + +@dataclass +class PenalChargeResponse(BaseModel): + """Model for penal charge response.""" + resultCode: str + resultDescription: str + +@dataclass +class RevokeEnableConsentRequest(BaseModel): + """Model for revoke/enable consent request.""" + transactionId: str + fbnTransactionId: str + customerId: str + accountId: str + processTime: str + consentType: str + countryId: str + comment: Optional[str] = None + +@dataclass +class RevokeEnableConsentResponse(BaseModel): + """Model for revoke/enable consent response.""" + type_field: str # This will be mapped to $type in JSON + customerId: str + accountId: str + resultCode: str + resultDescription: str + +@dataclass +class ValidateTokenRequest(BaseModel): + """Model for validate token request.""" + RequestId: str + UserId: str + CountryId: str + TokenCode: str + +@dataclass +class ValidateTokenResponse(BaseModel): + """Model for validate token response.""" + Authenticated: bool + AuthenticatedMessage: str + ResponseCode: str + ResponseMessage: str + RequestId: str + +@dataclass +class LienCheckRequest(BaseModel): + """Model for lien check request.""" + transactionId: str + customerId: str + accountId: str + countryId: str + +@dataclass +class LienCheckResponse(BaseModel): + """Model for lien check response.""" + lienAmount: float + resultCode: str + resultDescription: str + +@dataclass +class NewTransactionCheckRequest(BaseModel): + """Model for new transaction check request.""" + transactionId: str + debtId: str + transactionType: str + fbnTransactionId: str + origTransactionId: str + customerId: str + +@dataclass +class TransactionData(BaseModel): + """Model for transaction data.""" + transactionId: str + providedAmount: float + collectedAmount: float + resultCode: str + resultDescription: str + +@dataclass +class NewTransactionCheckResponse(BaseModel): + """Model for new transaction check response.""" + transactionId: str + data: Dict[str, Any] + resultCode: str + resultDescription: str + +@dataclass +class SMSRequest(BaseModel): + """Model for SMS request.""" + text: str + dest: str + unicode: bool + +@dataclass +class SMSResponse(BaseModel): + """Model for SMS response.""" + data: str + statusCode: int + IsSuccessful: bool + errorMessage: Optional[str] = None \ No newline at end of file diff --git a/api/routes.py b/api/routes.py new file mode 100644 index 0000000..aec5f4f --- /dev/null +++ b/api/routes.py @@ -0,0 +1,55 @@ +""" +Routes module for the Flask application. +""" +from flask import Blueprint, Flask +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +def register_blueprints(app: Flask) -> None: + """ + Register all blueprints with the Flask application. + + Args: + app: Flask application instance + """ + # Import controllers + from api.controllers.eligibility import eligibility_bp + from api.controllers.offers import offers_bp + from api.controllers.loan import loan_bp + from api.controllers.repayment import repayment_bp + from api.controllers.consent import consent_bp + from api.controllers.notification import notification_bp + from api.controllers.rac import rac_bp + from api.controllers.disbursement import disbursement_bp + from api.controllers.collection import collection_bp + from api.controllers.transaction import transaction_bp + from api.controllers.penal import penal_bp + from api.controllers.token import token_bp + from api.controllers.lien import lien_bp + from api.controllers.sms import sms_bp + + # Create main API blueprint + api_bp = Blueprint('api', __name__, url_prefix='/v1/api/salary') + + # Register feature blueprints + api_bp.register_blueprint(eligibility_bp) + api_bp.register_blueprint(offers_bp) + api_bp.register_blueprint(loan_bp) + api_bp.register_blueprint(repayment_bp) + api_bp.register_blueprint(consent_bp) + api_bp.register_blueprint(notification_bp) + api_bp.register_blueprint(rac_bp) + api_bp.register_blueprint(disbursement_bp) + api_bp.register_blueprint(collection_bp) + api_bp.register_blueprint(transaction_bp) + api_bp.register_blueprint(penal_bp) + api_bp.register_blueprint(token_bp) + api_bp.register_blueprint(lien_bp) + api_bp.register_blueprint(sms_bp) + + # Register main blueprint with app + app.register_blueprint(api_bp) + + logger.info("All blueprints registered successfully") \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..0c46ba3 --- /dev/null +++ b/app.py @@ -0,0 +1,76 @@ +""" +Simbrella FirstAdvance API Flask Implementation +This module serves as the entry point for the Flask application. +""" +from flask import Flask +from flask.typing import ResponseReturnValue +from config import Config +from api.routes import register_blueprints +from api.middleware import register_middleware +import logging +import socket +import sys + +def create_app(config_class=Config) -> Flask: + """ + Factory pattern to create the Flask application. + + Args: + config_class: Configuration class to use + + Returns: + Flask application instance + """ + app = Flask(__name__) + app.config.from_object(config_class) + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + # Register middleware + register_middleware(app) + + # Register blueprints + register_blueprints(app) + + # Register error handlers + @app.errorhandler(400) + def bad_request(error) -> ResponseReturnValue: + return {"resultCode": "400", "resultDescription": f"Bad request: {str(error)}"}, 400 + + @app.errorhandler(404) + def not_found(error) -> ResponseReturnValue: + return {"resultCode": "404", "resultDescription": "Resource not found"}, 404 + + @app.errorhandler(422) + def validation_error(error) -> ResponseReturnValue: + return {"resultCode": "422", "resultDescription": f"Validation error: {str(error)}"}, 422 + + @app.errorhandler(500) + def server_error(error) -> ResponseReturnValue: + return {"resultCode": "500", "resultDescription": "Internal server error"}, 500 + + return app + +if __name__ == '__main__': + app = create_app() + + # Get port from config or use a default + port = app.config.get('PORT', 5000) + + # Try to run the app, with better error handling for socket issues + try: + app.run(debug=app.config.get('DEBUG', False), host='0.0.0.0', port=port) + except socket.error as e: + if e.errno == 10013: # Permission denied error on Windows + print(f"Error: Permission denied when trying to bind to port {port}.") + print("Try one of the following solutions:") + print(f"1. Use a different port (above 1024): set PORT=8080 in your environment variables") + print("2. Run the application with administrator privileges") + print("3. Check if another application is already using this port") + else: + print(f"Socket error: {e}") + sys.exit(1) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..9869efe --- /dev/null +++ b/config.py @@ -0,0 +1,40 @@ +""" +Configuration module for the Flask application. +""" +import os +from typing import Any, Dict, List +from dataclasses import dataclass, field + +def get_cors_origins() -> List[str]: + """Get CORS origins from environment variable.""" + return os.environ.get('CORS_ORIGINS', '*').split(',') + +@dataclass +class Config: + """Base configuration class.""" + DEBUG: bool = os.environ.get('DEBUG', 'False').lower() == 'true' + TESTING: bool = os.environ.get('TESTING', 'False').lower() == 'true' + PORT: int = int(os.environ.get('PORT', 8080)) # Changed default port to 8080 + + # API credentials + API_USERNAME: str = os.environ.get('API_USERNAME', 'admin') + API_PASSWORD: str = os.environ.get('API_PASSWORD', 'password') + + # API keys for Simbrella to FirstBank API + SIMBRELLA_APP_ID: str = os.environ.get('SIMBRELLA_APP_ID', '') + SIMBRELLA_API_KEY: str = os.environ.get('SIMBRELLA_API_KEY', '') + + # Database configuration + DATABASE_URI: str = os.environ.get('DATABASE_URI', 'sqlite:///app.db') + + # Logging configuration + LOG_LEVEL: str = os.environ.get('LOG_LEVEL', 'INFO') + + # CORS settings + CORS_ORIGINS: List[str] = field(default_factory=get_cors_origins) + + @classmethod + def to_dict(cls) -> Dict[str, Any]: + """Convert config to dictionary for Flask.""" + return {k: v for k, v in cls.__dict__.items() + if not k.startswith('__') and not callable(v)} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0ad23bf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask~=3.1.0 +requests~=2.32.3 \ No newline at end of file diff --git a/test_api.py b/test_api.py new file mode 100644 index 0000000..9ed7da0 --- /dev/null +++ b/test_api.py @@ -0,0 +1,173 @@ +import requests +import base64 +import json + +# Configuration +BASE_URL = "http://127.0.0.1:8080/v1/api/salary" +USERNAME = "admin" +PASSWORD = "password" +APP_ID = "your_app_id" # Replace with your actual app ID +API_KEY = "your_api_key" # Replace with your actual API key + +# Authentication headers +basic_auth = base64.b64encode(f"{USERNAME}:{PASSWORD}".encode()).decode() +basic_auth_headers = { + "Content-Type": "application/json", + "Authorization": f"Basic {basic_auth}" +} + +api_key_headers = { + "Content-Type": "application/json", + "appID": APP_ID, + "apiKey": API_KEY +} + + +def test_eligibility_check(): + """Test the EligibilityCheck endpoint.""" + url = f"{BASE_URL}/EligibilityCheck" + payload = { + "$type": "EligibilityCheckRequest", + "transactionId": "Tr202503RK9232P115", + "countryCode": "NGR", + "customerId": "CN621868", + "accountId": "ACN8263457", + "msisdn": "2348012345678", + "lienAmount": 4.0, + "channel": "USSD" + } + + response = requests.post(url, headers=basic_auth_headers, json=payload) + print(f"EligibilityCheck Status: {response.status_code}") + print(json.dumps(response.json(), indent=2)) + print("-" * 50) + return response.json() + + +def test_select_offer(): + """Test the SelectOffer endpoint.""" + url = f"{BASE_URL}/SelectOffer" + payload = { + "requestId": "202503170001371256908", + "transactionId": "1231231321232", + "customerId": "1256907", + "accountId": "5948306019", + "msisdn": "2348012345678", + "requestedAmount": 10000.0, + "productid": "101", + "channel": "USSD" + } + + response = requests.post(url, headers=basic_auth_headers, json=payload) + print(f"SelectOffer Status: {response.status_code}") + print(json.dumps(response.json(), indent=2)) + print("-" * 50) + return response.json() + + +def test_provide_loan(): + """Test the ProvideLoan endpoint.""" + url = f"{BASE_URL}/ProvideLoan" + payload = { + "$type": "ProvideLoanRequest", + "requestId": "202503170001371256908", + "transactionId": "Tr202503RK9232P115", + "customerId": "CN621868", + "accountId": "ACN8263457", + "msisdn": "2348012345678", + "productId": "101", + "lienAmount": 400.0, + "requestedAmount": 10000.0, + "collectionType": 1, + "loanType": 0, + "channel": "USSD" + } + + response = requests.post(url, headers=basic_auth_headers, json=payload) + print(f"ProvideLoan Status: {response.status_code}") + print(json.dumps(response.json(), indent=2)) + print("-" * 50) + return response.json() + + +def test_loan_information(): + """Test the LoanInformation endpoint.""" + url = f"{BASE_URL}/LoanInformation" + payload = { + "$type": "LoanInformationRequest", + "transactionId": "Tr202503RK9232P115", + "customerId": "CN621868", + "msisdn": "2348012345678", + "channel": "USSD" + } + + response = requests.post(url, headers=basic_auth_headers, json=payload) + print(f"LoanInformation Status: {response.status_code}") + print(json.dumps(response.json(), indent=2)) + print("-" * 50) + return response.json() + + +def test_rac_check(): + """Test the RACCheck endpoint.""" + url = f"{BASE_URL}/RACCheck" + payload = { + "transactionId": "T001", + "fbnTransactionId": "Tr202503RK9232P115", + "customerId": "CN621868", + "accountId": "2017821799", + "RAC_Array": ["SalaryAccount", "BVN", "CRMS", "CRC", "AccountStatus", "Lien", "NoBounchedCheck", "Whitelist"] + } + + response = requests.post(url, headers=api_key_headers, json=payload) + print(f"RACCheck Status: {response.status_code}") + print(json.dumps(response.json(), indent=2)) + print("-" * 50) + return response.json() + + +def test_disbursement(): + """Test the Disbursement endpoint.""" + url = f"{BASE_URL}/Disbursement" + payload = { + "requestId": "202503170001371256908", + "debtId": "273194670", + "transactionId": "T001", + "customerId": "CN621868", + "accountId": "2017821799", + "productId": "101", + "provideAmount": 100000.0, + "collectAmountInterest": 5000.0, + "collectAmountMgtFee": 1000.0, + "collectAmountInsurance": 1000.0, + "collectAmountVAT": 75.0, + "countryId": "01", + "comment": "Testing LoanRequest" + } + + response = requests.post(url, headers=api_key_headers, json=payload) + print(f"Disbursement Status: {response.status_code}") + print(json.dumps(response.json(), indent=2)) + print("-" * 50) + return response.json() + + +def run_all_tests(): + """Run all test functions.""" + print("Starting API tests...\n") + + # Test endpoints with basic auth + test_eligibility_check() + test_select_offer() + test_provide_loan() + test_loan_information() + + # Test endpoints with API key auth + test_rac_check() + test_disbursement() + + print("All tests completed!") + + +if __name__ == "__main__": + run_all_tests() \ No newline at end of file