""" 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()