billing realted PK bank transfer settigns
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user