billing realted PK bank transfer settigns

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-20 03:35:35 +00:00
parent 4996e2e1aa
commit 0957ece3a4
4 changed files with 302 additions and 72 deletions

View File

@@ -880,6 +880,129 @@ class CreditPackageViewSet(viewsets.ReadOnlyModelViewSet):
request=request 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): class AccountPaymentMethodViewSet(AccountModelViewSet):
"""ViewSet for account payment methods - Full CRUD support""" """ViewSet for account payment methods - Full CRUD support"""

View File

@@ -599,7 +599,7 @@ class CreditPackage(models.Model):
# Display # Display
description = models.TextField(blank=True) 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
sort_order = models.IntegerField(default=0, help_text="Display order (lower = first)") sort_order = models.IntegerField(default=0, help_text="Display order (lower = first)")

View File

@@ -182,16 +182,28 @@ class InvoiceService:
local_currency = get_currency_for_country(account.billing_country) if account.billing_country else 'USD' 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 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( invoice = Invoice.objects.create(
account=account, account=account,
invoice_number=InvoiceService.generate_invoice_number(account), invoice_number=InvoiceService.generate_invoice_number(account),
billing_email=account.billing_email or account.users.filter(role='owner').first().email,
status='pending', status='pending',
currency=currency, currency=currency,
invoice_date=invoice_date, invoice_date=invoice_date,
due_date=invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET), due_date=invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET),
metadata={ metadata={
'billing_snapshot': {
'email': billing_email,
'name': account.name,
'billing_address': account.billing_address or '',
},
'credit_package_id': credit_package.id, 'credit_package_id': credit_package.id,
'credit_package_name': credit_package.name,
'credit_amount': credit_package.credits, 'credit_amount': credit_package.credits,
'usd_price': str(credit_package.price), # Store original USD price 'usd_price': str(credit_package.price), # Store original USD price
'local_currency': local_currency, # Store local currency code for display 'local_currency': local_currency, # Store local currency code for display
@@ -233,15 +245,27 @@ class InvoiceService:
notes: Invoice notes notes: Invoice notes
due_date: Payment due date 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( invoice = Invoice.objects.create(
account=account, account=account,
invoice_number=InvoiceService.generate_invoice_number(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', status='draft',
currency='USD', currency='USD',
notes=notes, notes=notes,
invoice_date=timezone.now().date(), 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 # Add all line items

View File

@@ -559,11 +559,10 @@ export default function PlansAndBillingPage() {
return; return;
} }
// Fall back to manual/bank transfer flow // For manual/bank transfer - use Stripe to create invoice, then pay via bank transfer
await createSubscription({ plan_id: planId, payment_method: selectedPaymentMethod }); // This creates the invoice which user can then pay using bank transfer from billing history
toast?.success?.('Plan upgraded successfully!'); 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); setShowUpgradeModal(false);
await loadData();
} catch (err: any) { } catch (err: any) {
handleError(err, 'Failed to upgrade plan'); handleError(err, 'Failed to upgrade plan');
} finally { } finally {
@@ -597,10 +596,36 @@ export default function PlansAndBillingPage() {
return; return;
} }
// Fall back to manual/bank transfer flow // For manual/bank transfer - create invoice and show payment modal
await purchaseCreditPackage({ package_id: packageId, payment_method: selectedPaymentMethod as any || 'manual' }); const result = await purchaseCreditPackage({
toast?.success?.('Credits purchased successfully!'); package_id: packageId,
await loadData(); 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) { } catch (err: any) {
handleError(err, 'Failed to purchase credits'); handleError(err, 'Failed to purchase credits');
} finally { } finally {
@@ -674,12 +699,22 @@ export default function PlansAndBillingPage() {
? Math.round((creditBalance.credits_used_this_month / creditBalance.plan_credits_per_month) * 100) ? Math.round((creditBalance.credits_used_this_month / creditBalance.plan_credits_per_month) * 100)
: 0; : 0;
// Upgrade plans (exclude current and free) // All paid plans for the upgrade panel (includes current plan)
const upgradePlans = plans.filter(p => { const allPaidPlans = plans.filter(p => {
const price = Number(p.price) || 0; 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)); }).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 // PAYMENT PROCESSING OVERLAY - Beautiful full-page loading with breathing badge
if (paymentProcessing?.active) { if (paymentProcessing?.active) {
const stageConfig = { const stageConfig = {
@@ -860,14 +895,16 @@ export default function PlansAndBillingPage() {
Manage Billing Manage Billing
</Button> </Button>
)} )}
<Button {!isOnHighestPlan && (
variant="primary" <Button
tone="brand" variant="primary"
onClick={() => setShowUpgradeModal(true)} tone="brand"
startIcon={<ArrowUpIcon className="w-4 h-4" />} onClick={() => setShowUpgradeModal(true)}
> startIcon={<ArrowUpIcon className="w-4 h-4" />}
Upgrade >
</Button> Upgrade
</Button>
)}
</div> </div>
</div> </div>
@@ -1043,14 +1080,14 @@ export default function PlansAndBillingPage() {
) : ( ) : (
<> <>
{/* Payment Method Selector - Clear buttons */} {/* Payment Method Selector - Clear buttons */}
{(availableGateways.stripe || availableGateways.paypal) && ( {(availableGateways.stripe || availableGateways.paypal || availableGateways.manual) && (
<div className="mb-4"> <div className="mb-4">
<Label className="text-xs text-gray-500 dark:text-gray-400 mb-2 block">Payment Method</Label> <Label className="text-xs text-gray-500 dark:text-gray-400 mb-2 block">Payment Method</Label>
<div className="flex gap-2"> <div className="flex gap-2 flex-wrap">
{availableGateways.stripe && ( {availableGateways.stripe && (
<button <button
onClick={() => setSelectedGateway('stripe')} onClick={() => setSelectedGateway('stripe')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${ className={`flex-1 min-w-[140px] flex items-center justify-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${
selectedGateway === 'stripe' selectedGateway === 'stripe'
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300' ? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
@@ -1063,7 +1100,7 @@ export default function PlansAndBillingPage() {
{availableGateways.paypal && ( {availableGateways.paypal && (
<button <button
onClick={() => setSelectedGateway('paypal')} onClick={() => setSelectedGateway('paypal')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${ className={`flex-1 min-w-[140px] flex items-center justify-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${
selectedGateway === 'paypal' selectedGateway === 'paypal'
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300' ? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
@@ -1073,6 +1110,19 @@ export default function PlansAndBillingPage() {
<span className="text-sm font-medium">PayPal</span> <span className="text-sm font-medium">PayPal</span>
</button> </button>
)} )}
{availableGateways.manual && (
<button
onClick={() => setSelectedGateway('manual')}
className={`flex-1 min-w-[140px] flex items-center justify-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${
selectedGateway === 'manual'
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<Building2Icon className={`w-5 h-5 ${selectedGateway === 'manual' ? 'text-brand-600' : 'text-gray-500'}`} />
<span className="text-sm font-medium">Bank Transfer</span>
</button>
)}
</div> </div>
</div> </div>
)} )}
@@ -1117,56 +1167,87 @@ export default function PlansAndBillingPage() {
<ShootingStarIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" /> <ShootingStarIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-900 dark:text-white">Upgrade Plan</h3> <h3 className="font-semibold text-gray-900 dark:text-white">
<p className="text-xs text-gray-500 dark:text-gray-400">Get more features & credits</p> {isOnHighestPlan ? 'Your Plan' : 'Upgrade Plan'}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{isOnHighestPlan ? 'You are on the highest plan' : 'Get more features & credits'}
</p>
</div> </div>
</div> </div>
<Button {!isOnHighestPlan && (
size="sm" <Button
variant="outline" size="sm"
tone="brand" variant="outline"
onClick={() => setShowUpgradeModal(true)} tone="brand"
> onClick={() => setShowUpgradeModal(true)}
View All >
</Button> View All
</Button>
)}
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{upgradePlans.slice(0, 3).map((plan) => ( {allPaidPlans.map((plan) => {
<div const isCurrentPlan = plan.id === effectivePlanId;
key={plan.id} const currentPriceVal = Number(currentPlan?.price) || 0;
className="p-4 border border-gray-200 dark:border-gray-700 rounded-xl flex items-center justify-between hover:border-purple-300 dark:hover:border-purple-700 transition-colors" const thisPlanPrice = Number(plan.price) || 0;
> const isUpgrade = thisPlanPrice > currentPriceVal;
<div> const isDowngrade = thisPlanPrice < currentPriceVal;
<div className="font-semibold text-gray-900 dark:text-white">{plan.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400"> return (
{plan.included_credits?.toLocaleString() || 0} credits/mo <div
</div> key={plan.id}
</div> className={`p-4 border rounded-xl flex items-center justify-between transition-colors ${
<div className="text-right"> isCurrentPlan
<div className="font-bold text-gray-900 dark:text-white"> ? 'border-brand-500 bg-brand-50/50 dark:bg-brand-900/20'
{formatCurrency(plan.price, 'USD')}/mo : 'border-gray-200 dark:border-gray-700 hover:border-purple-300 dark:hover:border-purple-700'
</div> }`}
{billingCountry === 'PK' && ( >
<div className="text-xs text-gray-500 dark:text-gray-400"> <div>
≈ PKR {convertUSDToPKR(plan.price).toLocaleString()}/mo <div className="flex items-center gap-2">
<span className="font-semibold text-gray-900 dark:text-white">{plan.name}</span>
{isCurrentPlan && (
<Badge variant="soft" tone="brand" size="sm">Current</Badge>
)}
</div> </div>
)} <div className="text-sm text-gray-500 dark:text-gray-400">
<Button {plan.included_credits?.toLocaleString() || 0} credits/mo
size="sm" </div>
variant="ghost" </div>
tone="brand" <div className="text-right">
onClick={() => handleSelectPlan(plan.id)} <div className="font-bold text-gray-900 dark:text-white">
disabled={planLoadingId === plan.id} {formatCurrency(plan.price, 'USD')}/mo
> </div>
{planLoadingId === plan.id ? <Loader2Icon className="w-4 h-4 animate-spin" /> : 'Select'} {billingCountry === 'PK' && (
</Button> <div className="text-xs text-gray-500 dark:text-gray-400">
≈ PKR {convertUSDToPKR(plan.price).toLocaleString()}/mo
</div>
)}
{!isCurrentPlan && (
<Button
size="sm"
variant="ghost"
tone={isUpgrade ? 'brand' : 'neutral'}
onClick={() => handleSelectPlan(plan.id)}
disabled={planLoadingId === plan.id}
>
{planLoadingId === plan.id ? (
<Loader2Icon className="w-4 h-4 animate-spin" />
) : isUpgrade ? (
'Upgrade'
) : (
'Downgrade'
)}
</Button>
)}
</div>
</div> </div>
</div> );
))} })}
{upgradePlans.length === 0 && ( {allPaidPlans.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> <div className="text-center py-8 text-gray-500 dark:text-gray-400">
<CheckCircleIcon className="w-8 h-8 mx-auto mb-2 text-success-500" /> <CheckCircleIcon className="w-8 h-8 mx-auto mb-2 text-success-500" />
<p>You're on the best plan!</p> <p>No plans available</p>
</div> </div>
)} )}
</div> </div>
@@ -1360,8 +1441,10 @@ export default function PlansAndBillingPage() {
{/* Upgrade Modal */} {/* Upgrade Modal */}
{showUpgradeModal && ( {showUpgradeModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-99999 flex items-center justify-center overflow-y-auto modal">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-auto"> {/* Backdrop */}
<div className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px] dark:bg-gray-900/70" onClick={() => setShowUpgradeModal(false)}></div>
<div className="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-auto mx-4">
<div className="sticky top-0 bg-white dark:bg-gray-900 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700 z-10"> <div className="sticky top-0 bg-white dark:bg-gray-900 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700 z-10">
<div> <div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Choose Your Plan</h2> <h2 className="text-xl font-bold text-gray-900 dark:text-white">Choose Your Plan</h2>
@@ -1449,7 +1532,7 @@ export default function PlansAndBillingPage() {
</div> </div>
{/* Plans Grid */} {/* Plans Grid */}
<div className="p-6 grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-4 max-w-3xl mx-auto">
{upgradePlans.map((plan, index) => { {upgradePlans.map((plan, index) => {
const isPopular = index === 1; const isPopular = index === 1;
const planPrice = Number(plan.price) || 0; const planPrice = Number(plan.price) || 0;