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:
IGNY8 VPS (Salman)
2025-12-09 06:14:44 +00:00
parent 72d0b6b0fd
commit 4d13a57068
36 changed files with 4159 additions and 253 deletions

View 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': '',
'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}"

View 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
)