project initialization
Some checks failed
System Monitoring / Health Checks (push) Has been cancelled
System Monitoring / Performance Monitoring (push) Has been cancelled
System Monitoring / Database Monitoring (push) Has been cancelled
System Monitoring / Cache Monitoring (push) Has been cancelled
System Monitoring / Log Monitoring (push) Has been cancelled
System Monitoring / Resource Monitoring (push) Has been cancelled
System Monitoring / Uptime Monitoring (push) Has been cancelled
System Monitoring / Backup Monitoring (push) Has been cancelled
System Monitoring / Security Monitoring (push) Has been cancelled
System Monitoring / Monitoring Dashboard (push) Has been cancelled
System Monitoring / Alerting (push) Has been cancelled
Security Scanning / Dependency Scanning (push) Has been cancelled
Security Scanning / Code Security Scanning (push) Has been cancelled
Security Scanning / Secrets Scanning (push) Has been cancelled
Security Scanning / Container Security Scanning (push) Has been cancelled
Security Scanning / Compliance Checking (push) Has been cancelled
Security Scanning / Security Dashboard (push) Has been cancelled
Security Scanning / Security Remediation (push) Has been cancelled
Some checks failed
System Monitoring / Health Checks (push) Has been cancelled
System Monitoring / Performance Monitoring (push) Has been cancelled
System Monitoring / Database Monitoring (push) Has been cancelled
System Monitoring / Cache Monitoring (push) Has been cancelled
System Monitoring / Log Monitoring (push) Has been cancelled
System Monitoring / Resource Monitoring (push) Has been cancelled
System Monitoring / Uptime Monitoring (push) Has been cancelled
System Monitoring / Backup Monitoring (push) Has been cancelled
System Monitoring / Security Monitoring (push) Has been cancelled
System Monitoring / Monitoring Dashboard (push) Has been cancelled
System Monitoring / Alerting (push) Has been cancelled
Security Scanning / Dependency Scanning (push) Has been cancelled
Security Scanning / Code Security Scanning (push) Has been cancelled
Security Scanning / Secrets Scanning (push) Has been cancelled
Security Scanning / Container Security Scanning (push) Has been cancelled
Security Scanning / Compliance Checking (push) Has been cancelled
Security Scanning / Security Dashboard (push) Has been cancelled
Security Scanning / Security Remediation (push) Has been cancelled
This commit is contained in:
996
backend/src/core/services/payment_service.py
Normal file
996
backend/src/core/services/payment_service.py
Normal file
@@ -0,0 +1,996 @@
|
||||
"""
|
||||
Payment service for multi-tenant SaaS platform.
|
||||
|
||||
Handles payment processing, refunds, disputes, and Malaysian market
|
||||
integration with Stripe, Midtrans, and local payment methods.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional, Any, Tuple, Union
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
from django.core.mail import send_mail
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q, Sum, Count
|
||||
from logging import getLogger
|
||||
from ..models.payment import PaymentTransaction, PaymentMethodToken, Dispute
|
||||
from ..models.tenant import Tenant
|
||||
from ..models.subscription import Subscription
|
||||
from ..exceptions import ValidationError, AuthenticationError, BusinessLogicError
|
||||
|
||||
User = get_user_model()
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentService:
|
||||
"""
|
||||
Service for managing payment operations including:
|
||||
- Payment processing with multiple providers
|
||||
- Payment method management
|
||||
- Refunds and disputes
|
||||
- Malaysian tax compliance (SST)
|
||||
- Local payment methods integration
|
||||
- Payment reporting and analytics
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.payment_cache_prefix = 'payment:'
|
||||
self.refund_cache_prefix = 'refund:'
|
||||
self.cache_timeout = getattr(settings, 'PAYMENT_CACHE_TIMEOUT', 1800) # 30 minutes
|
||||
|
||||
# Malaysian SST rate
|
||||
self.sst_rate = Decimal(getattr(settings, 'SST_RATE', '0.06')) # 6%
|
||||
|
||||
# Payment provider configurations
|
||||
self.providers = {
|
||||
'stripe': {
|
||||
'enabled': getattr(settings, 'STRIPE_ENABLED', True),
|
||||
'api_key': getattr(settings, 'STRIPE_API_KEY', ''),
|
||||
'webhook_secret': getattr(settings, 'STRIPE_WEBHOOK_SECRET', ''),
|
||||
},
|
||||
'midtrans': {
|
||||
'enabled': getattr(settings, 'MIDTRANS_ENABLED', True),
|
||||
'server_key': getattr(settings, 'MIDTRANS_SERVER_KEY', ''),
|
||||
'client_key': getattr(settings, 'MIDTRANS_CLIENT_KEY', ''),
|
||||
},
|
||||
'fpx': {
|
||||
'enabled': getattr(settings, 'FPX_ENABLED', True),
|
||||
'merchant_id': getattr(settings, 'FPX_MERCHANT_ID', ''),
|
||||
},
|
||||
'touch_n_go': {
|
||||
'enabled': getattr(settings, 'TOUCH_N_GO_ENABLED', True),
|
||||
'merchant_id': getattr(settings, 'TOUCH_N_GO_MERCHANT_ID', ''),
|
||||
},
|
||||
'grabpay': {
|
||||
'enabled': getattr(settings, 'GRABPAY_ENABLED', True),
|
||||
'merchant_id': getattr(settings, 'GRABPAY_MERCHANT_ID', ''),
|
||||
},
|
||||
}
|
||||
|
||||
# Malaysian payment methods
|
||||
self.malaysian_payment_methods = [
|
||||
'credit_card', 'debit_card', 'fpx', 'online_banking',
|
||||
'touch_n_go', 'grabpay', 'boost', 'shopee_pay'
|
||||
]
|
||||
|
||||
@transaction.atomic
|
||||
def process_payment(
|
||||
self,
|
||||
tenant_id: str,
|
||||
amount: Decimal,
|
||||
currency: str = 'MYR',
|
||||
payment_method: str = 'stripe',
|
||||
payment_method_token: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
customer_email: Optional[str] = None,
|
||||
subscription_id: Optional[str] = None
|
||||
) -> PaymentTransaction:
|
||||
"""
|
||||
Process a payment transaction.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
amount: Payment amount
|
||||
currency: Currency code (default: MYR)
|
||||
payment_method: Payment method
|
||||
payment_method_token: Payment method token
|
||||
description: Payment description
|
||||
metadata: Additional metadata
|
||||
customer_email: Customer email
|
||||
subscription_id: Associated subscription ID
|
||||
|
||||
Returns:
|
||||
Created PaymentTransaction instance
|
||||
|
||||
Raises:
|
||||
ValidationError: If validation fails
|
||||
BusinessLogicError: If payment processing fails
|
||||
"""
|
||||
# Validate inputs
|
||||
if amount <= 0:
|
||||
raise ValidationError("Amount must be positive")
|
||||
|
||||
if currency != 'MYR':
|
||||
raise ValidationError("Only MYR currency is supported")
|
||||
|
||||
if payment_method not in self.providers or not self.providers[payment_method]['enabled']:
|
||||
raise ValidationError(f"Payment method {payment_method} not supported")
|
||||
|
||||
try:
|
||||
tenant = Tenant.objects.get(id=tenant_id)
|
||||
except Tenant.DoesNotExist:
|
||||
raise AuthenticationError("Tenant not found")
|
||||
|
||||
# Calculate SST if applicable
|
||||
sst_amount = amount * self.sst_rate
|
||||
total_amount = amount + sst_amount
|
||||
|
||||
# Create payment transaction
|
||||
transaction_id = self._generate_transaction_id()
|
||||
payment_transaction = PaymentTransaction.objects.create(
|
||||
tenant=tenant,
|
||||
transaction_id=transaction_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
sst_amount=sst_amount,
|
||||
total_amount=total_amount,
|
||||
payment_method=payment_method,
|
||||
payment_method_token=payment_method_token,
|
||||
status='pending',
|
||||
description=description or f'Payment for {tenant.name}',
|
||||
metadata=metadata or {},
|
||||
customer_email=customer_email or tenant.email,
|
||||
subscription_id=subscription_id,
|
||||
)
|
||||
|
||||
# Process payment with provider
|
||||
try:
|
||||
provider_result = self._process_with_provider(payment_transaction)
|
||||
|
||||
if provider_result['success']:
|
||||
payment_transaction.status = 'completed'
|
||||
payment_transaction.provider_transaction_id = provider_result['transaction_id']
|
||||
payment_transaction.provider_response = provider_result['response']
|
||||
payment_transaction.completed_at = timezone.now()
|
||||
|
||||
# Update associated subscription if applicable
|
||||
if subscription_id:
|
||||
self._update_subscription_payment(subscription_id, payment_transaction)
|
||||
|
||||
# Send payment confirmation
|
||||
self._send_payment_confirmation(payment_transaction)
|
||||
|
||||
logger.info(f"Payment processed successfully: {transaction_id}")
|
||||
else:
|
||||
payment_transaction.status = 'failed'
|
||||
payment_transaction.failure_reason = provider_result['error']
|
||||
payment_transaction.failed_at = timezone.now()
|
||||
|
||||
logger.error(f"Payment failed: {transaction_id}, error: {provider_result['error']}")
|
||||
|
||||
except Exception as e:
|
||||
payment_transaction.status = 'failed'
|
||||
payment_transaction.failure_reason = str(e)
|
||||
payment_transaction.failed_at = timezone.now()
|
||||
|
||||
logger.error(f"Payment processing error: {transaction_id}, error: {str(e)}")
|
||||
|
||||
payment_transaction.save()
|
||||
return payment_transaction
|
||||
|
||||
def process_refund(
|
||||
self,
|
||||
transaction_id: str,
|
||||
amount: Optional[Decimal] = None,
|
||||
reason: str = 'customer_request',
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> PaymentTransaction:
|
||||
"""
|
||||
Process a refund for a payment transaction.
|
||||
|
||||
Args:
|
||||
transaction_id: Original transaction ID
|
||||
amount: Refund amount (None for full refund)
|
||||
reason: Refund reason
|
||||
metadata: Additional metadata
|
||||
|
||||
Returns:
|
||||
Created refund PaymentTransaction instance
|
||||
|
||||
Raises:
|
||||
ValidationError: If validation fails
|
||||
BusinessLogicError: If refund cannot be processed
|
||||
"""
|
||||
try:
|
||||
original_transaction = PaymentTransaction.objects.get(transaction_id=transaction_id)
|
||||
except PaymentTransaction.DoesNotExist:
|
||||
raise AuthenticationError("Original transaction not found")
|
||||
|
||||
if original_transaction.status != 'completed':
|
||||
raise BusinessLogicError("Cannot refund non-completed transaction")
|
||||
|
||||
if original_transaction.refund_status == 'fully_refunded':
|
||||
raise BusinessLogicError("Transaction already fully refunded")
|
||||
|
||||
# Calculate refund amount
|
||||
if amount is None:
|
||||
# Full refund
|
||||
refund_amount = original_transaction.total_amount
|
||||
sst_refund_amount = original_transaction.sst_amount
|
||||
else:
|
||||
# Partial refund
|
||||
if amount <= 0:
|
||||
raise ValidationError("Refund amount must be positive")
|
||||
|
||||
if amount > original_transaction.total_amount:
|
||||
raise ValidationError("Refund amount cannot exceed original amount")
|
||||
|
||||
# Calculate proportional SST refund
|
||||
sst_refund_amount = (amount / original_transaction.total_amount) * original_transaction.sst_amount
|
||||
refund_amount = amount
|
||||
|
||||
# Check if partial refund is possible
|
||||
if original_transaction.refund_status == 'partially_refunded':
|
||||
already_refunded = PaymentTransaction.objects.filter(
|
||||
original_transaction=original_transaction,
|
||||
transaction_type='refund',
|
||||
status='completed'
|
||||
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
||||
|
||||
if (already_refunded + refund_amount) > original_transaction.total_amount:
|
||||
raise BusinessLogicError("Total refunds would exceed original amount")
|
||||
|
||||
# Create refund transaction
|
||||
refund_transaction_id = self._generate_transaction_id()
|
||||
refund_transaction = PaymentTransaction.objects.create(
|
||||
tenant=original_transaction.tenant,
|
||||
transaction_id=refund_transaction_id,
|
||||
transaction_type='refund',
|
||||
amount=refund_amount,
|
||||
currency=original_transaction.currency,
|
||||
sst_amount=sst_refund_amount,
|
||||
total_amount=refund_amount,
|
||||
payment_method=original_transaction.payment_method,
|
||||
original_transaction=original_transaction,
|
||||
status='pending',
|
||||
description=f'Refund for {original_transaction.description}',
|
||||
metadata=metadata or {},
|
||||
customer_email=original_transaction.customer_email,
|
||||
refund_reason=reason,
|
||||
)
|
||||
|
||||
# Process refund with provider
|
||||
try:
|
||||
refund_result = self._process_refund_with_provider(original_transaction, refund_transaction)
|
||||
|
||||
if refund_result['success']:
|
||||
refund_transaction.status = 'completed'
|
||||
refund_transaction.provider_transaction_id = refund_result['transaction_id']
|
||||
refund_transaction.provider_response = refund_result['response']
|
||||
refund_transaction.completed_at = timezone.now()
|
||||
|
||||
# Update original transaction refund status
|
||||
if amount is None or (original_transaction.total_amount - refund_amount) <= 0:
|
||||
original_transaction.refund_status = 'fully_refunded'
|
||||
else:
|
||||
original_transaction.refund_status = 'partially_refunded'
|
||||
original_transaction.save()
|
||||
|
||||
# Send refund confirmation
|
||||
self._send_refund_confirmation(refund_transaction)
|
||||
|
||||
logger.info(f"Refund processed successfully: {refund_transaction_id}")
|
||||
else:
|
||||
refund_transaction.status = 'failed'
|
||||
refund_transaction.failure_reason = refund_result['error']
|
||||
refund_transaction.failed_at = timezone.now()
|
||||
|
||||
logger.error(f"Refund failed: {refund_transaction_id}, error: {refund_result['error']}")
|
||||
|
||||
except Exception as e:
|
||||
refund_transaction.status = 'failed'
|
||||
refund_transaction.failure_reason = str(e)
|
||||
refund_transaction.failed_at = timezone.now()
|
||||
|
||||
logger.error(f"Refund processing error: {refund_transaction_id}, error: {str(e)}")
|
||||
|
||||
refund_transaction.save()
|
||||
return refund_transaction
|
||||
|
||||
def create_dispute(
|
||||
self,
|
||||
transaction_id: str,
|
||||
reason: str,
|
||||
description: str,
|
||||
amount: Optional[Decimal] = None,
|
||||
evidence: Optional[Dict[str, Any]] = None
|
||||
) -> Dispute:
|
||||
"""
|
||||
Create a payment dispute.
|
||||
|
||||
Args:
|
||||
transaction_id: Transaction ID
|
||||
reason: Dispute reason
|
||||
description: Dispute description
|
||||
amount: Disputed amount (None for full amount)
|
||||
evidence: Dispute evidence
|
||||
|
||||
Returns:
|
||||
Created Dispute instance
|
||||
|
||||
Raises:
|
||||
ValidationError: If validation fails
|
||||
BusinessLogicError: If dispute cannot be created
|
||||
"""
|
||||
try:
|
||||
transaction = PaymentTransaction.objects.get(transaction_id=transaction_id)
|
||||
except PaymentTransaction.DoesNotExist:
|
||||
raise AuthenticationError("Transaction not found")
|
||||
|
||||
if transaction.status != 'completed':
|
||||
raise BusinessLogicError("Cannot dispute non-completed transaction")
|
||||
|
||||
if transaction.transaction_type == 'refund':
|
||||
raise BusinessLogicError("Cannot dispute refund transactions")
|
||||
|
||||
# Check if dispute already exists
|
||||
if Dispute.objects.filter(transaction=transaction, status__in=['pending', 'under_review']).exists():
|
||||
raise BusinessLogicError("Dispute already exists for this transaction")
|
||||
|
||||
# Calculate dispute amount
|
||||
dispute_amount = amount or transaction.total_amount
|
||||
if dispute_amount <= 0:
|
||||
raise ValidationError("Dispute amount must be positive")
|
||||
|
||||
if dispute_amount > transaction.total_amount:
|
||||
raise ValidationError("Dispute amount cannot exceed transaction amount")
|
||||
|
||||
# Create dispute
|
||||
dispute = Dispute.objects.create(
|
||||
transaction=transaction,
|
||||
reason=reason,
|
||||
description=description,
|
||||
amount=dispute_amount,
|
||||
evidence=evidence or {},
|
||||
status='pending',
|
||||
created_by=getattr(transaction, 'created_by', None),
|
||||
)
|
||||
|
||||
# Update transaction status
|
||||
transaction.dispute_status = 'under_review'
|
||||
transaction.save()
|
||||
|
||||
# Process dispute with provider
|
||||
try:
|
||||
dispute_result = self._process_dispute_with_provider(transaction, dispute)
|
||||
|
||||
if dispute_result['success']:
|
||||
dispute.provider_dispute_id = dispute_result['dispute_id']
|
||||
dispute.provider_response = dispute_result['response']
|
||||
dispute.status = 'under_review'
|
||||
|
||||
logger.info(f"Dispute created successfully: {dispute.id}")
|
||||
else:
|
||||
dispute.status = 'failed'
|
||||
dispute.failure_reason = dispute_result['error']
|
||||
transaction.dispute_status = 'no_dispute'
|
||||
transaction.save()
|
||||
|
||||
logger.error(f"Dispute creation failed: {dispute.id}, error: {dispute_result['error']}")
|
||||
|
||||
except Exception as e:
|
||||
dispute.status = 'failed'
|
||||
dispute.failure_reason = str(e)
|
||||
transaction.dispute_status = 'no_dispute'
|
||||
transaction.save()
|
||||
|
||||
logger.error(f"Dispute processing error: {dispute.id}, error: {str(e)}")
|
||||
|
||||
dispute.save()
|
||||
return dispute
|
||||
|
||||
def add_payment_method(
|
||||
self,
|
||||
tenant_id: str,
|
||||
payment_method_type: str,
|
||||
payment_method_data: Dict[str, Any],
|
||||
is_default: bool = False,
|
||||
nickname: Optional[str] = None
|
||||
) -> PaymentMethodToken:
|
||||
"""
|
||||
Add a payment method for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
payment_method_type: Payment method type
|
||||
payment_method_data: Payment method data
|
||||
is_default: Whether to set as default
|
||||
nickname: Payment method nickname
|
||||
|
||||
Returns:
|
||||
Created PaymentMethodToken instance
|
||||
|
||||
Raises:
|
||||
ValidationError: If validation fails
|
||||
BusinessLogicError: If payment method cannot be added
|
||||
"""
|
||||
try:
|
||||
tenant = Tenant.objects.get(id=tenant_id)
|
||||
except Tenant.DoesNotExist:
|
||||
raise AuthenticationError("Tenant not found")
|
||||
|
||||
if payment_method_type not in self.providers:
|
||||
raise ValidationError(f"Unsupported payment method type: {payment_method_type}")
|
||||
|
||||
# Tokenize payment method with provider
|
||||
try:
|
||||
token_result = self._tokenize_payment_method(payment_method_type, payment_method_data)
|
||||
|
||||
if not token_result['success']:
|
||||
raise BusinessLogicError(f"Tokenization failed: {token_result['error']}")
|
||||
|
||||
except Exception as e:
|
||||
raise BusinessLogicError(f"Payment method tokenization failed: {str(e)}")
|
||||
|
||||
# Create payment method token
|
||||
payment_method = PaymentMethodToken.objects.create(
|
||||
tenant=tenant,
|
||||
payment_method_type=payment_method_type,
|
||||
token=token_result['token'],
|
||||
last_four=token_result.get('last_four', ''),
|
||||
expiry_month=token_result.get('expiry_month'),
|
||||
expiry_year=token_result.get('expiry_year'),
|
||||
card_type=token_result.get('card_type'),
|
||||
is_default=is_default,
|
||||
nickname=nickname or f"{payment_method_type.title()} Card",
|
||||
provider_response=token_result.get('response', {}),
|
||||
)
|
||||
|
||||
# If set as default, update other payment methods
|
||||
if is_default:
|
||||
PaymentMethodToken.objects.filter(
|
||||
tenant=tenant,
|
||||
is_default=True
|
||||
).exclude(id=payment_method.id).update(is_default=False)
|
||||
|
||||
logger.info(f"Added payment method for tenant {tenant_id}")
|
||||
return payment_method
|
||||
|
||||
def remove_payment_method(self, payment_method_id: str, tenant_id: str) -> bool:
|
||||
"""
|
||||
Remove a payment method.
|
||||
|
||||
Args:
|
||||
payment_method_id: Payment method ID
|
||||
tenant_id: Tenant ID
|
||||
|
||||
Returns:
|
||||
True if removal successful
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If payment method not found
|
||||
BusinessLogicError: If payment method cannot be removed
|
||||
"""
|
||||
try:
|
||||
payment_method = PaymentMethodToken.objects.get(
|
||||
id=payment_method_id,
|
||||
tenant_id=tenant_id,
|
||||
is_active=True
|
||||
)
|
||||
except PaymentMethodToken.DoesNotExist:
|
||||
raise AuthenticationError("Payment method not found")
|
||||
|
||||
# Check if payment method is used in active subscriptions
|
||||
active_subscriptions = Subscription.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
payment_method_token=payment_method.token,
|
||||
status__in=['active', 'trial']
|
||||
).exists()
|
||||
|
||||
if active_subscriptions:
|
||||
raise BusinessLogicError("Cannot remove payment method used in active subscriptions")
|
||||
|
||||
# Deactivate payment method
|
||||
payment_method.is_active = False
|
||||
payment_method.deactivated_at = timezone.now()
|
||||
payment_method.save()
|
||||
|
||||
# If this was the default, set another as default
|
||||
if payment_method.is_default:
|
||||
remaining_methods = PaymentMethodToken.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if remaining_methods:
|
||||
remaining_methods.is_default = True
|
||||
remaining_methods.save()
|
||||
|
||||
logger.info(f"Removed payment method {payment_method_id}")
|
||||
return True
|
||||
|
||||
def get_payment_methods(self, tenant_id: str) -> List[PaymentMethodToken]:
|
||||
"""
|
||||
Get active payment methods for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
|
||||
Returns:
|
||||
List of PaymentMethodToken instances
|
||||
"""
|
||||
return list(PaymentMethodToken.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
is_active=True
|
||||
).order_by('-is_default', '-created_at'))
|
||||
|
||||
def get_payment_history(
|
||||
self,
|
||||
tenant_id: str,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
status: Optional[str] = None,
|
||||
transaction_type: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> Tuple[List[PaymentTransaction], int]:
|
||||
"""
|
||||
Get payment history for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
start_date: Start date filter
|
||||
end_date: End date filter
|
||||
status: Status filter
|
||||
transaction_type: Transaction type filter
|
||||
limit: Result limit
|
||||
offset: Result offset
|
||||
|
||||
Returns:
|
||||
Tuple of (transactions list, total count)
|
||||
"""
|
||||
try:
|
||||
tenant = Tenant.objects.get(id=tenant_id)
|
||||
except Tenant.DoesNotExist:
|
||||
raise AuthenticationError("Tenant not found")
|
||||
|
||||
queryset = PaymentTransaction.objects.filter(tenant=tenant)
|
||||
|
||||
if start_date:
|
||||
queryset = queryset.filter(created_at__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(created_at__lte=end_date)
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
if transaction_type:
|
||||
queryset = queryset.filter(transaction_type=transaction_type)
|
||||
|
||||
total_count = queryset.count()
|
||||
transactions = queryset[offset:offset + limit]
|
||||
|
||||
return list(transactions), total_count
|
||||
|
||||
def get_payment_statistics(
|
||||
self,
|
||||
tenant_id: str,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get payment statistics for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
start_date: Start date filter
|
||||
end_date: End date filter
|
||||
|
||||
Returns:
|
||||
Dictionary with payment statistics
|
||||
"""
|
||||
cache_key = f"{self.payment_cache_prefix}{tenant_id}:stats"
|
||||
stats = cache.get(cache_key)
|
||||
|
||||
if stats is None:
|
||||
try:
|
||||
tenant = Tenant.objects.get(id=tenant_id)
|
||||
except Tenant.DoesNotExist:
|
||||
return {}
|
||||
|
||||
queryset = PaymentTransaction.objects.filter(tenant=tenant)
|
||||
|
||||
if start_date:
|
||||
queryset = queryset.filter(created_at__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(created_at__lte=end_date)
|
||||
|
||||
# Calculate statistics
|
||||
total_payments = queryset.filter(
|
||||
transaction_type='payment',
|
||||
status='completed'
|
||||
).aggregate(
|
||||
total_amount=Sum('total_amount'),
|
||||
total_sst=Sum('sst_amount'),
|
||||
count=Count('id')
|
||||
)
|
||||
|
||||
total_refunds = queryset.filter(
|
||||
transaction_type='refund',
|
||||
status='completed'
|
||||
).aggregate(
|
||||
total_amount=Sum('total_amount'),
|
||||
total_sst=Sum('sst_amount'),
|
||||
count=Count('id')
|
||||
)
|
||||
|
||||
active_disputes = Dispute.objects.filter(
|
||||
transaction__tenant=tenant,
|
||||
status__in=['pending', 'under_review']
|
||||
).count()
|
||||
|
||||
payment_methods = PaymentMethodToken.objects.filter(
|
||||
tenant=tenant,
|
||||
is_active=True
|
||||
).count()
|
||||
|
||||
stats = {
|
||||
'tenant_id': str(tenant.id),
|
||||
'total_payments_amount': float(total_payments['total_amount'] or 0),
|
||||
'total_payments_sst': float(total_payments['total_sst'] or 0),
|
||||
'total_payments_count': total_payments['count'] or 0,
|
||||
'total_refunds_amount': float(total_refunds['total_amount'] or 0),
|
||||
'total_refunds_sst': float(total_refunds['total_sst'] or 0),
|
||||
'total_refunds_count': total_refunds['count'] or 0,
|
||||
'net_amount': float((total_payments['total_amount'] or 0) - (total_refunds['total_amount'] or 0)),
|
||||
'active_disputes': active_disputes,
|
||||
'active_payment_methods': payment_methods,
|
||||
'period_start': start_date.isoformat() if start_date else None,
|
||||
'period_end': end_date.isoformat() if end_date else None,
|
||||
}
|
||||
|
||||
cache.set(cache_key, stats, timeout=self.cache_timeout)
|
||||
|
||||
return stats
|
||||
|
||||
def retry_failed_payment(self, transaction_id: str) -> PaymentTransaction:
|
||||
"""
|
||||
Retry a failed payment transaction.
|
||||
|
||||
Args:
|
||||
transaction_id: Transaction ID
|
||||
|
||||
Returns:
|
||||
Updated PaymentTransaction instance
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If transaction not found
|
||||
BusinessLogicError: If retry not allowed
|
||||
"""
|
||||
try:
|
||||
transaction = PaymentTransaction.objects.get(transaction_id=transaction_id)
|
||||
except PaymentTransaction.DoesNotExist:
|
||||
raise AuthenticationError("Transaction not found")
|
||||
|
||||
if transaction.status != 'failed':
|
||||
raise BusinessLogicError("Can only retry failed transactions")
|
||||
|
||||
# Check retry limit
|
||||
retry_count = PaymentTransaction.objects.filter(
|
||||
original_transaction=transaction,
|
||||
transaction_type='retry'
|
||||
).count()
|
||||
|
||||
if retry_count >= 3:
|
||||
raise BusinessLogicError("Maximum retry attempts exceeded")
|
||||
|
||||
# Create retry transaction
|
||||
retry_transaction = PaymentTransaction.objects.create(
|
||||
tenant=transaction.tenant,
|
||||
transaction_id=self._generate_transaction_id(),
|
||||
transaction_type='retry',
|
||||
amount=transaction.amount,
|
||||
currency=transaction.currency,
|
||||
sst_amount=transaction.sst_amount,
|
||||
total_amount=transaction.total_amount,
|
||||
payment_method=transaction.payment_method,
|
||||
payment_method_token=transaction.payment_method_token,
|
||||
original_transaction=transaction,
|
||||
status='pending',
|
||||
description=f'Retry of {transaction.description}',
|
||||
metadata=transaction.metadata.copy(),
|
||||
customer_email=transaction.customer_email,
|
||||
subscription_id=transaction.subscription_id,
|
||||
)
|
||||
|
||||
# Process payment
|
||||
try:
|
||||
provider_result = self._process_with_provider(retry_transaction)
|
||||
|
||||
if provider_result['success']:
|
||||
retry_transaction.status = 'completed'
|
||||
retry_transaction.provider_transaction_id = provider_result['transaction_id']
|
||||
retry_transaction.provider_response = provider_result['response']
|
||||
retry_transaction.completed_at = timezone.now()
|
||||
|
||||
# Update original transaction
|
||||
transaction.retry_status = 'retried_successfully'
|
||||
transaction.save()
|
||||
|
||||
# Update associated subscription
|
||||
if transaction.subscription_id:
|
||||
self._update_subscription_payment(transaction.subscription_id, retry_transaction)
|
||||
|
||||
# Send payment confirmation
|
||||
self._send_payment_confirmation(retry_transaction)
|
||||
|
||||
logger.info(f"Payment retry successful: {retry_transaction.transaction_id}")
|
||||
else:
|
||||
retry_transaction.status = 'failed'
|
||||
retry_transaction.failure_reason = provider_result['error']
|
||||
retry_transaction.failed_at = timezone.now()
|
||||
|
||||
# Update original transaction
|
||||
transaction.retry_status = 'retry_failed'
|
||||
transaction.save()
|
||||
|
||||
logger.error(f"Payment retry failed: {retry_transaction.transaction_id}")
|
||||
|
||||
except Exception as e:
|
||||
retry_transaction.status = 'failed'
|
||||
retry_transaction.failure_reason = str(e)
|
||||
retry_transaction.failed_at = timezone.now()
|
||||
|
||||
transaction.retry_status = 'retry_failed'
|
||||
transaction.save()
|
||||
|
||||
logger.error(f"Payment retry error: {retry_transaction.transaction_id}")
|
||||
|
||||
retry_transaction.save()
|
||||
return retry_transaction
|
||||
|
||||
# Helper methods
|
||||
|
||||
def _generate_transaction_id(self) -> str:
|
||||
"""Generate unique transaction ID."""
|
||||
return f"TRX{uuid.uuid4().hex[:16].upper()}"
|
||||
|
||||
def _process_with_provider(self, transaction: PaymentTransaction) -> Dict[str, Any]:
|
||||
"""Process payment with appropriate provider."""
|
||||
provider = transaction.payment_method
|
||||
|
||||
if provider == 'stripe':
|
||||
return self._process_stripe_payment(transaction)
|
||||
elif provider == 'midtrans':
|
||||
return self._process_midtrans_payment(transaction)
|
||||
elif provider in ['fpx', 'touch_n_go', 'grabpay']:
|
||||
return self._process_local_payment(transaction)
|
||||
else:
|
||||
return {'success': False, 'error': f'Unsupported provider: {provider}'}
|
||||
|
||||
def _process_stripe_payment(self, transaction: PaymentTransaction) -> Dict[str, Any]:
|
||||
"""Process payment with Stripe."""
|
||||
# This would integrate with Stripe Python SDK
|
||||
# For now, simulate successful payment
|
||||
return {
|
||||
'success': True,
|
||||
'transaction_id': f"ch_{uuid.uuid4().hex[:16]}",
|
||||
'response': {'status': 'succeeded'}
|
||||
}
|
||||
|
||||
def _process_midtrans_payment(self, transaction: PaymentTransaction) -> Dict[str, Any]:
|
||||
"""Process payment with Midtrans."""
|
||||
# This would integrate with Midtrans Python SDK
|
||||
# For now, simulate successful payment
|
||||
return {
|
||||
'success': True,
|
||||
'transaction_id': f"MIDTRANS-{uuid.uuid4().hex[:12].upper()}",
|
||||
'response': {'status_code': '200', 'transaction_status': 'settlement'}
|
||||
}
|
||||
|
||||
def _process_local_payment(self, transaction: PaymentTransaction) -> Dict[str, Any]:
|
||||
"""Process payment with local Malaysian providers."""
|
||||
# This would integrate with FPX, Touch 'n Go, GrabPay APIs
|
||||
# For now, simulate successful payment
|
||||
return {
|
||||
'success': True,
|
||||
'transaction_id': f"LOCAL-{uuid.uuid4().hex[:12].upper()}",
|
||||
'response': {'status': 'success'}
|
||||
}
|
||||
|
||||
def _process_refund_with_provider(
|
||||
self,
|
||||
original_transaction: PaymentTransaction,
|
||||
refund_transaction: PaymentTransaction
|
||||
) -> Dict[str, Any]:
|
||||
"""Process refund with provider."""
|
||||
provider = original_transaction.payment_method
|
||||
|
||||
if provider == 'stripe':
|
||||
return self._process_stripe_refund(original_transaction, refund_transaction)
|
||||
elif provider == 'midtrans':
|
||||
return self._process_midtrans_refund(original_transaction, refund_transaction)
|
||||
else:
|
||||
return {'success': False, 'error': f'Refunds not supported for provider: {provider}'}
|
||||
|
||||
def _process_stripe_refund(
|
||||
self,
|
||||
original_transaction: PaymentTransaction,
|
||||
refund_transaction: PaymentTransaction
|
||||
) -> Dict[str, Any]:
|
||||
"""Process refund with Stripe."""
|
||||
# This would integrate with Stripe Python SDK
|
||||
return {
|
||||
'success': True,
|
||||
'transaction_id': f"re_{uuid.uuid4().hex[:16]}",
|
||||
'response': {'status': 'succeeded'}
|
||||
}
|
||||
|
||||
def _process_midtrans_refund(
|
||||
self,
|
||||
original_transaction: PaymentTransaction,
|
||||
refund_transaction: PaymentTransaction
|
||||
) -> Dict[str, Any]:
|
||||
"""Process refund with Midtrans."""
|
||||
# This would integrate with Midtrans Python SDK
|
||||
return {
|
||||
'success': True,
|
||||
'transaction_id': f"MIDTRANS-REF-{uuid.uuid4().hex[:8].upper()}",
|
||||
'response': {'status_code': '200'}
|
||||
}
|
||||
|
||||
def _process_dispute_with_provider(
|
||||
self,
|
||||
transaction: PaymentTransaction,
|
||||
dispute: Dispute
|
||||
) -> Dict[str, Any]:
|
||||
"""Process dispute with provider."""
|
||||
provider = transaction.payment_method
|
||||
|
||||
if provider == 'stripe':
|
||||
return self._process_stripe_dispute(transaction, dispute)
|
||||
elif provider == 'midtrans':
|
||||
return self._process_midtrans_dispute(transaction, dispute)
|
||||
else:
|
||||
return {'success': False, 'error': f'Disputes not supported for provider: {provider}'}
|
||||
|
||||
def _process_stripe_dispute(
|
||||
self,
|
||||
transaction: PaymentTransaction,
|
||||
dispute: Dispute
|
||||
) -> Dict[str, Any]:
|
||||
"""Process dispute with Stripe."""
|
||||
# This would integrate with Stripe Python SDK
|
||||
return {
|
||||
'success': True,
|
||||
'dispute_id': f"dp_{uuid.uuid4().hex[:16]}",
|
||||
'response': {'status': 'needs_response'}
|
||||
}
|
||||
|
||||
def _process_midtrans_dispute(
|
||||
self,
|
||||
transaction: PaymentTransaction,
|
||||
dispute: Dispute
|
||||
) -> Dict[str, Any]:
|
||||
"""Process dispute with Midtrans."""
|
||||
# This would integrate with Midtrans Python SDK
|
||||
return {
|
||||
'success': True,
|
||||
'dispute_id': f"MIDTRANS-DISP-{uuid.uuid4().hex[:8].upper()}",
|
||||
'response': {'status': 'pending'}
|
||||
}
|
||||
|
||||
def _tokenize_payment_method(self, payment_method_type: str, payment_method_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Tokenize payment method with provider."""
|
||||
if payment_method_type == 'stripe':
|
||||
return self._tokenize_stripe_payment_method(payment_method_data)
|
||||
elif payment_method_type == 'midtrans':
|
||||
return self._tokenize_midtrans_payment_method(payment_method_data)
|
||||
else:
|
||||
return {'success': False, 'error': f'Tokenization not supported for: {payment_method_type}'}
|
||||
|
||||
def _tokenize_stripe_payment_method(self, payment_method_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Tokenize payment method with Stripe."""
|
||||
# This would integrate with Stripe Python SDK
|
||||
return {
|
||||
'success': True,
|
||||
'token': f"pm_{uuid.uuid4().hex[:16]}",
|
||||
'last_four': payment_method_data.get('number', '')[-4:],
|
||||
'expiry_month': payment_method_data.get('exp_month'),
|
||||
'expiry_year': payment_method_data.get('exp_year'),
|
||||
'card_type': payment_method_data.get('brand', 'visa'),
|
||||
'response': {'id': f"pm_{uuid.uuid4().hex[:16]}"}
|
||||
}
|
||||
|
||||
def _tokenize_midtrans_payment_method(self, payment_method_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Tokenize payment method with Midtrans."""
|
||||
# This would integrate with Midtrans Python SDK
|
||||
return {
|
||||
'success': True,
|
||||
'token': f"MIDTRANS-TOKEN-{uuid.uuid4().hex[:12].upper()}",
|
||||
'last_four': payment_method_data.get('number', '')[-4:],
|
||||
'response': {'token_id': f"MIDTRANS-TOKEN-{uuid.uuid4().hex[:12].upper()}"}
|
||||
}
|
||||
|
||||
def _update_subscription_payment(self, subscription_id: str, payment_transaction: PaymentTransaction):
|
||||
"""Update subscription with payment information."""
|
||||
try:
|
||||
subscription = Subscription.objects.get(id=subscription_id)
|
||||
subscription.last_payment_at = payment_transaction.completed_at
|
||||
subscription.last_payment_amount = payment_transaction.total_amount
|
||||
subscription.save()
|
||||
except Subscription.DoesNotExist:
|
||||
logger.warning(f"Subscription {subscription_id} not found for payment update")
|
||||
|
||||
def _send_payment_confirmation(self, transaction: PaymentTransaction):
|
||||
"""Send payment confirmation email."""
|
||||
subject = f"Payment Confirmation - {transaction.transaction_id}"
|
||||
message = f"""
|
||||
Dear {transaction.tenant.name},
|
||||
|
||||
Your payment has been processed successfully!
|
||||
|
||||
Payment Details:
|
||||
- Transaction ID: {transaction.transaction_id}
|
||||
- Amount: RM{transaction.total_amount:.2f}
|
||||
- SST (6%): RM{transaction.sst_amount:.2f}
|
||||
- Payment Method: {transaction.payment_method.title()}
|
||||
- Date: {transaction.completed_at.strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
Thank you for your payment!
|
||||
|
||||
Best regards,
|
||||
The {settings.APP_NAME} Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message,
|
||||
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@example.com'),
|
||||
recipient_list=[transaction.customer_email],
|
||||
fail_silently=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send payment confirmation: {str(e)}")
|
||||
|
||||
def _send_refund_confirmation(self, transaction: PaymentTransaction):
|
||||
"""Send refund confirmation email."""
|
||||
subject = f"Refund Confirmation - {transaction.transaction_id}"
|
||||
message = f"""
|
||||
Dear {transaction.tenant.name},
|
||||
|
||||
Your refund has been processed successfully!
|
||||
|
||||
Refund Details:
|
||||
- Refund Transaction ID: {transaction.transaction_id}
|
||||
- Original Transaction ID: {transaction.original_transaction.transaction_id}
|
||||
- Refund Amount: RM{transaction.total_amount:.2f}
|
||||
- SST Refunded: RM{transaction.sst_amount:.2f}
|
||||
- Refund Reason: {transaction.refund_reason}
|
||||
- Date: {transaction.completed_at.strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
The refund should appear in your account within 5-7 business days.
|
||||
|
||||
Best regards,
|
||||
The {settings.APP_NAME} Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message,
|
||||
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@example.com'),
|
||||
recipient_list=[transaction.customer_email],
|
||||
fail_silently=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send refund confirmation: {str(e)}")
|
||||
|
||||
|
||||
# Global payment service instance
|
||||
payment_service = PaymentService()
|
||||
Reference in New Issue
Block a user