from datetime import datetime
from decimal import Decimal
from enum import Enum
from hashlib import md5
from typing import Any, List, Optional, Union
from pydantic import (
AnyHttpUrl,
BaseModel,
ConfigDict,
ValidationError,
ValidationInfo,
field_validator,
validator,
)
[docs]
class MultiCardNamesEnum(str, Enum):
BRAC_VISA = "brac_visa"
DBBL_VISA = "dbbl_visa"
CITY_VISA = "city_visa"
EBL_VISA = "ebl_visa"
SBL_VISA = "sbl_visa"
BRAC_MASTER = "brac_master"
DBBL_MASTER = "dbbl_master"
CITY_MASTER = "city_master"
EBL_MASTER = "ebl_master"
SBL_MASTER = "sbl_master"
CITY_AMEX = "city_amex"
QCASH = "qcash"
DBBL_NEXUS = "dbbl_nexus"
BANK_ASIA = "bankasia"
ABBANK = "abbank"
IBBL = "ibbl"
MTBL = "mtbl"
BKASH = ("bkash",)
DBBL_MOBILE_BANKING = "dbblmobilebanking"
CITY = "city"
UPAY = "upay"
TAPNPAY = "tapnpay"
INTERNET_BANK = "internetbank"
MOBILE_BANK = "mobilebank"
OTHER_CARD = "othercard"
VISA_CARD = "visacard"
MASTER_CARD = "mastercard"
AMEX_CARD = "amexcard"
[docs]
class EMIOptionsEnum(int, Enum):
THREE_MONTHS = 3
SIX_MONTHS = 6
NINE_MONTHS = 9
[docs]
class EMIOptionsResponseEnum(str, Enum):
NONE = 0
THREE_MONTHS = 3
SIX_MONTHS = 6
NINE_MONTHS = 9
[docs]
class ShippingMethodEnum(str, Enum):
YES = "YES"
NO = "NO"
COURIER = "Courier"
[docs]
class BooleanIntEnum(int, Enum):
TRUE = 1
FALSE = 0
[docs]
class ProductProfileEnum(str, Enum):
GENERAL = "general"
PHYSICAL_GOODS = "physical-goods"
NON_PHYSICAL_GOODS = "non-physical-goods"
AIRLINE_TICKETS = "airline-tickets"
TRAVEL_VERTICAL = "travel-vertical"
TELECOM_VERTICAL = "telecom-vertical"
[docs]
class Credential(BaseModel):
store_id: str
store_passwd: str
[docs]
class CartItem(BaseModel):
"""Dataclass for cart items in PaymentInitPostData."""
product: str
quantity: int
amount: Decimal
[docs]
@field_validator("product")
def not_more_than_255(cls, v: str, info: ValidationInfo):
if not v:
raise ValueError(f"{info.field_name} can't be empty")
if len(v) > 255:
raise ValueError(f"{info.field_name} can't be more than 255 characters")
return v
[docs]
@field_validator("amount")
def valid_decimal(cls, v, info: ValidationInfo):
val = str(float(v)).split(".")
if len(val[0]) > 12 or len(val[1]) > 2:
raise ValueError(
f"{info.field_name} must have a decimal maximum of (12,2)."
)
return v
[docs]
class PaymentInitPostData(BaseModel):
"""Dataclass for session initiation post data."""
# Basic Fields
total_amount: Decimal
currency: str
tran_id: str
product_category: str
success_url: AnyHttpUrl
fail_url: AnyHttpUrl
cancel_url: AnyHttpUrl
# EMI Fields
emi_option: BooleanIntEnum = BooleanIntEnum.FALSE
# Customer Information
cus_name: str
cus_email: str
cus_add1: str
cus_city: str
cus_country: str
cus_phone: str
# Shipping Method
shipping_method: ShippingMethodEnum = ShippingMethodEnum.YES
num_of_item: int
# Product Information
product_name: str
product_category: str
product_profile: ProductProfileEnum
# Basic Fields Optional
ipn_url: Optional[str] = None
multi_card_name: Optional[MultiCardNamesEnum] = None
allowed_bin: Optional[str] = None
# EMI Optional
emi_max_inst_option: Optional[EMIOptionsEnum] = None
emi_selected_inst: Optional[EMIOptionsEnum] = None
emi_allow_only: Optional[int] = None
# Customer Optional
cus_add2: Optional[str] = None
cus_postcode: Optional[str] = None
cus_state: Optional[str] = None
cus_fax: Optional[str] = None
# Shipping Method Optional
ship_name: Optional[str] = None
ship_add1: Optional[str] = None
ship_add2: Optional[str] = None
ship_city: Optional[str] = None
ship_postcode: Optional[str] = None
ship_country: Optional[str] = None
ship_phone: Optional[str] = None
ship_state: Optional[str] = None
# Product Information Optional
hours_till_departure: Optional[str] = None
flight_type: Optional[str] = None
pnr: Optional[str] = None
journey_from_to: Optional[str] = None
third_party_booking: Optional[str] = None
hotel_name: Optional[str] = None
length_of_stay: Optional[str] = None
check_in_time: Optional[str] = None
hotel_city: Optional[str] = None
product_type: Optional[str] = None
topup_number: Optional[str] = None
country_topup: Optional[str] = None
cart: Optional[List[CartItem]] = None
product_amount: Optional[Decimal] = None
vat: Optional[Decimal] = None
discount_amount: Optional[Decimal] = None
convenience_fee: Optional[Decimal] = None
# Additional Optional
value_a: Optional[str] = None
value_b: Optional[str] = None
value_c: Optional[str] = None
value_d: Optional[str] = None
model_config = ConfigDict(arbitrary_types_allowed=True)
[docs]
@field_validator(
"ship_name",
"ship_add1",
"ship_city",
"ship_postcode",
"ship_country",
)
def validate_based_on_shipping_method(cls, v, info: ValidationInfo):
shipping = info.data.get("shipping_method") == ShippingMethodEnum.YES
if shipping:
if not v:
raise ValueError(
f"{info.field_name} should be provided if shipping_method set to 'YES'"
)
elif len(v) > 50:
raise ValueError(f"{info.field_name} can't be more than 50 characters")
if not shipping and v:
raise ValueError(
f"{info.field_name} should be omitted if shipping_method not set to 'YES'"
)
return v
[docs]
@field_validator(
"currency",
)
def not_more_than_three(cls, v, info: ValidationInfo):
if v and len(v) > 3:
raise ValueError(f"{info.field_name} can't be more than 3 characters")
return v
[docs]
@field_validator(
"tran_id",
"cus_postcode",
"hours_till_departure",
"length_of_stay",
"check_in_time",
"product_type",
"country_topup",
)
def not_more_than_thirty(cls, v, info: ValidationInfo):
if v and len(v) > 30:
raise ValueError(f"{info.field_name} can't be more than 30 characters")
return v
[docs]
@field_validator(
"product_category",
"cus_name",
"cus_email",
"cus_add1",
"cus_add2",
"cus_city",
"cus_state",
"cus_country",
"ship_add2",
"ship_state",
"pnr",
"hotel_city",
)
def not_more_than_fifty(cls, v, info: ValidationInfo):
if v and len(v) > 50:
raise ValueError(f"{info.field_name} can't be more than 50 characters")
return v
[docs]
@field_validator("product_profile", "product_category")
def not_more_than_hundred(cls, v, info: ValidationInfo):
if v and len(v) > 100:
raise ValueError(f"{info.field_name} can't be more than 100 characters")
return v
[docs]
@field_validator("topup_number")
def not_more_than_hundred_fifty(cls, v, info: ValidationInfo):
if v and len(v) > 150:
raise ValueError(f"{info.field_name} can't be more than 150 characters")
return v
[docs]
@field_validator(
"success_url",
"fail_url",
"cancel_url",
"ipn_url",
"allowed_bin",
"product_name",
"journey_from_to",
"hotel_name",
"value_a",
"value_b",
"value_c",
"value_d",
)
def not_more_than_255(cls, v, info: ValidationInfo):
if v and len(str(v)) > 255:
raise ValueError(f"{info.field_name} can't be more than 255 characters")
return v
[docs]
@field_validator(
"total_amount",
"product_amount",
"vat",
"discount_amount",
"convenience_fee",
)
def valid_decimal(cls, v, info: ValidationInfo):
val = str(float(v)).split(".")
if len(val[0]) > 10 or len(val[1]) > 2:
raise ValueError(
f"{info.field_name} must have a decimal maximum of (10,2)."
)
return v
[docs]
@field_validator("emi_allow_only")
def valid_emi_allow_only(cls, v, info: ValidationInfo):
emi = info.data.get("emi_option") == BooleanIntEnum.TRUE
if not emi and v == 1:
raise ValidationError("emi_option should be enabled to use this field")
return v
[docs]
@field_validator("num_of_item")
@classmethod
def validate_num_of_item(cls, v):
if v > 99 or v < 0:
raise ValueError(
"num_of_item should be of maximum two digits and a positive integer."
)
return v
[docs]
@field_validator(
"hours_till_departure",
"flight_type",
"pnr",
"journey_from_to",
"third_party_booking",
)
def mandatory_if_airline_tickets(cls, v, info: ValidationInfo):
is_ticket = (
info.data.get("product_profile") == ProductProfileEnum.AIRLINE_TICKETS
)
if is_ticket and not v:
raise ValueError(
f"{info.field_name} is required if product_profile is {ProductProfileEnum.AIRLINE_TICKETS}"
)
if v and not is_ticket:
raise ValueError(
f"{info.field_name} should be omitted if product_profile is {ProductProfileEnum.AIRLINE_TICKETS}"
)
return v
[docs]
@field_validator(
"hotel_name",
"length_of_stay",
"check_in_time",
"hotel_city",
)
def mandatory_if_travel_vertical(cls, v, info: ValidationInfo):
is_travel_vertical = (
info.data.get("product_profile") == ProductProfileEnum.TRAVEL_VERTICAL
)
if is_travel_vertical and not v:
raise ValueError(
f"{info.field_name} is required if product_profile is {ProductProfileEnum.TRAVEL_VERTICAL}"
)
if v and not is_travel_vertical:
raise ValueError(
f"{info.field_name} should be omitted if product_profile is {ProductProfileEnum.TRAVEL_VERTICAL}"
)
return v
[docs]
@field_validator(
"product_type",
"topup_number",
"country_topup",
)
def mandatory_if_telecom_vertical(cls, v, info: ValidationInfo):
is_telecom_vertical = (
info.data.get("product_profile") == ProductProfileEnum.TELECOM_VERTICAL
)
if is_telecom_vertical and not v:
raise ValueError(
f"{info.field_name} is required if product_profile is {ProductProfileEnum.TELECOM_VERTICAL}"
)
if v and not is_telecom_vertical:
raise ValueError(
f"{info.field_name} should be omitted if product_profile is {ProductProfileEnum.TELECOM_VERTICAL}"
)
return v
[docs]
@field_validator("cart")
@classmethod
def check_cart_items(cls, v):
for _ in v:
v.validate()
return v
[docs]
class ResponseStatusEnum(str, Enum):
SUCCESS = "SUCCESS"
FAILED = "FAILED"
[docs]
class Gateway(BaseModel):
name: str
type: str
logo: Optional[str] = None
gw: Optional[str] = None
r_flag: Optional[str] = None
redirectGatewayURL: Optional[str] = None
[docs]
class PaymentInitResponse(BaseModel):
"""Payment initiation response as a dataclass."""
status: ResponseStatusEnum
failedreason: Optional[str] = None
sessionkey: Optional[str] = None
gw: Optional[Any] = None
redirectGatewayURL: Optional[str] = None
directPaymentURLBank: Optional[str] = None
directPaymentURLCard: Optional[str] = None
directPaymentURL: Optional[str] = None
redirectGatewayURLFailed: Optional[str] = None
GatewayPageURL: Optional[str] = None
storeBanner: Optional[str] = None
storeLogo: Optional[str] = None
desc: Optional[List[Gateway]] = None
model_config = ConfigDict(arbitrary_types_allowed=True)
[docs]
class IPNOrderStatusEnum(str, Enum):
VALID = "VALID"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
UNATTEMPTED = "UNATTEMPTED"
EXPIRED = "EXPIRED"
[docs]
class OrderStatusEnum(str, Enum):
VALID = "VALID"
VALIDATED = "VALIDATED"
INVALID_TRANSACTION = "INVALID_TRANSACTION"
[docs]
class RiskLevelEnum(str, Enum):
HIGH = "1"
LOW = "0"
[docs]
class BaseOrderResponse(BaseModel):
"""Base dataclass for Order and IPN."""
tran_date: datetime
tran_id: str
val_id: str
amount: Decimal
store_amount: Decimal
card_type: str
card_no: str
currency: str
bank_tran_id: str
card_issuer: str
card_brand: str
card_issuer_country: str
card_issuer_country_code: str
currency_type: str
currency_amount: Decimal
currency_rate: Decimal
risk_level: RiskLevelEnum
risk_title: str
error: Optional[str] = None
base_fair: Optional[Decimal] = None
card_sub_brand: Optional[str] = None
value_a: Optional[str] = None
value_b: Optional[str] = None
value_c: Optional[str] = None
value_d: Optional[str] = None
[docs]
class IPNResponse(BaseOrderResponse):
"""IPN response dataclass with validation"""
store_id: str
status: IPNOrderStatusEnum
verify_sign: str
verify_key: str
verify_sign_sha2: Optional[str] = None
[docs]
def get_hash(self, credential: Credential, hasher=md5):
keys = self.verify_key.split(",")
keys.append("store_passwd")
keys = sorted(keys)
data = []
for key in keys:
if key == "store_passwd":
data.append((key, hasher(credential.store_passwd.encode()).hexdigest()))
else:
val = getattr(self, key)
if isinstance(val, Enum):
val = val.value
data.append((key, str(val)))
hash_string = "&".join(["=".join(v) for v in data])
hash_string = hasher(hash_string.encode()).hexdigest()
return hash_string
[docs]
def validate_against_credential(self, credential: Union[Credential, dict]):
if not isinstance(credential, Credential):
credential = Credential(**credential)
hash = self.get_hash(credential)
return hash == self.verify_sign
[docs]
class IPNValidationStatus(BaseModel):
"""IPN validation result's dataclass."""
status: bool
response: IPNResponse
model_config = ConfigDict(arbitrary_types_allowed=True)
[docs]
class OrderValidationPostData(BaseModel):
"""Dataclass for Order validation API post data."""
val_id: str
v: Optional[int] = None
[docs]
@field_validator("val_id")
def not_more_than_fifty(cls, v, info: ValidationInfo):
if v and len(v) > 50:
raise ValueError(f"{info.field_name} can't be more than 50 characters")
return v
[docs]
@field_validator("v")
@classmethod
def validate_v(cls, v):
if v < 0 or v > 9:
raise ValueError("v must be an one digit positive integer")
[docs]
class OrderValidationResponse(BaseOrderResponse):
"""Order validation response."""
status: OrderStatusEnum
emi_instalment: EMIOptionsResponseEnum
discount_amount: Decimal
discount_percentage: Decimal
discount_remarks: str
[docs]
class RefundRequestPostData(BaseModel):
"""Dataclass for Refund API post data."""
bank_tran_id: str
refund_amount: str
refund_remarks: str
refe_id: str
[docs]
@field_validator(
"refe_id",
)
def not_more_than_fifty(cls, v, info: ValidationInfo):
if v and len(v) > 50:
raise ValueError(f"{info.field_name} can't be more than 50 characters")
return v
[docs]
@field_validator("bank_tran_id")
def not_more_than_eighty(cls, v, info: ValidationInfo):
if v and len(v) > 80:
raise ValueError(f"{info.field_name} can't be more than 80 characters")
return v
[docs]
@field_validator("refund_remarks")
def not_more_than_255(cls, v, info: ValidationInfo):
if v and len(v) > 255:
raise ValueError(f"{info.field_name} can't be more than 255 characters")
return v
[docs]
@field_validator("refund_amount")
def valid_decimal(cls, v, info: ValidationInfo):
val = str(float(v)).split(".")
if len(val[0]) > 10 or len(val[1]) > 2:
raise ValueError(
f"{info.field_name} must have a decimal maximum of (10,2)."
)
return v
[docs]
class APIConnectEnum(str, Enum):
INVALID_REQUEST = "INVALID_REQUEST"
FAILED = "FAILED"
INACTIVE = "INACTIVE"
DONE = "DONE"
[docs]
class RefundStatusEnum(str, Enum):
SUCCESS = "success"
FAILED = "failed"
PROCESSING = "processing"
[docs]
class RefundInitiateResponse(BaseModel):
"""Refund initiation response."""
APIConnect: APIConnectEnum
bank_tran_id: str
trans_id: Optional[str] = None
refund_ref_id: Optional[str] = None
status: RefundStatusEnum
errorReason: Optional[str] = None
[docs]
class RefundResponse(RefundInitiateResponse):
"""Refund response."""
initiated_on: datetime
refunded_on: datetime
[docs]
class Session(BaseModel):
"""Dataclass for transaction session."""
status: str
tran_date: datetime
tran_id: str
val_id: str
amount: Decimal
store_amount: Decimal
card_type: str
card_no: str
bank_tran_id: str
card_issuer: str
card_brand: str
card_issuer_country: str
card_issuer_country_code: str
currency_type: str
currency_amount: Decimal
risk_level: RiskLevelEnum
risk_title: str
sessionkey: Optional[str] = None
error: Optional[str] = None
currency: Optional[str] = None
emi_instalment: Optional[Union[Decimal, str]] = None
emi_amount: Optional[Union[Decimal, str]] = None
discount_percentage: Optional[Union[Decimal, str]] = None
discount_remarks: Optional[str] = None
value_a: Optional[str] = None
value_b: Optional[str] = None
value_c: Optional[str] = None
value_d: Optional[str] = None
[docs]
class TransactionBySessionResponse(Session):
"""Dataclass for transaction by session query."""
APIConnect: APIConnectEnum
[docs]
class TransactionsByIDResponse(BaseModel):
"""Dataclass for transactions by ID query."""
APIConnect: APIConnectEnum
no_of_trans_found: int
element: List[Session]
[docs]
class APIResponse(BaseModel):
"""dataclass for api response complete with raw response data, status_code and one of response objects for easy introspection."""
raw_data: Any = None
status_code: int
response: Optional[
Union[
OrderValidationResponse,
IPNResponse,
PaymentInitResponse,
RefundResponse,
RefundInitiateResponse,
TransactionBySessionResponse,
TransactionsByIDResponse,
]
] = None
model_config = ConfigDict(arbitrary_types_allowed=True)