feat(billing): add missing payment methods and configurations
- Added migration to include global payment method configurations for Stripe and PayPal (both disabled). - Ensured existing payment methods like bank transfer and manual payment are correctly configured. - Added database constraints and indexes for improved data integrity in billing models. - Introduced foreign key relationship between CreditTransaction and Payment models. - Added webhook configuration fields to PaymentMethodConfig for future payment gateway integrations. - Updated SignUpFormUnified component to handle payment method selection based on user country and plan. - Implemented PaymentHistory component to display user's payment history with status indicators.
This commit is contained in:
213
backend/igny8_core/business/billing/utils/currency.py
Normal file
213
backend/igny8_core/business/billing/utils/currency.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Currency utilities for billing
|
||||
Maps countries to their currencies based on Stripe/PayPal standards
|
||||
"""
|
||||
|
||||
# Country to currency mapping (Stripe/PayPal standard format)
|
||||
COUNTRY_CURRENCY_MAP = {
|
||||
# North America
|
||||
'US': 'USD',
|
||||
'CA': 'CAD',
|
||||
'MX': 'MXN',
|
||||
|
||||
# Europe
|
||||
'GB': 'GBP',
|
||||
'DE': 'EUR',
|
||||
'FR': 'EUR',
|
||||
'IT': 'EUR',
|
||||
'ES': 'EUR',
|
||||
'NL': 'EUR',
|
||||
'BE': 'EUR',
|
||||
'AT': 'EUR',
|
||||
'PT': 'EUR',
|
||||
'IE': 'EUR',
|
||||
'GR': 'EUR',
|
||||
'FI': 'EUR',
|
||||
'LU': 'EUR',
|
||||
'CH': 'CHF',
|
||||
'NO': 'NOK',
|
||||
'SE': 'SEK',
|
||||
'DK': 'DKK',
|
||||
'PL': 'PLN',
|
||||
'CZ': 'CZK',
|
||||
'HU': 'HUF',
|
||||
'RO': 'RON',
|
||||
|
||||
# Asia Pacific
|
||||
'IN': 'INR',
|
||||
'PK': 'PKR',
|
||||
'BD': 'BDT',
|
||||
'LK': 'LKR',
|
||||
'JP': 'JPY',
|
||||
'CN': 'CNY',
|
||||
'HK': 'HKD',
|
||||
'SG': 'SGD',
|
||||
'MY': 'MYR',
|
||||
'TH': 'THB',
|
||||
'ID': 'IDR',
|
||||
'PH': 'PHP',
|
||||
'VN': 'VND',
|
||||
'KR': 'KRW',
|
||||
'TW': 'TWD',
|
||||
'AU': 'AUD',
|
||||
'NZ': 'NZD',
|
||||
|
||||
# Middle East
|
||||
'AE': 'AED',
|
||||
'SA': 'SAR',
|
||||
'QA': 'QAR',
|
||||
'KW': 'KWD',
|
||||
'BH': 'BHD',
|
||||
'OM': 'OMR',
|
||||
'IL': 'ILS',
|
||||
'TR': 'TRY',
|
||||
|
||||
# Africa
|
||||
'ZA': 'ZAR',
|
||||
'NG': 'NGN',
|
||||
'KE': 'KES',
|
||||
'EG': 'EGP',
|
||||
'MA': 'MAD',
|
||||
|
||||
# South America
|
||||
'BR': 'BRL',
|
||||
'AR': 'ARS',
|
||||
'CL': 'CLP',
|
||||
'CO': 'COP',
|
||||
'PE': 'PEN',
|
||||
}
|
||||
|
||||
# Default currency fallback
|
||||
DEFAULT_CURRENCY = 'USD'
|
||||
|
||||
|
||||
def get_currency_for_country(country_code: str) -> str:
|
||||
"""
|
||||
Get currency code for a given country code.
|
||||
|
||||
Args:
|
||||
country_code: ISO 2-letter country code (e.g., 'US', 'GB', 'IN')
|
||||
|
||||
Returns:
|
||||
Currency code (e.g., 'USD', 'GBP', 'INR')
|
||||
"""
|
||||
if not country_code:
|
||||
return DEFAULT_CURRENCY
|
||||
|
||||
country_code = country_code.upper().strip()
|
||||
return COUNTRY_CURRENCY_MAP.get(country_code, DEFAULT_CURRENCY)
|
||||
|
||||
|
||||
def get_currency_symbol(currency_code: str) -> str:
|
||||
"""
|
||||
Get currency symbol for a given currency code.
|
||||
|
||||
Args:
|
||||
currency_code: Currency code (e.g., 'USD', 'GBP', 'INR')
|
||||
|
||||
Returns:
|
||||
Currency symbol (e.g., '$', '£', '₹')
|
||||
"""
|
||||
CURRENCY_SYMBOLS = {
|
||||
'USD': '$',
|
||||
'EUR': '€',
|
||||
'GBP': '£',
|
||||
'INR': '₹',
|
||||
'JPY': '¥',
|
||||
'CNY': '¥',
|
||||
'AUD': 'A$',
|
||||
'CAD': 'C$',
|
||||
'CHF': 'Fr',
|
||||
'SEK': 'kr',
|
||||
'NOK': 'kr',
|
||||
'DKK': 'kr',
|
||||
'PLN': 'zł',
|
||||
'BRL': 'R$',
|
||||
'ZAR': 'R',
|
||||
'AED': 'د.إ',
|
||||
'SAR': 'ر.س',
|
||||
'PKR': '₨',
|
||||
}
|
||||
|
||||
return CURRENCY_SYMBOLS.get(currency_code, currency_code + ' ')
|
||||
|
||||
|
||||
# Currency multipliers for countries with payment methods configured
|
||||
# These represent approximate exchange rates to USD
|
||||
CURRENCY_MULTIPLIERS = {
|
||||
'USD': 1.0, # United States
|
||||
'GBP': 0.79, # United Kingdom
|
||||
'INR': 83.0, # India
|
||||
'PKR': 278.0, # Pakistan
|
||||
'CAD': 1.35, # Canada
|
||||
'AUD': 1.52, # Australia
|
||||
'EUR': 0.92, # Germany, France (Eurozone)
|
||||
}
|
||||
|
||||
# Map countries to their multipliers
|
||||
COUNTRY_MULTIPLIERS = {
|
||||
'US': CURRENCY_MULTIPLIERS['USD'],
|
||||
'GB': CURRENCY_MULTIPLIERS['GBP'],
|
||||
'IN': CURRENCY_MULTIPLIERS['INR'],
|
||||
'PK': CURRENCY_MULTIPLIERS['PKR'],
|
||||
'CA': CURRENCY_MULTIPLIERS['CAD'],
|
||||
'AU': CURRENCY_MULTIPLIERS['AUD'],
|
||||
'DE': CURRENCY_MULTIPLIERS['EUR'],
|
||||
'FR': CURRENCY_MULTIPLIERS['EUR'],
|
||||
}
|
||||
|
||||
|
||||
def get_currency_multiplier(country_code: str) -> float:
|
||||
"""
|
||||
Get currency multiplier for a given country code.
|
||||
Used to convert USD prices to local currency.
|
||||
|
||||
Args:
|
||||
country_code: ISO 2-letter country code (e.g., 'US', 'GB', 'IN')
|
||||
|
||||
Returns:
|
||||
Multiplier float (e.g., 1.0 for USD, 83.0 for INR)
|
||||
"""
|
||||
if not country_code:
|
||||
return 1.0
|
||||
|
||||
country_code = country_code.upper().strip()
|
||||
return COUNTRY_MULTIPLIERS.get(country_code, 1.0)
|
||||
|
||||
|
||||
def convert_usd_to_local(usd_amount: float, country_code: str) -> float:
|
||||
"""
|
||||
Convert USD amount to local currency for given country.
|
||||
|
||||
Args:
|
||||
usd_amount: Amount in USD
|
||||
country_code: ISO 2-letter country code
|
||||
|
||||
Returns:
|
||||
Amount in local currency
|
||||
"""
|
||||
multiplier = get_currency_multiplier(country_code)
|
||||
return round(usd_amount * multiplier, 2)
|
||||
|
||||
|
||||
def format_currency(amount: float, country_code: str = 'US') -> str:
|
||||
"""
|
||||
Format amount with appropriate currency symbol.
|
||||
|
||||
Args:
|
||||
amount: Numeric amount
|
||||
country_code: ISO 2-letter country code
|
||||
|
||||
Returns:
|
||||
Formatted string (e.g., '$99.00', '₹8,300.00')
|
||||
"""
|
||||
currency = get_currency_for_country(country_code)
|
||||
symbol = get_currency_symbol(currency)
|
||||
|
||||
# Format with commas for thousands
|
||||
if amount >= 1000:
|
||||
formatted = f"{amount:,.2f}"
|
||||
else:
|
||||
formatted = f"{amount:.2f}"
|
||||
|
||||
return f"{symbol}{formatted}"
|
||||
186
backend/igny8_core/business/billing/utils/errors.py
Normal file
186
backend/igny8_core/business/billing/utils/errors.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Standardized Error Response Utilities
|
||||
Ensures consistent error formats across the billing module
|
||||
"""
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
|
||||
class ErrorCode:
|
||||
"""Standardized error codes for billing module"""
|
||||
# Payment errors
|
||||
PAYMENT_NOT_FOUND = 'payment_not_found'
|
||||
PAYMENT_ALREADY_PROCESSED = 'payment_already_processed'
|
||||
PAYMENT_AMOUNT_MISMATCH = 'payment_amount_mismatch'
|
||||
PAYMENT_METHOD_NOT_AVAILABLE = 'payment_method_not_available'
|
||||
|
||||
# Invoice errors
|
||||
INVOICE_NOT_FOUND = 'invoice_not_found'
|
||||
INVOICE_ALREADY_PAID = 'invoice_already_paid'
|
||||
INVOICE_VOIDED = 'invoice_voided'
|
||||
INVOICE_EXPIRED = 'invoice_expired'
|
||||
|
||||
# Subscription errors
|
||||
SUBSCRIPTION_NOT_FOUND = 'subscription_not_found'
|
||||
SUBSCRIPTION_INACTIVE = 'subscription_inactive'
|
||||
SUBSCRIPTION_ALREADY_EXISTS = 'subscription_already_exists'
|
||||
|
||||
# Credit errors
|
||||
INSUFFICIENT_CREDITS = 'insufficient_credits'
|
||||
INVALID_CREDIT_PACKAGE = 'invalid_credit_package'
|
||||
|
||||
# Validation errors
|
||||
VALIDATION_ERROR = 'validation_error'
|
||||
MISSING_REQUIRED_FIELD = 'missing_required_field'
|
||||
INVALID_STATUS_TRANSITION = 'invalid_status_transition'
|
||||
|
||||
# Authorization errors
|
||||
UNAUTHORIZED = 'unauthorized'
|
||||
FORBIDDEN = 'forbidden'
|
||||
|
||||
# System errors
|
||||
INTERNAL_ERROR = 'internal_error'
|
||||
TIMEOUT = 'timeout'
|
||||
RATE_LIMITED = 'rate_limited'
|
||||
|
||||
|
||||
def error_response(
|
||||
message: str,
|
||||
code: str = ErrorCode.INTERNAL_ERROR,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
field_errors: Optional[Dict[str, List[str]]] = None,
|
||||
status_code: int = status.HTTP_400_BAD_REQUEST
|
||||
) -> Response:
|
||||
"""
|
||||
Create standardized error response
|
||||
|
||||
Args:
|
||||
message: Human-readable error message
|
||||
code: Error code from ErrorCode class
|
||||
details: Additional error context
|
||||
field_errors: Field-specific validation errors
|
||||
status_code: HTTP status code
|
||||
|
||||
Returns:
|
||||
DRF Response with standardized error format
|
||||
"""
|
||||
response_data = {
|
||||
'success': False,
|
||||
'error': {
|
||||
'code': code,
|
||||
'message': message,
|
||||
}
|
||||
}
|
||||
|
||||
if details:
|
||||
response_data['error']['details'] = details
|
||||
|
||||
if field_errors:
|
||||
response_data['error']['field_errors'] = field_errors
|
||||
|
||||
return Response(response_data, status=status_code)
|
||||
|
||||
|
||||
def success_response(
|
||||
data: Any = None,
|
||||
message: Optional[str] = None,
|
||||
status_code: int = status.HTTP_200_OK
|
||||
) -> Response:
|
||||
"""
|
||||
Create standardized success response
|
||||
|
||||
Args:
|
||||
data: Response data
|
||||
message: Optional success message
|
||||
status_code: HTTP status code
|
||||
|
||||
Returns:
|
||||
DRF Response with standardized success format
|
||||
"""
|
||||
response_data = {
|
||||
'success': True,
|
||||
}
|
||||
|
||||
if message:
|
||||
response_data['message'] = message
|
||||
|
||||
if data is not None:
|
||||
response_data['data'] = data
|
||||
|
||||
return Response(response_data, status=status_code)
|
||||
|
||||
|
||||
def validation_error_response(
|
||||
field_errors: Dict[str, List[str]],
|
||||
message: str = 'Validation failed'
|
||||
) -> Response:
|
||||
"""
|
||||
Create validation error response
|
||||
|
||||
Args:
|
||||
field_errors: Dictionary mapping field names to error messages
|
||||
message: General validation error message
|
||||
|
||||
Returns:
|
||||
DRF Response with validation error format
|
||||
"""
|
||||
return error_response(
|
||||
message=message,
|
||||
code=ErrorCode.VALIDATION_ERROR,
|
||||
field_errors=field_errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
def not_found_response(
|
||||
resource: str,
|
||||
identifier: Any = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create not found error response
|
||||
|
||||
Args:
|
||||
resource: Resource type (e.g., 'Payment', 'Invoice')
|
||||
identifier: Resource identifier (ID, slug, etc.)
|
||||
|
||||
Returns:
|
||||
DRF Response with not found error
|
||||
"""
|
||||
message = f"{resource} not found"
|
||||
if identifier:
|
||||
message += f": {identifier}"
|
||||
|
||||
code_map = {
|
||||
'Payment': ErrorCode.PAYMENT_NOT_FOUND,
|
||||
'Invoice': ErrorCode.INVOICE_NOT_FOUND,
|
||||
'Subscription': ErrorCode.SUBSCRIPTION_NOT_FOUND,
|
||||
}
|
||||
|
||||
return error_response(
|
||||
message=message,
|
||||
code=code_map.get(resource, ErrorCode.INTERNAL_ERROR),
|
||||
status_code=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
def unauthorized_response(
|
||||
message: str = 'Authentication required'
|
||||
) -> Response:
|
||||
"""Create unauthorized error response"""
|
||||
return error_response(
|
||||
message=message,
|
||||
code=ErrorCode.UNAUTHORIZED,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
|
||||
def forbidden_response(
|
||||
message: str = 'You do not have permission to perform this action'
|
||||
) -> Response:
|
||||
"""Create forbidden error response"""
|
||||
return error_response(
|
||||
message=message,
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
status_code=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
Reference in New Issue
Block a user