diff --git a/backend/igny8_core/modules/billing/serializers.py b/backend/igny8_core/modules/billing/serializers.py index 6d7780a7..8583da16 100644 --- a/backend/igny8_core/modules/billing/serializers.py +++ b/backend/igny8_core/modules/billing/serializers.py @@ -87,7 +87,7 @@ class PaymentMethodConfigSerializer(serializers.ModelSerializer): fields = [ 'id', 'country_code', 'payment_method', 'payment_method_display', 'is_enabled', 'display_name', 'instructions', - 'bank_name', 'account_number', 'swift_code', + 'bank_name', 'account_title', 'account_number', 'routing_number', 'swift_code', 'iban', 'wallet_type', 'wallet_id', 'sort_order' ] read_only_fields = ['id'] diff --git a/backend/igny8_core/modules/billing/views.py b/backend/igny8_core/modules/billing/views.py index 07cf8a02..df48e591 100644 --- a/backend/igny8_core/modules/billing/views.py +++ b/backend/igny8_core/modules/billing/views.py @@ -642,34 +642,154 @@ class AdminBillingViewSet(viewsets.ViewSet): return Response({'results': data}) def approve_payment(self, request, pk): - """Approve a pending payment""" + """Approve a pending payment - activates account, subscription, and adds credits""" + from django.db import transaction from igny8_core.business.billing.models import Payment + from igny8_core.modules.billing.services import CreditService + import logging + logger = logging.getLogger(__name__) + try: - payment = Payment.objects.get(pk=pk, status='pending_approval') - payment.status = 'completed' - payment.processed_at = timezone.now() - payment.save() - - # If payment has an invoice, mark it as paid - if payment.invoice: - payment.invoice.status = 'paid' - payment.invoice.paid_at = timezone.now() - payment.invoice.save() - - return Response({'success': True, 'message': 'Payment approved'}) + with transaction.atomic(): + # Get payment with related objects + payment = Payment.objects.select_related( + 'invoice', + 'invoice__subscription', + 'invoice__subscription__plan', + 'account', + 'account__subscription', + 'account__subscription__plan', + 'account__plan' + ).get(pk=pk, status='pending_approval') + + admin_notes = request.data.get('notes', '') + + # 1. Update Payment status + payment.status = 'succeeded' + payment.processed_at = timezone.now() + payment.approved_by = request.user + payment.approved_at = timezone.now() + if admin_notes: + payment.admin_notes = admin_notes + payment.save() + + invoice = payment.invoice + account = payment.account + + # 2. Mark invoice as paid + if invoice: + invoice.status = 'paid' + invoice.paid_at = timezone.now() + invoice.save() + + # 3. Get and activate subscription + subscription = None + if invoice and hasattr(invoice, 'subscription') and invoice.subscription: + subscription = invoice.subscription + elif account and hasattr(account, 'subscription'): + try: + subscription = account.subscription + except Exception: + pass + + if subscription: + subscription.status = 'active' + subscription.external_payment_id = payment.manual_reference + subscription.save(update_fields=['status', 'external_payment_id']) + + # 4. CRITICAL: Set account status to active + account.status = 'active' + account.save(update_fields=['status']) + + # 5. Add credits if plan has included credits + credits_added = 0 + try: + plan = None + if subscription and subscription.plan: + plan = subscription.plan + elif account and account.plan: + plan = account.plan + + if plan and plan.included_credits > 0: + credits_added = plan.included_credits + CreditService.add_credits( + account=account, + amount=credits_added, + transaction_type='subscription', + description=f'{plan.name} plan credits - Invoice {invoice.invoice_number if invoice else "N/A"}', + metadata={ + 'subscription_id': subscription.id if subscription else None, + 'invoice_id': invoice.id if invoice else None, + 'payment_id': payment.id, + 'plan_id': plan.id, + 'approved_by': request.user.email + } + ) + except Exception as credit_error: + logger.error(f'Credit addition failed for payment {payment.id}: {credit_error}', exc_info=True) + # Don't fail the approval if credits fail - account is still activated + + logger.info( + f'Payment approved: Payment {payment.id}, Account {account.id} set to active, ' + f'{credits_added} credits added' + ) + + # 6. Send approval email + try: + from igny8_core.business.billing.services.email_service import BillingEmailService + BillingEmailService.send_payment_approved_email(payment, account, subscription) + except Exception as e: + logger.error(f'Failed to send payment approved email: {str(e)}') + + return Response({ + 'success': True, + 'message': 'Payment approved. Account activated.', + 'payment': { + 'id': payment.id, + 'status': payment.status, + 'account_status': account.status, + 'credits_added': credits_added + } + }) except Payment.DoesNotExist: return Response({'error': 'Payment not found or not pending'}, status=404) + except Exception as e: + logger.error(f'Error approving payment {pk}: {str(e)}', exc_info=True) + return Response({'error': f'Failed to approve payment: {str(e)}'}, status=500) def reject_payment(self, request, pk): """Reject a pending payment""" from igny8_core.business.billing.models import Payment + import logging + logger = logging.getLogger(__name__) + try: - payment = Payment.objects.get(pk=pk, status='pending_approval') + payment = Payment.objects.select_related('account').get(pk=pk, status='pending_approval') + rejection_reason = request.data.get('reason', 'Rejected by admin') + payment.status = 'failed' payment.failed_at = timezone.now() - payment.failure_reason = request.data.get('reason', 'Rejected by admin') + payment.failure_reason = rejection_reason + payment.approved_by = request.user + payment.approved_at = timezone.now() + payment.admin_notes = rejection_reason payment.save() + # Update account status to allow retry (if not already active) + account = payment.account + if account and account.status != 'active': + account.status = 'pending_payment' + account.save(update_fields=['status']) + + logger.info(f'Payment rejected: Payment {payment.id}, Reason: {rejection_reason}') + + # Send rejection email + try: + from igny8_core.business.billing.services.email_service import BillingEmailService + BillingEmailService.send_payment_rejected_email(payment, account, rejection_reason) + except Exception as e: + logger.error(f'Failed to send payment rejected email: {str(e)}') + return Response({'success': True, 'message': 'Payment rejected'}) except Payment.DoesNotExist: return Response({'error': 'Payment not found or not pending'}, status=404) diff --git a/frontend/src/components/billing/BankTransferForm.tsx b/frontend/src/components/billing/BankTransferForm.tsx index e9453101..ed1ff0de 100644 --- a/frontend/src/components/billing/BankTransferForm.tsx +++ b/frontend/src/components/billing/BankTransferForm.tsx @@ -36,12 +36,18 @@ interface BankDetails { interface BankTransferFormProps { invoice: Invoice; + planPrice?: string; + planPricePKR?: string; + userCountry?: string; onSuccess: () => void; onCancel: () => void; } export default function BankTransferForm({ invoice, + planPrice, + planPricePKR, + userCountry, onSuccess, onCancel, }: BankTransferFormProps) { @@ -61,44 +67,26 @@ export default function BankTransferForm({ const loadBankDetails = async () => { setBankDetailsLoading(true); try { - const { results } = await getAvailablePaymentMethods(); + const { results } = await getAvailablePaymentMethods(userCountry); // Find bank_transfer method config const bankMethod = results.find( (m) => m.type === 'bank_transfer' && m.is_enabled - ); - - // Cast to any to access extended bank_details properties - // Backend may return additional fields not in the TypeScript type - const details = bankMethod?.bank_details as any; - - if (details) { + ) as any; + + if (bankMethod?.bank_name && (bankMethod?.account_title || bankMethod?.account_number)) { setBankDetails({ - bank_name: details.bank_name || 'Bank ABC', - account_title: details.account_title || details.account_name || 'IGNY8', - account_number: details.account_number || '', - iban: details.iban, - swift_code: details.swift_code, + bank_name: bankMethod.bank_name, + account_title: bankMethod.account_title || bankMethod.account_name || '', + account_number: bankMethod.account_number || '', + iban: bankMethod.iban, + swift_code: bankMethod.swift_code, }); } else { - // Fallback hardcoded details - should be replaced with backend config - setBankDetails({ - bank_name: 'MCB Bank Limited', - account_title: 'IGNY8 Technologies', - account_number: '0000123456789', - iban: 'PK00MUCB0000000123456789', - swift_code: 'MUCBPKKAXXX', - }); + setBankDetails(null); } } catch (error) { console.error('Failed to load bank details:', error); - // Use fallback - setBankDetails({ - bank_name: 'MCB Bank Limited', - account_title: 'IGNY8 Technologies', - account_number: '0000123456789', - iban: 'PK00MUCB0000000123456789', - swift_code: 'MUCBPKKAXXX', - }); + setBankDetails(null); } finally { setBankDetailsLoading(false); } @@ -280,18 +268,26 @@ export default function BankTransferForm({ {/* Amount */}
Amount to Transfer - - {invoice.currency === 'PKR' ? 'PKR ' : '$'} - {(() => { - const amount = parseFloat(String(invoice.total_amount || invoice.total || 0)); - // Round PKR to nearest thousand - if (invoice.currency === 'PKR') { - const rounded = Math.round(amount / 1000) * 1000; +
+
+ ${(() => { + const amount = parseFloat(String(planPrice || invoice.total_amount || invoice.total || 0)); + return Number.isFinite(amount) ? amount.toFixed(2) : '0.00'; + })()} USD +
+
+ ≈ PKR {(() => { + const pkrAmount = planPricePKR + ? parseFloat(planPricePKR) + : (() => { + const usdAmount = parseFloat(String(planPrice || invoice.total_amount || invoice.total || 0)); + return Number.isFinite(usdAmount) ? usdAmount * 278 : 0; + })(); + const rounded = Math.round(pkrAmount / 1000) * 1000; return rounded.toLocaleString(); - } - return amount.toFixed(2); - })()} - + })()} +
+
@@ -330,7 +326,7 @@ export default function BankTransferForm({ disabled={loading} className="flex-1" > - Cancel + Back - { setShowBankTransfer(false); setBankTransferSubmitted(true); @@ -273,14 +292,6 @@ export default function PendingPaymentView({
{/* Header with Awaiting Badge */}
-
- -
-
- - Awaiting Approval - -

Payment Submitted!

@@ -377,6 +388,15 @@ export default function PendingPaymentView({ Status is checked automatically every 30 seconds

+
+
+ +
+ + Awaiting Approval + +
+ {/* Disabled Payment Options Notice */}
diff --git a/frontend/src/services/billing.api.ts b/frontend/src/services/billing.api.ts index 2b95e9af..28e33229 100644 --- a/frontend/src/services/billing.api.ts +++ b/frontend/src/services/billing.api.ts @@ -189,6 +189,12 @@ export interface PaymentMethod { is_verified?: boolean; instructions?: string; country_code?: string; + bank_name?: string; + account_title?: string; + account_number?: string; + routing_number?: string; + swift_code?: string; + iban?: string; bank_details?: { bank_name?: string; account_number?: string; @@ -686,12 +692,13 @@ export async function removeTeamMember(memberId: number): Promise<{ // Get GLOBAL payment method configs (system-wide available payment options like stripe, paypal, bank_transfer) // This is used on Plans page to show what payment methods are available to choose -export async function getAvailablePaymentMethods(): Promise<{ +export async function getAvailablePaymentMethods(countryCode?: string): Promise<{ results: PaymentMethod[]; count: number; }> { // Call the payment-configs endpoint which returns global PaymentMethodConfig records - const response = await fetchAPI('/v1/billing/payment-configs/payment-methods/'); + const params = countryCode ? `?country_code=${countryCode}` : ''; + const response = await fetchAPI(`/v1/billing/payment-configs/payment-methods/${params}`); // Return all payment methods - stripe, paypal, bank_transfer, manual, local_wallet const results = Array.isArray(response.results) ? response.results : []; // Map payment_method to type for consistent API