Added offer and charge

This commit is contained in:
Azeez Muibi
2025-04-30 12:48:34 +01:00
parent d1b9d80c84
commit 9bec2b2e9f
15 changed files with 775 additions and 28 deletions
+36 -1
View File
@@ -8,6 +8,8 @@ from app.api.services.loan_service import LoanService
from app.api.services.transaction_service import TransactionService from app.api.services.transaction_service import TransactionService
from app.api.services.auth_service import AuthService from app.api.services.auth_service import AuthService
from app.api.services.dashboard_service import DashboardService from app.api.services.dashboard_service import DashboardService
from app.api.services.offer_service import OfferService
from app.api.services.charge_service import ChargeService
from functools import wraps from functools import wraps
from app.utils.logger import logger from app.utils.logger import logger
from app.api.middlewares import enforce_json, require_auth from app.api.middlewares import enforce_json, require_auth
@@ -183,6 +185,7 @@ def get_all_repayment_schedules():
filters = { filters = {
'loan_id': request.args.get('loan_id'), 'loan_id': request.args.get('loan_id'),
'product_id': request.args.get('product_id'), 'product_id': request.args.get('product_id'),
'transaction_id': request.args.get('transaction_id'),
'paid': request.args.get('paid'), 'paid': request.args.get('paid'),
'due_before': request.args.get('due_before'), 'due_before': request.args.get('due_before'),
'due_after': request.args.get('due_after'), 'due_after': request.args.get('due_after'),
@@ -192,4 +195,36 @@ def get_all_repayment_schedules():
} }
# logger.info(f"Get repayment schedules request received with filters: {filters}") # logger.info(f"Get repayment schedules request received with filters: {filters}")
response = LoanRepaymentScheduleService.get_all_repayment_schedules(filters) response = LoanRepaymentScheduleService.get_all_repayment_schedules(filters)
return response return response
@api.route('/offers', methods=['GET'])
# @token_required
def get_all_offers():
# Extract query parameters for filtering
filters = {
'id': request.args.get('id'),
'product_id': request.args.get('product_id'),
'start_date': request.args.get('start_date'),
'end_date': request.args.get('end_date'),
'page': request.args.get('page', 1),
'limit': request.args.get('limit', 20)
}
# logger.info(f"Get offers request received with filters: {filters}")
response = OfferService.get_all_offers(filters)
return jsonify(response)
@api.route('/charges', methods=['GET'])
# @token_required
def get_all_charges():
# Extract query parameters for filtering
filters = {
'offer_id': request.args.get('offer_id'),
'code': request.args.get('code'),
'start_date': request.args.get('start_date'),
'end_date': request.args.get('end_date'),
'page': request.args.get('page', 1),
'limit': request.args.get('limit', 20)
}
# logger.info(f"Get charges request received with filters: {filters}")
response = ChargeService.get_all_charges(filters)
return jsonify(response)
+88
View File
@@ -0,0 +1,88 @@
import logging
from datetime import datetime
from flask import jsonify
from app.models.charge import Charge
# Configure logging
logger = logging.getLogger(__name__)
class ChargeService:
"""
Service class for handling charge-related operations.
"""
@staticmethod
def get_all_charges(filters=None):
"""
Get all charges with optional filtering.
Args:
filters (dict, optional): Filters for the charges query.
Returns:
dict: A standardized response with charges data.
"""
try:
if filters is None:
filters = {}
# Extract filters
offer_id = filters.get('offer_id')
code = filters.get('code')
start_date = filters.get('start_date')
end_date = filters.get('end_date')
# Extract pagination parameters
page = int(filters.get('page', 1))
limit = int(filters.get('limit', 20))
# Ensure page and limit are valid
if page < 1:
page = 1
if limit < 1 or limit > 100:
limit = 20
# Convert string dates to datetime objects if provided
if start_date and isinstance(start_date, str):
start_date = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
if end_date and isinstance(end_date, str):
end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
# Get charges with optional filters and pagination
charges, total_count = Charge.get_all_charges(
offer_id=offer_id,
code=code,
start_date=start_date,
end_date=end_date,
page=page,
limit=limit
)
# Convert charges to dictionary format
charges_data = []
for charge in charges:
charges_data.append(charge.to_dict())
# Calculate total pages
total_pages = (total_count + limit - 1) // limit
response_data = {
'charges': charges_data,
'count': len(charges_data),
'pagination': {
'total_count': total_count,
'total_pages': total_pages,
'current_page': page,
'limit': limit,
'has_next': page < total_pages,
'has_prev': page > 1
}
}
return response_data
except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True)
return jsonify({
"message": "Internal Server Error"
}), 500
@@ -28,6 +28,7 @@ class LoanRepaymentScheduleService:
# Extract filters # Extract filters
loan_id = filters.get('loan_id') loan_id = filters.get('loan_id')
transaction_id = filters.get('transaction_id')
product_id = filters.get('product_id') product_id = filters.get('product_id')
paid = filters.get('paid') paid = filters.get('paid')
due_before = filters.get('due_before') due_before = filters.get('due_before')
@@ -57,6 +58,7 @@ class LoanRepaymentScheduleService:
# Get loan repayment schedules with optional filters and pagination # Get loan repayment schedules with optional filters and pagination
schedules, total_count = LoanRepaymentSchedule.get_all_repayment_schedules( schedules, total_count = LoanRepaymentSchedule.get_all_repayment_schedules(
loan_id=loan_id, loan_id=loan_id,
transaction_id=transaction_id,
product_id=product_id, product_id=product_id,
paid=paid, paid=paid,
due_before=due_before, due_before=due_before,
@@ -73,6 +75,7 @@ class LoanRepaymentScheduleService:
'id': schedule.id, 'id': schedule.id,
'loan_id': schedule.loan_id, 'loan_id': schedule.loan_id,
'product_id': schedule.product_id, 'product_id': schedule.product_id,
'transaction_id': schedule.transaction_id,
'installment_number': schedule.installment_number, 'installment_number': schedule.installment_number,
'due_date': schedule.due_date.isoformat() if schedule.due_date else None, 'due_date': schedule.due_date.isoformat() if schedule.due_date else None,
'installment_amount': schedule.installment_amount, 'installment_amount': schedule.installment_amount,
+88
View File
@@ -0,0 +1,88 @@
import logging
from datetime import datetime
from flask import jsonify
from app.models.offer import Offer
# Configure logging
logger = logging.getLogger(__name__)
class OfferService:
"""
Service class for handling offer-related operations.
"""
@staticmethod
def get_all_offers(filters=None):
"""
Get all offers with optional filtering.
Args:
filters (dict, optional): Filters for the offers query.
Returns:
dict: A standardized response with offers data.
"""
try:
if filters is None:
filters = {}
# Extract filters
id = filters.get('id')
product_id = filters.get('product_id')
start_date = filters.get('start_date')
end_date = filters.get('end_date')
# Extract pagination parameters
page = int(filters.get('page', 1))
limit = int(filters.get('limit', 20))
# Ensure page and limit are valid
if page < 1:
page = 1
if limit < 1 or limit > 100:
limit = 20
# Convert string dates to datetime objects if provided
if start_date and isinstance(start_date, str):
start_date = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
if end_date and isinstance(end_date, str):
end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
# Get offers with optional filters and pagination
offers, total_count = Offer.get_all_offers(
id=id,
product_id=product_id,
start_date=start_date,
end_date=end_date,
page=page,
limit=limit
)
# Convert offers to dictionary format
offers_data = []
for offer in offers:
offers_data.append(offer.to_dict())
# Calculate total pages
total_pages = (total_count + limit - 1) // limit
response_data = {
'offers': offers_data,
'count': len(offers_data),
'pagination': {
'total_count': total_count,
'total_pages': total_pages,
'current_page': page,
'limit': limit,
'has_next': page < total_pages,
'has_prev': page > 1
}
}
return response_data
except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True)
return jsonify({
"message": "Internal Server Error"
}), 500
+3 -1
View File
@@ -6,5 +6,7 @@ from .user import User
from .repayment import Repayment from .repayment import Repayment
from .loan_charge import LoanCharge from .loan_charge import LoanCharge
from .loan_repayment_schedule import LoanRepaymentSchedule from .loan_repayment_schedule import LoanRepaymentSchedule
from .charge import Charge
from .offer import Offer
__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'User', 'Repayment', 'LoanCharge', 'LoanRepaymentSchedule'] __all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'User', 'Repayment', 'LoanCharge', 'LoanRepaymentSchedule', 'Charge', 'Offer']
+82
View File
@@ -0,0 +1,82 @@
from datetime import datetime, timezone
from app.extensions import db
from sqlalchemy.orm import relationship
class Charge(db.Model):
__tablename__ = 'charges'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
offer_id = db.Column(db.String, db.ForeignKey('offers.id'), nullable=False)
code = db.Column(db.String(50), nullable=False)
percent = db.Column(db.Float, nullable=False)
description = db.Column(db.String(255), nullable=True)
due = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
offer = relationship(
"Offer",
back_populates="charges",
foreign_keys=[offer_id]
)
@classmethod
def get_all_charges(cls, offer_id=None, code=None, start_date=None, end_date=None, page=1, limit=20):
"""
Get all charges with optional filtering
Args:
offer_id (str, optional): Filter by offer ID
code (str, optional): Filter by charge code
start_date (datetime, optional): Filter by start date (created_at)
end_date (datetime, optional): Filter by end date (created_at)
page (int, optional): Page number for pagination
limit (int, optional): Number of items per page
Returns:
tuple: (list of Charge objects, total count)
"""
query = cls.query
# Apply filters if provided
if offer_id:
query = query.filter(cls.offer_id == offer_id)
if code:
query = query.filter(cls.code == code)
if start_date:
query = query.filter(cls.created_at >= start_date)
if end_date:
query = query.filter(cls.created_at <= end_date)
# Order by created_at descending (newest first)
query = query.order_by(cls.created_at.desc())
# Get total count before pagination
total_count = query.count()
# Apply pagination
offset = (page - 1) * limit
query = query.limit(limit).offset(offset)
return query.all(), total_count
def to_dict(self):
"""
Convert the Charge object to a dictionary format for JSON serialization.
"""
return {
'id': self.id,
'offer_id': self.offer_id,
'code': self.code,
'percent': self.percent,
'description': self.description,
'due': self.due,
'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'<Charge {self.id} - {self.code}>'
+6 -1
View File
@@ -28,7 +28,7 @@ class LoanRepaymentSchedule(db.Model):
# ) # )
@classmethod @classmethod
def get_all_repayment_schedules(cls, loan_id=None, product_id=None, paid=None, def get_all_repayment_schedules(cls, loan_id=None, product_id=None, transaction_id=None, paid=None,
due_before=None, due_after=None, installment_number=None, due_before=None, due_after=None, installment_number=None,
page=1, limit=20): page=1, limit=20):
""" """
@@ -37,6 +37,7 @@ class LoanRepaymentSchedule(db.Model):
Args: Args:
loan_id (int, optional): Filter by loan ID loan_id (int, optional): Filter by loan ID
product_id (str, optional): Filter by product ID product_id (str, optional): Filter by product ID
transaction_id (str, optional): Filter by transaction ID
paid (bool, optional): Filter by paid status paid (bool, optional): Filter by paid status
due_before (datetime, optional): Filter schedules due before this date due_before (datetime, optional): Filter schedules due before this date
due_after (datetime, optional): Filter schedules due after this date due_after (datetime, optional): Filter schedules due after this date
@@ -56,6 +57,9 @@ class LoanRepaymentSchedule(db.Model):
if product_id: if product_id:
query = query.filter(cls.product_id == product_id) query = query.filter(cls.product_id == product_id)
if transaction_id:
query = query.filter(cls.transaction_id == transaction_id)
if paid is not None: if paid is not None:
query = query.filter(cls.paid == paid) query = query.filter(cls.paid == paid)
@@ -85,6 +89,7 @@ class LoanRepaymentSchedule(db.Model):
'id': self.id, 'id': self.id,
'loan_id': self.loan_id, 'loan_id': self.loan_id,
'product_id': self.product_id, 'product_id': self.product_id,
'transaction_id': self.transaction_id,
'installment_number': self.installment_number, 'installment_number': self.installment_number,
'due_date': self.due_date.isoformat() if self.due_date else None, 'due_date': self.due_date.isoformat() if self.due_date else None,
'installment_amount': self.installment_amount, 'installment_amount': self.installment_amount,
+60 -25
View File
@@ -1,8 +1,8 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from app.extensions import db from app.extensions import db
from app.models.charge import Charge
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
class Offer(db.Model): class Offer(db.Model):
__tablename__ = 'offers' __tablename__ = 'offers'
@@ -22,45 +22,80 @@ class Offer(db.Model):
charges = relationship( charges = relationship(
"Charge", "Charge",
primaryjoin="Offer.id == Charge.offer_id",
foreign_keys="Charge.offer_id",
back_populates="offer", back_populates="offer",
cascade="all, delete-orphan"
) )
@classmethod @classmethod
def get_all_offers(cls): def get_all_offers(cls, id=None, product_id=None, start_date=None, end_date=None, page=1, limit=20):
""" """
Return all offers in dictionary format. Get all offers with optional filtering
"""
offers = cls.query.all() Args:
id (str, optional): Filter by offer ID
product_id (str, optional): Filter by product ID
start_date (datetime, optional): Filter by start date (created_at)
end_date (datetime, optional): Filter by end date (created_at)
page (int, optional): Page number for pagination
limit (int, optional): Number of items per page
Returns:
tuple: (list of Offer objects, total count)
"""
query = cls.query
# Apply filters if provided
if id:
query = query.filter(cls.id == id)
if product_id:
query = query.filter(cls.product_id == product_id)
if start_date:
query = query.filter(cls.created_at >= start_date)
if end_date:
query = query.filter(cls.created_at <= end_date)
# Order by list_order ascending (if available) or created_at descending
query = query.order_by(cls.list_order.asc(), cls.created_at.desc())
# Get total count before pagination
total_count = query.count()
# Apply pagination
offset = (page - 1) * limit
query = query.limit(limit).offset(offset)
return query.all(), total_count
if not offers:
raise ValueError(f"No available offers")
return offers
@classmethod @classmethod
def is_valid_offer(cls, offer_id): def is_valid_offer(cls, offer_id):
offer = cls.query.filter_by(id=str(offer_id)).first() offer = cls.query.filter_by(id=str(offer_id)).first()
if not offer: if not offer:
return False return False
return offer return offer
def to_dict(self): def to_dict(self):
"""
Convert the Offer object to a dictionary format for JSON serialization.
"""
return { return {
"offerId": self.id, 'id': self.id,
"productId": self.product_id, 'product_id': self.product_id,
"minAmount": self.min_amount, 'min_amount': self.min_amount,
"maxAmount": self.max_amount, 'max_amount': self.max_amount,
"tenor": self.tenor, 'tenor': self.tenor,
"interest_rate": self.interest_rate, 'schedule': self.schedule,
"management_rate": self.management_rate, 'interest_rate': self.interest_rate,
"insurance_rate": self.insurance_rate, 'management_rate': self.management_rate,
"vat_rate": self.vat_rate 'insurance_rate': self.insurance_rate,
'vat_rate': self.vat_rate,
'list_order': self.list_order,
'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): def __repr__(self):
return f'<LoanOffer {self.id}>' return f'<Offer {self.id}>'
+12
View File
@@ -113,6 +113,12 @@
}, },
"/repayment-schedules": { "/repayment-schedules": {
"$ref": "../swagger/paths/RepaymentSchedules.json" "$ref": "../swagger/paths/RepaymentSchedules.json"
},
"/offers": {
"$ref": "../swagger/paths/Offers.json"
},
"/charges": {
"$ref": "../swagger/paths/Charges.json"
} }
}, },
"components": { "components": {
@@ -149,6 +155,12 @@
}, },
"RepaymentSchedulesResponse": { "RepaymentSchedulesResponse": {
"$ref": "../swagger/schemas/RepaymentSchedulesResponse.json" "$ref": "../swagger/schemas/RepaymentSchedulesResponse.json"
},
"OffersResponse": {
"$ref": "../swagger/schemas/OffersResponse.json"
},
"ChargesResponse": {
"$ref": "../swagger/schemas/ChargesResponse.json"
} }
}, },
"securitySchemes": { "securitySchemes": {
+95
View File
@@ -0,0 +1,95 @@
{
"get": {
"tags": ["Charges"],
"summary": "Get all charges with optional filtering",
"description": "Retrieve charges with various filter options including offer ID, charge code, etc.",
"operationId": "getCharges",
"parameters": [
{
"name": "offer_id",
"in": "query",
"description": "Filter by offer ID",
"required": false,
"schema": {
"type": "string"
},
"example": "SAL30"
},
{
"name": "code",
"in": "query",
"description": "Filter by charge code",
"required": false,
"schema": {
"type": "string"
},
"example": "INTEREST"
},
{
"name": "start_date",
"in": "query",
"description": "Filter by start date (ISO format)",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
},
"example": "2023-01-01T00:00:00Z"
},
{
"name": "end_date",
"in": "query",
"description": "Filter by end date (ISO format)",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
},
"example": "2023-12-31T23:59:59Z"
},
{
"name": "page",
"in": "query",
"description": "Page number for pagination",
"required": false,
"schema": {
"type": "integer",
"default": 1,
"minimum": 1
},
"example": 1
},
{
"name": "limit",
"in": "query",
"description": "Number of items per page (max 100)",
"required": false,
"schema": {
"type": "integer",
"default": 20,
"minimum": 1,
"maximum": 100
},
"example": 20
}
],
"responses": {
"200": {
"description": "Successful operation",
"content": {
"application/json": {
"schema": {
"$ref": "../schemas/ChargesResponse.json"
}
}
}
},
"400": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
}
}
+95
View File
@@ -0,0 +1,95 @@
{
"get": {
"tags": ["Offers"],
"summary": "Get all offers with optional filtering",
"description": "Retrieve offers with various filter options including offer ID, product ID, etc.",
"operationId": "getOffers",
"parameters": [
{
"name": "id",
"in": "query",
"description": "Filter by offer ID",
"required": false,
"schema": {
"type": "string"
},
"example": "SAL30"
},
{
"name": "product_id",
"in": "query",
"description": "Filter by product ID",
"required": false,
"schema": {
"type": "string"
},
"example": "2030"
},
{
"name": "start_date",
"in": "query",
"description": "Filter by start date (ISO format)",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
},
"example": "2023-01-01T00:00:00Z"
},
{
"name": "end_date",
"in": "query",
"description": "Filter by end date (ISO format)",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
},
"example": "2023-12-31T23:59:59Z"
},
{
"name": "page",
"in": "query",
"description": "Page number for pagination",
"required": false,
"schema": {
"type": "integer",
"default": 1,
"minimum": 1
},
"example": 1
},
{
"name": "limit",
"in": "query",
"description": "Number of items per page (max 100)",
"required": false,
"schema": {
"type": "integer",
"default": 20,
"minimum": 1,
"maximum": 100
},
"example": 20
}
],
"responses": {
"200": {
"description": "Successful operation",
"content": {
"application/json": {
"schema": {
"$ref": "../schemas/OffersResponse.json"
}
}
}
},
"400": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
}
}
+10
View File
@@ -25,6 +25,16 @@
}, },
"example": "101" "example": "101"
}, },
{
"name": "transaction_id",
"in": "query",
"description": "Filter by transaction ID",
"required": false,
"schema": {
"type": "string"
},
"example": "TRX789"
},
{ {
"name": "paid", "name": "paid",
"in": "query", "in": "query",
+84
View File
@@ -0,0 +1,84 @@
{
"type": "object",
"properties": {
"charges": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"example": 4
},
"offer_id": {
"type": "string",
"example": "SAL30"
},
"code": {
"type": "string",
"example": "INTEREST"
},
"percent": {
"type": "number",
"format": "float",
"example": 1.1
},
"description": {
"type": "string",
"example": "This is fee 000"
},
"due": {
"type": "integer",
"example": 0
},
"created_at": {
"type": "string",
"format": "date-time",
"example": "2023-01-15T10:30:00Z"
},
"updated_at": {
"type": "string",
"format": "date-time",
"example": "2023-01-15T10:30:00Z"
}
}
}
},
"count": {
"type": "integer",
"example": 1
},
"pagination": {
"type": "object",
"properties": {
"total_count": {
"type": "integer",
"example": 100
},
"total_pages": {
"type": "integer",
"example": 5
},
"current_page": {
"type": "integer",
"example": 1
},
"limit": {
"type": "integer",
"example": 20
},
"has_next": {
"type": "boolean",
"example": true
},
"has_prev": {
"type": "boolean",
"example": false
}
}
}
},
"xml": {
"name": "ChargesResponse"
}
}
+109
View File
@@ -0,0 +1,109 @@
{
"type": "object",
"properties": {
"offers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"example": "SAL30"
},
"product_id": {
"type": "string",
"example": "2030"
},
"min_amount": {
"type": "number",
"format": "float",
"example": 3000.0
},
"max_amount": {
"type": "number",
"format": "float",
"example": 1000000.0
},
"tenor": {
"type": "integer",
"example": 30
},
"schedule": {
"type": "integer",
"example": 1
},
"interest_rate": {
"type": "number",
"format": "float",
"example": 3.0
},
"management_rate": {
"type": "number",
"format": "float",
"example": 1.0
},
"insurance_rate": {
"type": "number",
"format": "float",
"example": 1.0
},
"vat_rate": {
"type": "number",
"format": "float",
"example": 7.5
},
"list_order": {
"type": "integer",
"example": 1
},
"created_at": {
"type": "string",
"format": "date-time",
"example": "2023-01-15T10:30:00Z"
},
"updated_at": {
"type": "string",
"format": "date-time",
"example": "2023-01-15T10:30:00Z"
}
}
}
},
"count": {
"type": "integer",
"example": 1
},
"pagination": {
"type": "object",
"properties": {
"total_count": {
"type": "integer",
"example": 100
},
"total_pages": {
"type": "integer",
"example": 5
},
"current_page": {
"type": "integer",
"example": 1
},
"limit": {
"type": "integer",
"example": 20
},
"has_next": {
"type": "boolean",
"example": true
},
"has_prev": {
"type": "boolean",
"example": false
}
}
}
},
"xml": {
"name": "OffersResponse"
}
}
@@ -18,6 +18,10 @@
"type": "string", "type": "string",
"example": "101" "example": "101"
}, },
"transaction_id": {
"type": "string",
"example": "TRX123456"
},
"installment_number": { "installment_number": {
"type": "integer", "type": "integer",
"example": 1 "example": 1