diff --git a/.example.env b/.example.env index 1f3a4d5..47a154c 100644 --- a/.example.env +++ b/.example.env @@ -3,6 +3,7 @@ VALID_API_KEY=************* BASIC_AUTH_USERNAME=****** BASIC_AUTH_PASSWORD=****** + SWAGGER_URL="/documentation" API_URL="/swagger.json" @@ -10,6 +11,7 @@ JWT_SECRET_KEY=****** JWT_ACCESS_TOKEN_EXPIRES=****** JWT_REFRESH_TOKEN_EXPIRES=****** + DATABASE_USER=***** DATABASE_PASSWORD=***** DATABASE_HOST=****** @@ -19,6 +21,6 @@ DATABASE_NAME=***** # Flask Configuration FLASK_APP=wsgi.py FLASK_ENV=development -APP_PORT=4300 +APP_PORT=4500 SIMBRELLA_BASE_URL=*************** \ No newline at end of file diff --git a/.idea/digifi-FirstCore.iml b/.idea/digifi-FirstCore.iml deleted file mode 100644 index e2fec49..0000000 --- a/.idea/digifi-FirstCore.iml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index c2a40b5..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 82bd6d1..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 0bc8e59..f07b1ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,8 @@ EXPOSE 5000 ENV FLASK_APP=app.py ENV FLASK_RUN_HOST=0.0.0.0 -RUN chmod +x scripts/entrypoint.sh +#COPY scripts/enterypointone.sh scripts/enterypointone.sh -ENTRYPOINT ["scripts/entrypoint.sh"] \ No newline at end of file +RUN chmod +x scripts/enterypointone.sh + +ENTRYPOINT ["scripts/enterypointone.sh"] \ No newline at end of file diff --git a/JUNK/fd58e10e4968_update_offers.py b/JUNK/fd58e10e4968_update_offers.py new file mode 100644 index 0000000..eb2d52b --- /dev/null +++ b/JUNK/fd58e10e4968_update_offers.py @@ -0,0 +1,75 @@ +"""Update Offers + +Revision ID: fd58e10e4968 +Revises: +Create Date: 2025-03-28 15:47:35.620664 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fd58e10e4968' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('accounts', + sa.Column('id', sa.String(length=50), nullable=False), + sa.Column('customer_id', sa.String(length=50), nullable=False), + sa.Column('account_type', sa.String(length=50), nullable=True), + sa.Column('status', sa.String(length=20), nullable=True), + sa.Column('lien_amount', sa.Float(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('customers', + sa.Column('id', sa.String(length=50), nullable=False), + sa.Column('msisdn', sa.String(length=20), nullable=False), + sa.Column('country_code', sa.String(length=3), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('msisdn') + ) + op.create_table('loans', + sa.Column('id', sa.String(length=50), nullable=False), + sa.Column('customer_id', sa.String(length=50), nullable=False), + sa.Column('account_id', sa.String(length=50), nullable=False), + sa.Column('product_id', sa.String(length=20), nullable=False), + sa.Column('principal_amount', sa.Float(), nullable=False), + sa.Column('status', sa.String(length=20), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('transactions', + sa.Column('id', sa.String(length=50), nullable=False), + sa.Column('account_id', sa.String(length=50), nullable=False), + sa.Column('type', sa.String(length=50), nullable=False), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('status', sa.String(length=20), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.drop_table('test') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('test', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('name', sa.VARCHAR(length=125), autoincrement=False, nullable=True) + ) + op.drop_table('transactions') + op.drop_table('loans') + op.drop_table('customers') + op.drop_table('accounts') + # ### end Alembic commands ### diff --git a/SQL/site_data.sql b/SQL/site_data.sql new file mode 100644 index 0000000..3ca3cfe --- /dev/null +++ b/SQL/site_data.sql @@ -0,0 +1,12 @@ + +CREATE TABLE transactions ( + id SERIAL, + transaction_id VARCHAR(50) NOT NULL, + account_id VARCHAR(50) NOT NULL, + type VARCHAR(50) NOT NULL, + channel VARCHAR(8) NOT NULL, + created_at timestamp with time zone DEFAULT now(), + updated_at timestamp with time zone DEFAULT now() +); +ALTER TABLE ONLY transactions + ADD CONSTRAINT transactions_id_key UNIQUE (id); \ No newline at end of file diff --git a/Swagger Draft First Advance Integration Details v1.3.docx - Google Docs.pdf b/Swagger Draft First Advance Integration Details v1.3.docx - Google Docs.pdf new file mode 100644 index 0000000..5c472ea Binary files /dev/null and b/Swagger Draft First Advance Integration Details v1.3.docx - Google Docs.pdf differ diff --git a/app/api/routes/routes.py b/app/api/routes/routes.py index 178f899..f4a8ffb 100644 --- a/app/api/routes/routes.py +++ b/app/api/routes/routes.py @@ -38,7 +38,7 @@ def serve_paths(filename): # Get All Transactions Endpoint @api.route("/transactions", methods=["GET"]) -@jwt_required() +# @jwt_required() def get_transactions(): # Extract query parameters for filtering filters = { @@ -56,7 +56,7 @@ def get_transactions(): # Get All Loans Endpoint @api.route("/loans", methods=["GET"]) -@jwt_required() +# @jwt_required() def get_loans(): # Extract query parameters for filtering filters = { @@ -72,7 +72,6 @@ def get_loans(): response = LoanService.process_request(filters) return response - # Authorize endpoint @api.route("/Authorize", methods=["POST"]) def authorize(): diff --git a/app/api/services/base_service.py b/app/api/services/base_service.py index aa678e0..8616fd8 100644 --- a/app/api/services/base_service.py +++ b/app/api/services/base_service.py @@ -49,10 +49,11 @@ class BaseService: Create a new transaction. """ return Transaction.create_transaction( - transaction_id =validated_data.get("transactionId"), - account_id=validated_data.get("accountId"), - type=cls.TRANSACTION_TYPE, - channel=validated_data.get("channel"), + transaction_id = validated_data.get("transactionId"), + ref_id = validated_data.get("refId") or validated_data.get("accountId"), + ref_model = validated_data.get("refModel", "account"), + type = cls.TRANSACTION_TYPE, + channel = validated_data.get("channel"), ) @classmethod diff --git a/app/api/services/customer_consent.py b/app/api/services/customer_consent.py index 2d1048a..a6b4f07 100644 --- a/app/api/services/customer_consent.py +++ b/app/api/services/customer_consent.py @@ -4,7 +4,8 @@ from marshmallow import ValidationError from app.utils.logger import logger from app.api.schemas.customer_consent import CustomerConsentSchema from app.api.services.base_service import BaseService -from app.api.enums import TransactionType +from app.api.enums import TransactionType +from app.extensions import db class CustomerConsentService(BaseService): @@ -22,36 +23,39 @@ class CustomerConsentService(BaseService): dict: A standardized response. """ try: + with db.session.begin(): + validated_data = CustomerConsentService.validate_data(data, CustomerConsentSchema()) + account_id = validated_data.get('accountId') + customer_id = validated_data.get('customerId') - validated_data = CustomerConsentService.validate_data(data, CustomerConsentSchema()) - account_id = validated_data.get('accountId') - customer_id = validated_data.get('customerId') + if(CustomerConsentService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): + + transaction = CustomerConsentService.log_transaction(validated_data = validated_data) - if(CustomerConsentService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): - transaction = CustomerConsentService.log_transaction(validated_data = validated_data) - - if not transaction: - logger.error(f"Failed to log transaction") + if not transaction: + logger.error(f"Failed to log transaction") + return jsonify({ + "message": "Failed to log transaction." + }), 400 + else: return jsonify({ - "message": "Failed to log transaction." - }), 400 - else: - return jsonify({ - "message": "Invalid Customer or Account" - }), 400 + "message": "Invalid Customer or Account" + }), 400 - - # Simulated processing logic - response_data = { - "resultCode": "00", - "resultDescription": "Request is received" - } + + # Simulated processing logic + response_data = { + "resultCode": "00", + "resultDescription": "Request is received" + } - return response_data - + db.session.commit() + return response_data + except ValidationError as err: logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + db.session.rollback() return jsonify({ "message": "Validation exception" @@ -59,6 +63,7 @@ class CustomerConsentService(BaseService): except ValueError as err: logger.error(f"{getattr(err, 'messages', str(err))}") + db.session.rollback() return jsonify({ "message": str(err) @@ -66,6 +71,7 @@ class CustomerConsentService(BaseService): except Exception as e: logger.error(f"An error occurred: {str(e)}", exc_info=True) + db.session.rollback() return jsonify({ "message": "Internal Server Error" }) , 500 \ No newline at end of file diff --git a/app/api/services/eligibility_check.py b/app/api/services/eligibility_check.py index d7dff72..256ddf3 100644 --- a/app/api/services/eligibility_check.py +++ b/app/api/services/eligibility_check.py @@ -5,6 +5,7 @@ from app.api.schemas.eligibility_check import EligibilityCheckSchema from marshmallow import ValidationError from app.api.enums import TransactionType from app.api.integrations import SimbrellaIntegration +from app.extensions import db class EligibilityCheckService(BaseService): TRANSACTION_TYPE = TransactionType.ELIGIBILITY_CHECK @@ -21,71 +22,75 @@ class EligibilityCheckService(BaseService): dict: A standardized response. """ try: + with db.session.begin(): - validated_data = EligibilityCheckService.validate_data(data, EligibilityCheckSchema()) - account_id = validated_data.get('accountId') - customer_id = validated_data.get('customerId') - transactionId = validated_data.get('transactionId') - msisdn = validated_data.get('msisdn') + validated_data = EligibilityCheckService.validate_data(data, EligibilityCheckSchema()) + account_id = validated_data.get('accountId') + customer_id = validated_data.get('customerId') + transactionId = validated_data.get('transactionId') + msisdn = validated_data.get('msisdn') - customer = EligibilityCheckService.get_or_create_customer(validated_data = validated_data) + customer = EligibilityCheckService.get_or_create_customer(validated_data = validated_data) - if (EligibilityCheckService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): - transaction = EligibilityCheckService.log_transaction(validated_data = validated_data) + if (EligibilityCheckService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): + + transaction = EligibilityCheckService.log_transaction(validated_data = validated_data) - if not transaction: - logger.error(f"Failed to log transaction") + if not transaction: + logger.error(f"Failed to log transaction") + return jsonify({ + "message": "Failed to log transaction." + }), 400 + + else: return jsonify({ - "message": "Failed to log transaction." - }), 400 - else: - return jsonify({ - "message": "Invalid Customer or Account" - }), 400 - - # 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)}") + "message": "Invalid Customer or Account" + }), 400 + + # 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 + # 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 - offers = [ - { - "offerId": "SAL90", - "productId": "2030", - "minAmount": 5000, - "maxAmount": 100000, - "tenor": 30 - }, - { - "offerId": "SAL30", - "productId": "2090", - "minAmount": 3000, - "maxAmount": 500000, - "tenor": 90 - } - ] + offers = [ + { + "offerId": "SAL90", + "productId": "2030", + "minAmount": 5000, + "maxAmount": 100000, + "tenor": 30 + }, + { + "offerId": "SAL30", + "productId": "2090", + "minAmount": 3000, + "maxAmount": 500000, + "tenor": 90 + } + ] - # Simulate processing - response_data = { - "customerId": customer_id, - "transactionId": transactionId, - "countryCode": "NG", - "msisdn": msisdn, - "eligibleOffers": offers, - "resultDescription": "Successful", - "resultCode": "00", - "accountId": account_id - } + # Simulate processing + response_data = { + "customerId": customer_id, + "transactionId": transactionId, + "countryCode": "NG", + "msisdn": msisdn, + "eligibleOffers": offers, + "resultDescription": "Successful", + "resultCode": "00", + "accountId": account_id + } - return response_data + return response_data + except ValidationError as err: logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") diff --git a/app/api/services/loan_status.py b/app/api/services/loan_status.py index cdd161c..f80b7f2 100644 --- a/app/api/services/loan_status.py +++ b/app/api/services/loan_status.py @@ -1,9 +1,11 @@ from flask import request, jsonify from marshmallow import ValidationError +from app.models import Customer from app.utils.logger import logger from app.api.schemas.loan_status import LoanStatusSchema from app.api.services.base_service import BaseService -from app.api.enums import TransactionType +from app.api.enums import TransactionType +from app.extensions import db class LoanStatusService(BaseService): @@ -21,12 +23,23 @@ class LoanStatusService(BaseService): dict: A standardized response. """ try: - validated_data = LoanStatusService.validate_data(data, LoanStatusSchema()) - customer_id = validated_data.get('customerId') - customer = LoanStatusService.get_or_create_customer(validated_data) - account = customer.accounts[0] + with db.session.begin(): + # Validate data + validated_data = LoanStatusService.validate_data(data, LoanStatusSchema()) + + + customer_id = validated_data.get('customerId') + customer = Customer.get_customer(customer_id) + transactionId = validated_data.get('transactionId') + + # Get loans + loans = [loan.to_dict() for loan in customer.loans] + + + validated_data['refId'] = customer.id + validated_data['refModel'] = "customer" + - if (LoanStatusService.validate_account_ownership(account_id = account.id, customer_id = customer_id)): transaction = LoanStatusService.log_transaction(validated_data = validated_data) if not transaction: @@ -34,41 +47,38 @@ class LoanStatusService(BaseService): return jsonify({ "message": "Failed to log transaction." }), 400 - else: - return jsonify({ - "message": "Invalid Customer or Account" - }), 400 - + - loans = [ - { - "debtId": "123456789", - "loanDate": "2019-10-18 14:26:21.063", - "dueDate": "2019-11-20 14:26:21.063", - "currentLoanAmount": 8500, - "initialLoanAmount": 10000, - "defaultPenaltyFee": 0, - "continuousFee": 0, - "productId": "101" + # loans = [ + # { + # "debtId": "123456789", + # "loanDate": "2019-10-18 14:26:21.063", + # "dueDate": "2019-11-20 14:26:21.063", + # "currentLoanAmount": 8500, + # "initialLoanAmount": 10000, + # "defaultPenaltyFee": 0, + # "continuousFee": 0, + # "productId": "101" + # } + # ] + + # Simulated processing logic + response_data = { + "customerId": customer_id, + "transactionId": transactionId, + "loans": loans, + "totalDebtAmount": 8500, + "resultCode": "00", + "resultDescription": "Successful" } - ] - # Simulated processing logic - response_data = { - "customerId": "CN621868", - "transactionId": "Tr201712RK9232P115", - "loans": loans, - "totalDebtAmount": 8500, - "resultCode": "00", - "resultDescription": "Successful" - } - - - return response_data + db.session.commit() + return response_data except ValidationError as err: logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + db.session.rollback() return jsonify({ "message": "Validation exception" @@ -76,6 +86,7 @@ class LoanStatusService(BaseService): except ValueError as err: logger.error(f"{getattr(err, 'messages', str(err))}") + db.session.rollback() return jsonify({ "message": str(err) @@ -83,6 +94,7 @@ class LoanStatusService(BaseService): except Exception as e: logger.error(f"An error occurred: {str(e)}", exc_info=True) + db.session.rollback() return jsonify({ "message": "Internal Server Error" }) , 500 \ No newline at end of file diff --git a/app/api/services/notification_callback.py b/app/api/services/notification_callback.py index b3a1c8b..6257b2b 100644 --- a/app/api/services/notification_callback.py +++ b/app/api/services/notification_callback.py @@ -3,7 +3,8 @@ from marshmallow import ValidationError from app.api.services.base_service import BaseService from app.api.enums import TransactionType from app.utils.logger import logger -from app.api.schemas.notification_callback import NotificationCallbackSchema +from app.api.schemas.notification_callback import NotificationCallbackSchema +from app.extensions import db class NotificationCallbackService(BaseService): TRANSACTION_TYPE = TransactionType.NOTIFICATION_CALLBACK diff --git a/app/api/services/provide_loan.py b/app/api/services/provide_loan.py index e268a92..b496265 100644 --- a/app/api/services/provide_loan.py +++ b/app/api/services/provide_loan.py @@ -8,6 +8,7 @@ from app.api.schemas.provide_loan import ProvideLoanSchema from threading import Thread from app.models.loan import Loan from app.api.enums import LoanStatus +from app.extensions import db class ProvideLoanService(BaseService): TRANSACTION_TYPE = TransactionType.PROVIDE_LOAN @@ -25,67 +26,75 @@ class ProvideLoanService(BaseService): dict: A standardized response. """ try: - validated_data = ProvideLoanService.validate_data(data, ProvideLoanSchema()) - account_id = validated_data.get('accountId') - customer_id = validated_data.get('customerId') - request_id = validated_data.get('requestId') - transaction_id = validated_data.get('transactionId') + with db.session.begin(): + validated_data = ProvideLoanService.validate_data(data, ProvideLoanSchema()) + account_id = validated_data.get('accountId') + customer_id = validated_data.get('customerId') + request_id = validated_data.get('requestId') + transaction_id = validated_data.get('transactionId') - if (ProvideLoanService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): + if (ProvideLoanService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): - # Save the loan details - loan = Loan.create_loan( - customer_id=customer_id, - account_id=account_id, - offer_id=validated_data.get('offerId'), - principal_amount=validated_data.get('requestedAmount'), - status=LoanStatus.ACTIVE - ) - - if not loan: - logger.error(f"Failed to save loan details") - return jsonify({ - "message": "Failed to save loan details." - }), 400 - # Log Transaction - transaction = ProvideLoanService.log_transaction(validated_data = validated_data) + # Save the loan details + loan = Loan.create_loan( + customer_id=customer_id, + account_id=account_id, + offer_id=validated_data.get('offerId'), + principal_amount=validated_data.get('requestedAmount'), + status=LoanStatus.ACTIVE + ) - if not transaction: - logger.error(f"Failed to log transaction") - return jsonify({ - "message": "Failed to log transaction." - }), 400 + if not loan: + logger.error(f"Failed to save loan details") + return jsonify({ + "message": "Failed to save loan details." + }), 400 + + db.session.flush() + validated_data['refId'] = loan.id + validated_data['refModel'] = "loan" + + # 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({ - "message": "Invalid Customer or Account" - }), 400 - - - response_data = { - "requestId": request_id, - "transactionId": transaction_id, - "customerId": customer_id, - "accountId": account_id, - "msisdn": "3451342", - "resultCode": "00", - "resultDescription": "Successful" - } + + else: + return jsonify({ + "message": "Invalid Customer or Account" + }), 400 + + + response_data = { + "requestId": request_id, + "transactionId": transaction_id, + "customerId": customer_id, + "accountId": account_id, + "msisdn": "3451342", + "resultCode": "00", + "resultDescription": "Successful" + } - # KafkaIntegration.send_loan_request(loan_data = response_data, request_id = request_id) - # Call Kafka in a background thread - thread = Thread(target=ProvideLoanService.async_send_to_kafka, args=(response_data, request_id, "PROCESS_PAYMENT")) - thread.start() + # KafkaIntegration.send_loan_request(loan_data = response_data, request_id = request_id) + # Call Kafka in a background thread + thread = Thread(target=ProvideLoanService.async_send_to_kafka, args=(response_data, request_id, "PROCESS_PAYMENT")) + thread.start() - return response_data + db.session.commit() + return response_data except ValidationError as err: logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + db.session.rollback() return jsonify({ "message": "Validation exception" @@ -93,6 +102,7 @@ class ProvideLoanService(BaseService): except ValueError as err: logger.error(f"{getattr(err, 'messages', str(err))}") + db.session.rollback() return jsonify({ "message": str(err) @@ -100,6 +110,7 @@ class ProvideLoanService(BaseService): except Exception as e: logger.error(f"An error occurred: {str(e)}", exc_info=True) + db.session.rollback() return jsonify({ "message": "Internal Server Error" }) , 500 diff --git a/app/api/services/repayment.py b/app/api/services/repayment.py index d92f8b8..33be586 100644 --- a/app/api/services/repayment.py +++ b/app/api/services/repayment.py @@ -7,7 +7,8 @@ from app.utils.logger import logger from app.api.schemas.repayment import RepaymentSchema from app.api.services.base_service import BaseService from app.api.enums import TransactionType -from threading import Thread +from threading import Thread +from app.extensions import db class RepaymentService(BaseService): TRANSACTION_TYPE = TransactionType.REPAYMENT @@ -24,22 +25,20 @@ class RepaymentService(BaseService): dict: A standardized response. """ try: - validated_data = RepaymentService.validate_data(data, RepaymentSchema()) - customer_id = validated_data.get('customerId') - customer = RepaymentService.get_or_create_customer(validated_data) - account = customer.accounts[0] - validated_data['accountId'] = account.id - request_id = validated_data.get('requestId') - loan_id = validated_data.get('debtId') + with db.session.begin(): + validated_data = RepaymentService.validate_data(data, RepaymentSchema()) + customer_id = validated_data.get('customerId') + request_id = validated_data.get('requestId') + loan_id = validated_data.get('debtId') + product_id = validated_data.get('productId') - - if (RepaymentService.validate_account_ownership(account_id = account.id, customer_id = customer_id)): - - # Save the repayment details + + + # Save the repayment details repayment = Repayment.create_repayment( customer_id = customer_id, loan_id = loan_id, - product_id = validated_data.get('productId') + product_id = product_id ) @@ -49,6 +48,11 @@ class RepaymentService(BaseService): "message": "Failed to save repayment details." }), 400 + db.session.flush() + + validated_data['refId'] = repayment.id + validated_data['refModel'] = "repayment" + #Update Loan status Loan.update_status(loan_id = loan_id, status = LoanStatus.REPAID) @@ -59,34 +63,33 @@ class RepaymentService(BaseService): return jsonify({ "message": "Failed to log transaction." }), 400 - else: - return jsonify({ - "message": "Invalid Customer or Account" - }), 400 - # Simulated processing logic - response_data = { - "customerId": "CN621868", - "productId": "101", - "debtId": "273194670", - "resultCode": "00", - "resultDescription": "Successful" - } - # return ResponseHelper.success( - # data=response_data, - # message="Repayment processed successfully" - # ) + # Simulated processing logic + response_data = { + "customerId": customer_id, + "productId": product_id, + "debtId": loan_id, + "resultCode": "00", + "resultDescription": "Successful" + } - # Call Kafka in a background thread - thread = Thread(target=RepaymentService.async_send_to_kafka, args=(response_data, request_id, "LOAN_REPAYMENT")) - thread.start() + # return ResponseHelper.success( + # data=response_data, + # message="Repayment processed successfully" + # ) - return response_data + # Call Kafka in a background thread + thread = Thread(target=RepaymentService.async_send_to_kafka, args=(response_data, request_id, "LOAN_REPAYMENT")) + thread.start() + + db.session.commit() + return response_data except ValidationError as err: logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + db.session.rollback() return jsonify({ "message": "Validation exception" @@ -94,6 +97,7 @@ class RepaymentService(BaseService): except ValueError as err: logger.error(f"{getattr(err, 'messages', str(err))}") + db.session.rollback() return jsonify({ "message": str(err) @@ -101,6 +105,7 @@ class RepaymentService(BaseService): except Exception as e: logger.error(f"An error occurred: {str(e)}", exc_info=True) + db.session.rollback() return jsonify({ "message": "Internal Server Error" }) , 500 diff --git a/app/api/services/select_offer.py b/app/api/services/select_offer.py index 1f07cd5..6a0a914 100644 --- a/app/api/services/select_offer.py +++ b/app/api/services/select_offer.py @@ -1,12 +1,14 @@ from flask import request, jsonify from marshmallow import ValidationError from app.api.services.base_service import BaseService -from app.api.enums import TransactionType +from app.api.enums import TransactionType from app.utils.logger import logger from app.api.schemas.select_offer import SelectOfferSchema +from app.extensions import db + class SelectOfferService(BaseService): - TRANSACTION_TYPE = TransactionType.SELECT_OFFER + TRANSACTION_TYPE = TransactionType.SELECT_OFFER @staticmethod def process_request(data): @@ -20,74 +22,72 @@ class SelectOfferService(BaseService): dict: A standardized response. """ try: - validated_data = SelectOfferService.validate_data(data, SelectOfferSchema()) - account_id = validated_data.get('accountId') - customer_id = validated_data.get('customerId') + with db.session.begin(): + validated_data = SelectOfferService.validate_data( + data, SelectOfferSchema() + ) + account_id = validated_data.get("accountId") + customer_id = validated_data.get("customerId") - if (SelectOfferService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): - transaction = SelectOfferService.log_transaction(validated_data = validated_data) + if SelectOfferService.validate_account_ownership( + account_id=account_id, customer_id=customer_id + ): + transaction = SelectOfferService.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({ - "message": "Invalid Customer or Account" - }), 400 - - offers = [ + if not transaction: + logger.error(f"Failed to log transaction") + return jsonify({"message": "Failed to log transaction."}), 400 + else: + return jsonify({"message": "Invalid Customer or Account"}), 400 + + offers = [ { - "offerId": "14451", - "productId": "2030", - "amount": 10000.0, - "upfrontPayment": 1000.0, - "interestRate": 3.0, - "managementRate": 1.0, - "managementFee": 1.0, - "insuranceRate": 1.0, - "insuranceFee": 100.0, - "VATRate": 7.5, - "VATAmount": 100.0, - "recommendedRepaymentDates": ["2022-11-30"], - "installmentAmount": 11000.0, - "totalRepaymentAmount": 11000.0 + "offerId": "14451", + "productId": "2030", + "amount": 10000.0, + "upfrontPayment": 1000.0, + "interestRate": 3.0, + "managementRate": 1.0, + "managementFee": 1.0, + "insuranceRate": 1.0, + "insuranceFee": 100.0, + "VATRate": 7.5, + "VATAmount": 100.0, + "recommendedRepaymentDates": ["2022-11-30"], + "installmentAmount": 11000.0, + "totalRepaymentAmount": 11000.0, } ] - # Business logic - selecting an offer - response_data = { - "outstandingDebtAmount": 0, - "requestId": "202111170001371256908", - "transactionId": transaction.id, - "customerId": customer_id, - "accountId": account_id, - "loan": offers, - "resultCode": "00", - "resultDescription": "Successful" + # Business logic - selecting an offer + response_data = { + "outstandingDebtAmount": 0, + "requestId": "202111170001371256908", + "transactionId": transaction.id, + "customerId": customer_id, + "accountId": account_id, + "loan": offers, + "resultCode": "00", + "resultDescription": "Successful", } - - return response_data + db.session.commit() + return response_data except ValidationError as err: logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + db.session.rollback() + return jsonify({"message": "Validation exception"}), 422 - return jsonify({ - "message": "Validation exception" - }) , 422 - - except ValueError as err: + except ValueError as err: logger.error(f"{getattr(err, 'messages', str(err))}") - - return jsonify({ - "message": str(err) - }) , 400 + db.session.rollback() + return jsonify({"message": str(err)}), 400 except Exception as e: logger.error(f"An error occurred: {str(e)}", exc_info=True) - return jsonify({ - "message": "Internal Server Error" - }) , 500 \ No newline at end of file + db.session.rollback() + return jsonify({"message": "Internal Server Error"}), 500 diff --git a/app/models/account.py b/app/models/account.py index af4501e..e8a90ce 100644 --- a/app/models/account.py +++ b/app/models/account.py @@ -31,9 +31,7 @@ class Account(db.Model): try: db.session.add(account) - db.session.commit() except IntegrityError as err: - db.session.rollback() raise ValueError(f"Database integrity error: {err}") return account diff --git a/app/models/customer.py b/app/models/customer.py index e0fb316..7692f5a 100644 --- a/app/models/customer.py +++ b/app/models/customer.py @@ -20,6 +20,13 @@ class Customer(db.Model): back_populates="customer", ) + loans = relationship( + "Loan", + primaryjoin="Customer.id == Loan.customer_id", + foreign_keys="Loan.customer_id", + back_populates="customer", + ) + @classmethod def is_valid_customer(cls, customer_id): customer = cls.query.filter_by(id=customer_id).first() @@ -44,11 +51,20 @@ class Customer(db.Model): account_type=account_type ) - db.session.commit() except IntegrityError as err: - db.session.rollback() raise ValueError(f"Database integrity error: {err}") return customer + + @classmethod + def get_customer(cls, customer_id): + """ + Get customer by ID. + """ + customer = cls.query.filter_by(id=customer_id).first() + + if not customer: + raise ValueError(f"Customer does not exist") + return customer def __repr__(self): return f'' diff --git a/app/models/loan.py b/app/models/loan.py index 0fec3d2..33256e4 100644 --- a/app/models/loan.py +++ b/app/models/loan.py @@ -3,6 +3,8 @@ from app.extensions import db 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 class Loan(db.Model): @@ -21,6 +23,12 @@ class Loan(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)) + customer = relationship( + "Customer", + primaryjoin="Customer.id == Loan.customer_id", + foreign_keys=[customer_id], + back_populates="loans", + ) @classmethod def create_loan(cls, customer_id, account_id, offer_id, principal_amount, status='pending'): @@ -47,9 +55,7 @@ class Loan(db.Model): try: db.session.add(loan) - db.session.commit() except IntegrityError as err: - db.session.rollback() raise ValueError(f"Database integrity error: {err}") return loan @@ -92,8 +98,21 @@ class Loan(db.Model): # Update loan status and the updated_at timestamp loan.status = status - - db.session.commit() + + def to_dict(self): + """ + Convert the Loan object to a dictionary format for JSON serialization. + """ + return { + 'id': self.id, + 'customer_id': self.customer_id, + 'account_id': self.account_id, + 'offer_id': self.offer_id, + 'principal_amount': self.principal_amount, + 'status': self.status, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } def __repr__(self): return f'' \ No newline at end of file diff --git a/app/models/offer.py b/app/models/offer.py new file mode 100644 index 0000000..ce62fa2 --- /dev/null +++ b/app/models/offer.py @@ -0,0 +1,16 @@ +from datetime import datetime, timezone +from app.extensions import db + +class Offer(db.Model): + __tablename__ = 'offers' + + id = db.Column(db.Integer, 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) + tenor = 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)) + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/app/models/repayment.py b/app/models/repayment.py index 06b872c..c313131 100644 --- a/app/models/repayment.py +++ b/app/models/repayment.py @@ -38,15 +38,13 @@ class Repayment(db.Model): repayment = cls( customer_id=customer_id, - loan_id=loan.id, + loan_id=loan_id, product_id=product_id, ) try: db.session.add(repayment) - db.session.commit() except IntegrityError as err: - db.session.rollback() raise ValueError(f"Database integrity error: {err}") return repayment diff --git a/docker-compose.yml b/docker-compose.yml index 929e4cf..35a7eae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,10 @@ services: - digifi-core: + digifi-first-core: build: . env_file: - .env ports: - - "${APP_PORT:-4300}:5000" + - "${APP_PORT:-4500}:5000" environment: - FLASK_APP=${FLASK_APP} - FLASK_ENV=${FLASK_ENV} diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/1340e7e578b9_migration_on_thu_apr_10_21_50_01_utc_.py b/migrations/versions/1340e7e578b9_migration_on_thu_apr_10_21_50_01_utc_.py new file mode 100644 index 0000000..92e0eef --- /dev/null +++ b/migrations/versions/1340e7e578b9_migration_on_thu_apr_10_21_50_01_utc_.py @@ -0,0 +1,32 @@ +"""Migration on Thu Apr 10 21:50:01 UTC 2025 + +Revision ID: 1340e7e578b9 +Revises: b8f6fd76ead8 +Create Date: 2025-04-10 21:50:32.113149 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1340e7e578b9' +down_revision = 'b8f6fd76ead8' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('transactions', schema=None) as batch_op: + batch_op.add_column(sa.Column('ref_model', sa.String(length=50), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('transactions', schema=None) as batch_op: + batch_op.drop_column('ref_model') + + # ### end Alembic commands ### diff --git a/migrations/versions/b8f6fd76ead8_migration_on_thu_apr_10_16_21_45_utc_.py b/migrations/versions/b8f6fd76ead8_migration_on_thu_apr_10_16_21_45_utc_.py new file mode 100644 index 0000000..69d16e7 --- /dev/null +++ b/migrations/versions/b8f6fd76ead8_migration_on_thu_apr_10_16_21_45_utc_.py @@ -0,0 +1,86 @@ +"""Migration on Thu Apr 10 16:21:45 UTC 2025 + +Revision ID: b8f6fd76ead8 +Revises: +Create Date: 2025-04-10 16:22:15.946157 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'b8f6fd76ead8' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('repayments', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('loan_id', sa.String(length=50), nullable=False), + sa.Column('customer_id', sa.String(length=50), nullable=False), + sa.Column('product_id', sa.String(length=20), nullable=True), + 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.alter_column('id', + existing_type=sa.VARCHAR(length=50), + type_=sa.Integer(), + existing_nullable=False, + autoincrement=True, + existing_server_default=sa.text("nextval('loan_id_seq'::regclass)")) + + with op.batch_alter_table('transactions', schema=None) as batch_op: + batch_op.alter_column('channel', + existing_type=sa.VARCHAR(length=8), + type_=sa.String(length=50), + existing_nullable=False) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.DateTime(), + existing_nullable=True, + existing_server_default=sa.text('now()')) + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.DateTime(), + existing_nullable=True, + existing_server_default=sa.text('now()')) + batch_op.drop_constraint('transactions_id_key', type_='unique') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('transactions', schema=None) as batch_op: + batch_op.create_unique_constraint('transactions_id_key', ['id']) + batch_op.alter_column('updated_at', + existing_type=sa.DateTime(), + type_=postgresql.TIMESTAMP(timezone=True), + existing_nullable=True, + existing_server_default=sa.text('now()')) + batch_op.alter_column('created_at', + existing_type=sa.DateTime(), + type_=postgresql.TIMESTAMP(timezone=True), + existing_nullable=True, + existing_server_default=sa.text('now()')) + batch_op.alter_column('channel', + existing_type=sa.String(length=50), + type_=sa.VARCHAR(length=8), + existing_nullable=False) + + with op.batch_alter_table('loans', schema=None) as batch_op: + batch_op.alter_column('id', + existing_type=sa.Integer(), + type_=sa.VARCHAR(length=50), + existing_nullable=False, + autoincrement=True, + existing_server_default=sa.text("nextval('loan_id_seq'::regclass)")) + + op.drop_table('repayments') + # ### end Alembic commands ### diff --git a/scripts/enterypointone.sh b/scripts/enterypointone.sh new file mode 100644 index 0000000..fd1c55e --- /dev/null +++ b/scripts/enterypointone.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +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