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
996 lines
37 KiB
Python
996 lines
37 KiB
Python
"""
|
|
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() |