From 0957ece3a408b4fb76008814097fa56cdb4d660e Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Tue, 20 Jan 2026 03:35:35 +0000 Subject: [PATCH] billing realted PK bank transfer settigns --- .../business/billing/billing_views.py | 123 ++++++++++ backend/igny8_core/business/billing/models.py | 2 +- .../billing/services/invoice_service.py | 30 ++- .../src/pages/account/PlansAndBillingPage.tsx | 219 ++++++++++++------ 4 files changed, 302 insertions(+), 72 deletions(-) diff --git a/backend/igny8_core/business/billing/billing_views.py b/backend/igny8_core/business/billing/billing_views.py index 058b2032..bb301826 100644 --- a/backend/igny8_core/business/billing/billing_views.py +++ b/backend/igny8_core/business/billing/billing_views.py @@ -879,6 +879,129 @@ class CreditPackageViewSet(viewsets.ReadOnlyModelViewSet): {'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results}, request=request ) + + @action(detail=True, methods=['post']) + def purchase(self, request, pk=None): + """ + Purchase a credit package - creates an invoice for manual payment. + + For Stripe/PayPal, use the dedicated checkout endpoints. + This endpoint is specifically for bank transfer/manual payments. + + Request body: + { + "payment_method": "bank_transfer" // Required for manual/bank transfer + } + + Returns: + { + "invoice_id": 123, + "invoice_number": "INV-2025-001", + "total_amount": "99.00", + "currency": "USD", + "status": "pending", + "message": "Invoice created. Please submit payment confirmation." + } + """ + payment_method = request.data.get('payment_method', 'bank_transfer') + + # Only allow manual/bank_transfer through this endpoint + if payment_method not in ['bank_transfer', 'manual', 'local_wallet']: + return error_response( + error='Use dedicated Stripe/PayPal endpoints for card payments', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + try: + package = self.get_queryset().get(pk=pk) + except CreditPackage.DoesNotExist: + return error_response( + error='Credit package not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) + + # Get the account + account = getattr(request, 'account', None) + if not account: + return error_response( + error='Account not found', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Check if account has an active status (allow purchases only for active accounts) + if account.status not in ['active', 'trial']: + return error_response( + error='Account must be active to purchase credits. Please complete your subscription first.', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Check for existing pending invoice for this package + existing_pending = Invoice.objects.filter( + account=account, + status='pending', + metadata__credit_package_id=package.id + ).first() + + if existing_pending: + return success_response( + data={ + 'invoice_id': existing_pending.id, + 'invoice_number': existing_pending.invoice_number, + 'total_amount': str(existing_pending.total), + 'currency': existing_pending.currency, + 'status': existing_pending.status, + 'message': 'You already have a pending invoice for this credit package.', + 'next_action': 'submit_payment' + }, + request=request + ) + + try: + with transaction.atomic(): + # Create invoice for the credit package + invoice = InvoiceService.create_credit_package_invoice( + account=account, + credit_package=package + ) + + # Update invoice payment method + invoice.payment_method = payment_method + invoice.save() + + logger.info( + f"Credit package invoice created: {invoice.invoice_number} " + f"for account {account.id}, package {package.name} ({package.credits} credits)" + ) + + return success_response( + data={ + 'invoice_id': invoice.id, + 'invoice_number': invoice.invoice_number, + 'total_amount': str(invoice.total), + 'currency': invoice.currency, + 'status': invoice.status, + 'credit_package': { + 'id': package.id, + 'name': package.name, + 'credits': package.credits, + }, + 'message': 'Invoice created successfully. Please submit your bank transfer payment confirmation.', + 'next_action': 'submit_payment' + }, + request=request, + status_code=status.HTTP_201_CREATED + ) + except Exception as e: + logger.error(f"Failed to create credit package invoice: {str(e)}", exc_info=True) + return error_response( + error=f'Failed to create invoice: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) class AccountPaymentMethodViewSet(AccountModelViewSet): diff --git a/backend/igny8_core/business/billing/models.py b/backend/igny8_core/business/billing/models.py index 2fedaeab..96c71fe1 100644 --- a/backend/igny8_core/business/billing/models.py +++ b/backend/igny8_core/business/billing/models.py @@ -599,7 +599,7 @@ class CreditPackage(models.Model): # Display description = models.TextField(blank=True) - features = models.JSONField(default=list, help_text="Bonus features or highlights") + features = models.JSONField(default=list, blank=True, help_text="Bonus features or highlights") # Sort order sort_order = models.IntegerField(default=0, help_text="Display order (lower = first)") diff --git a/backend/igny8_core/business/billing/services/invoice_service.py b/backend/igny8_core/business/billing/services/invoice_service.py index be24a29c..b5f48d33 100644 --- a/backend/igny8_core/business/billing/services/invoice_service.py +++ b/backend/igny8_core/business/billing/services/invoice_service.py @@ -182,16 +182,28 @@ class InvoiceService: local_currency = get_currency_for_country(account.billing_country) if account.billing_country else 'USD' local_equivalent = convert_usd_to_local(usd_price, account.billing_country) if local_currency != 'USD' else usd_price + # Get billing email from account + billing_email = account.billing_email + if not billing_email: + owner = account.users.filter(role='owner').first() + if owner: + billing_email = owner.email + invoice = Invoice.objects.create( account=account, invoice_number=InvoiceService.generate_invoice_number(account), - billing_email=account.billing_email or account.users.filter(role='owner').first().email, status='pending', currency=currency, invoice_date=invoice_date, due_date=invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET), metadata={ + 'billing_snapshot': { + 'email': billing_email, + 'name': account.name, + 'billing_address': account.billing_address or '', + }, 'credit_package_id': credit_package.id, + 'credit_package_name': credit_package.name, 'credit_amount': credit_package.credits, 'usd_price': str(credit_package.price), # Store original USD price 'local_currency': local_currency, # Store local currency code for display @@ -233,15 +245,27 @@ class InvoiceService: notes: Invoice notes due_date: Payment due date """ + # Get billing email + email = billing_email or account.billing_email + if not email: + owner = account.users.filter(role='owner').first() + if owner: + email = owner.email + invoice = Invoice.objects.create( account=account, invoice_number=InvoiceService.generate_invoice_number(account), - billing_email=billing_email or account.billing_email or account.users.filter(role='owner').first().email, status='draft', currency='USD', notes=notes, invoice_date=timezone.now().date(), - due_date=due_date or (timezone.now() + timedelta(days=30)) + due_date=due_date or (timezone.now() + timedelta(days=30)), + metadata={ + 'billing_snapshot': { + 'email': email, + 'name': account.name, + } + } ) # Add all line items diff --git a/frontend/src/pages/account/PlansAndBillingPage.tsx b/frontend/src/pages/account/PlansAndBillingPage.tsx index c604dcd6..03290049 100644 --- a/frontend/src/pages/account/PlansAndBillingPage.tsx +++ b/frontend/src/pages/account/PlansAndBillingPage.tsx @@ -559,11 +559,10 @@ export default function PlansAndBillingPage() { return; } - // Fall back to manual/bank transfer flow - await createSubscription({ plan_id: planId, payment_method: selectedPaymentMethod }); - toast?.success?.('Plan upgraded successfully!'); + // For manual/bank transfer - use Stripe to create invoice, then pay via bank transfer + // This creates the invoice which user can then pay using bank transfer from billing history + toast?.info?.('Bank transfer requires an invoice. Please select Card payment to create an invoice, then use "Pay Now" with bank transfer option from Billing History.'); setShowUpgradeModal(false); - await loadData(); } catch (err: any) { handleError(err, 'Failed to upgrade plan'); } finally { @@ -597,10 +596,36 @@ export default function PlansAndBillingPage() { return; } - // Fall back to manual/bank transfer flow - await purchaseCreditPackage({ package_id: packageId, payment_method: selectedPaymentMethod as any || 'manual' }); - toast?.success?.('Credits purchased successfully!'); - await loadData(); + // For manual/bank transfer - create invoice and show payment modal + const result = await purchaseCreditPackage({ + package_id: packageId, + payment_method: 'bank_transfer' + }); + + if (result.invoice_id) { + // Find the package to get details for the invoice display + const pkg = packages.find(p => p.id === packageId); + + // Create invoice object for PayInvoiceModal + const invoiceForModal: Invoice = { + id: result.invoice_id, + invoice_number: result.invoice_number || '', + total: result.total_amount || '0', + total_amount: result.total_amount || '0', + currency: 'USD', + status: result.status || 'pending', + payment_method: 'bank_transfer', + subscription: null, // Credit purchase - no subscription + }; + + // Show the payment modal + setSelectedInvoice(invoiceForModal); + setShowPayInvoiceModal(true); + + toast?.success?.(result.message || 'Invoice created! Please submit your payment confirmation.'); + } else { + toast?.error?.(result.message || 'Failed to create invoice'); + } } catch (err: any) { handleError(err, 'Failed to purchase credits'); } finally { @@ -674,12 +699,22 @@ export default function PlansAndBillingPage() { ? Math.round((creditBalance.credits_used_this_month / creditBalance.plan_credits_per_month) * 100) : 0; - // Upgrade plans (exclude current and free) - const upgradePlans = plans.filter(p => { + // All paid plans for the upgrade panel (includes current plan) + const allPaidPlans = plans.filter(p => { const price = Number(p.price) || 0; - return price > 0 && p.id !== effectivePlanId; + return price > 0; }).sort((a, b) => (Number(a.price) || 0) - (Number(b.price) || 0)); + // Check if user is on the highest plan (by price) + const highestPlanPrice = allPaidPlans.length > 0 + ? Math.max(...allPaidPlans.map(p => Number(p.price) || 0)) + : 0; + const currentPlanPrice = Number(currentPlan?.price) || 0; + const isOnHighestPlan = currentPlanPrice >= highestPlanPrice && highestPlanPrice > 0; + + // Upgrade plans for modal (exclude current) + const upgradePlans = allPaidPlans.filter(p => p.id !== effectivePlanId); + // PAYMENT PROCESSING OVERLAY - Beautiful full-page loading with breathing badge if (paymentProcessing?.active) { const stageConfig = { @@ -860,14 +895,16 @@ export default function PlansAndBillingPage() { Manage Billing )} - + {!isOnHighestPlan && ( + + )} @@ -1043,14 +1080,14 @@ export default function PlansAndBillingPage() { ) : ( <> {/* Payment Method Selector - Clear buttons */} - {(availableGateways.stripe || availableGateways.paypal) && ( + {(availableGateways.stripe || availableGateways.paypal || availableGateways.manual) && (
-
+
{availableGateways.stripe && ( )} + {availableGateways.manual && ( + + )}
)} @@ -1117,56 +1167,87 @@ export default function PlansAndBillingPage() {
-

Upgrade Plan

-

Get more features & credits

+

+ {isOnHighestPlan ? 'Your Plan' : 'Upgrade Plan'} +

+

+ {isOnHighestPlan ? 'You are on the highest plan' : 'Get more features & credits'} +

- + {!isOnHighestPlan && ( + + )}
- {upgradePlans.slice(0, 3).map((plan) => ( -
-
-
{plan.name}
-
- {plan.included_credits?.toLocaleString() || 0} credits/mo -
-
-
-
- {formatCurrency(plan.price, 'USD')}/mo -
- {billingCountry === 'PK' && ( -
- ≈ PKR {convertUSDToPKR(plan.price).toLocaleString()}/mo + {allPaidPlans.map((plan) => { + const isCurrentPlan = plan.id === effectivePlanId; + const currentPriceVal = Number(currentPlan?.price) || 0; + const thisPlanPrice = Number(plan.price) || 0; + const isUpgrade = thisPlanPrice > currentPriceVal; + const isDowngrade = thisPlanPrice < currentPriceVal; + + return ( +
+
+
+ {plan.name} + {isCurrentPlan && ( + Current + )}
- )} - +
+ {plan.included_credits?.toLocaleString() || 0} credits/mo +
+
+
+
+ {formatCurrency(plan.price, 'USD')}/mo +
+ {billingCountry === 'PK' && ( +
+ ≈ PKR {convertUSDToPKR(plan.price).toLocaleString()}/mo +
+ )} + {!isCurrentPlan && ( + + )} +
-
- ))} - {upgradePlans.length === 0 && ( + ); + })} + {allPaidPlans.length === 0 && (
-

You're on the best plan!

+

No plans available

)}
@@ -1360,8 +1441,10 @@ export default function PlansAndBillingPage() { {/* Upgrade Modal */} {showUpgradeModal && ( -
-
+
+ {/* Backdrop */} +
setShowUpgradeModal(false)}>
+

Choose Your Plan

@@ -1449,7 +1532,7 @@ export default function PlansAndBillingPage() {
{/* Plans Grid */} -
+
{upgradePlans.map((plan, index) => { const isPopular = index === 1; const planPrice = Number(plan.price) || 0;