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

@@ -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):

View File

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

View File

@@ -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

View File

@@ -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
</Button>
)}
<Button
variant="primary"
tone="brand"
onClick={() => setShowUpgradeModal(true)}
startIcon={<ArrowUpIcon className="w-4 h-4" />}
>
Upgrade
</Button>
{!isOnHighestPlan && (
<Button
variant="primary"
tone="brand"
onClick={() => setShowUpgradeModal(true)}
startIcon={<ArrowUpIcon className="w-4 h-4" />}
>
Upgrade
</Button>
)}
</div>
</div>
@@ -1043,14 +1080,14 @@ export default function PlansAndBillingPage() {
) : (
<>
{/* Payment Method Selector - Clear buttons */}
{(availableGateways.stripe || availableGateways.paypal) && (
{(availableGateways.stripe || availableGateways.paypal || availableGateways.manual) && (
<div className="mb-4">
<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 && (
<button
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'
? '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'
@@ -1063,7 +1100,7 @@ export default function PlansAndBillingPage() {
{availableGateways.paypal && (
<button
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'
? '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'
@@ -1073,6 +1110,19 @@ export default function PlansAndBillingPage() {
<span className="text-sm font-medium">PayPal</span>
</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>
)}
@@ -1117,56 +1167,87 @@ export default function PlansAndBillingPage() {
<ShootingStarIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">Upgrade Plan</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">Get more features & credits</p>
<h3 className="font-semibold text-gray-900 dark:text-white">
{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>
<Button
size="sm"
variant="outline"
tone="brand"
onClick={() => setShowUpgradeModal(true)}
>
View All
</Button>
{!isOnHighestPlan && (
<Button
size="sm"
variant="outline"
tone="brand"
onClick={() => setShowUpgradeModal(true)}
>
View All
</Button>
)}
</div>
<div className="space-y-3">
{upgradePlans.slice(0, 3).map((plan) => (
<div
key={plan.id}
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"
>
<div>
<div className="font-semibold text-gray-900 dark:text-white">{plan.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{plan.included_credits?.toLocaleString() || 0} credits/mo
</div>
</div>
<div className="text-right">
<div className="font-bold text-gray-900 dark:text-white">
{formatCurrency(plan.price, 'USD')}/mo
</div>
{billingCountry === 'PK' && (
<div className="text-xs text-gray-500 dark:text-gray-400">
≈ 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 (
<div
key={plan.id}
className={`p-4 border rounded-xl flex items-center justify-between transition-colors ${
isCurrentPlan
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-purple-300 dark:hover:border-purple-700'
}`}
>
<div>
<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>
)}
<Button
size="sm"
variant="ghost"
tone="brand"
onClick={() => handleSelectPlan(plan.id)}
disabled={planLoadingId === plan.id}
>
{planLoadingId === plan.id ? <Loader2Icon className="w-4 h-4 animate-spin" /> : 'Select'}
</Button>
<div className="text-sm text-gray-500 dark:text-gray-400">
{plan.included_credits?.toLocaleString() || 0} credits/mo
</div>
</div>
<div className="text-right">
<div className="font-bold text-gray-900 dark:text-white">
{formatCurrency(plan.price, 'USD')}/mo
</div>
{billingCountry === 'PK' && (
<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>
))}
{upgradePlans.length === 0 && (
);
})}
{allPaidPlans.length === 0 && (
<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" />
<p>You're on the best plan!</p>
<p>No plans available</p>
</div>
)}
</div>
@@ -1360,8 +1441,10 @@ export default function PlansAndBillingPage() {
{/* Upgrade Modal */}
{showUpgradeModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-auto">
<div className="fixed inset-0 z-99999 flex items-center justify-center overflow-y-auto modal">
{/* 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>
<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>
{/* 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) => {
const isPopular = index === 1;
const planPrice = Number(plan.price) || 0;