payemnt billing and credits refactoring

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-20 07:39:51 +00:00
parent a97c72640a
commit bc50b022f1
34 changed files with 3028 additions and 307 deletions

View File

@@ -130,13 +130,29 @@ export default function BillingUsagePanel({ showOnlyActivity = false }: BillingU
return (
<div className="space-y-6">
{balance && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<Card className="p-6">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Current Balance</h3>
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Plan Credits</h3>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{(balance?.credits ?? 0).toLocaleString()}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Available credits</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Reset on renewal</p>
</Card>
<Card className="p-6 border-l-4 border-l-success-500">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Bonus Credits</h3>
<div className="text-3xl font-bold text-success-600 dark:text-success-400">
{((balance as any)?.bonus_credits ?? 0).toLocaleString()}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Never expire</p>
</Card>
<Card className="p-6">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Total Available</h3>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{((balance as any)?.total_credits ?? (balance?.credits ?? 0)).toLocaleString()}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Plan + Bonus</p>
</Card>
<Card className="p-6">
@@ -145,21 +161,32 @@ export default function BillingUsagePanel({ showOnlyActivity = false }: BillingU
{(balance?.plan_credits_per_month ?? 0).toLocaleString()}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{(balance as any)?.subscription_plan || 'No plan'}
{(balance as any)?.subscription_plan || 'From plan'}
</p>
</Card>
<Card className="p-6">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Status</h3>
<div className="mt-2">
<Badge variant="light" className="text-lg">
{(balance as any)?.subscription_status || 'No subscription'}
</Badge>
</div>
</Card>
</div>
)}
{/* Info box about credit consumption order */}
{balance && ((balance as any)?.bonus_credits ?? 0) > 0 && (
<Card className="p-4 bg-info-50 dark:bg-info-900/20 border-info-200 dark:border-info-800">
<div className="flex items-start space-x-3">
<div className="text-info-600 dark:text-info-400">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h4 className="text-sm font-medium text-info-800 dark:text-info-200">Credit Consumption Order</h4>
<p className="text-sm text-info-700 dark:text-info-300 mt-1">
Plan credits are used first. Bonus credits are only consumed after plan credits are exhausted.
Bonus credits never expire and are not affected by plan renewals.
</p>
</div>
</div>
</Card>
)}
{usageLimits && (
<Card className="p-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Plan Limits</h2>

View File

@@ -25,7 +25,7 @@ import {
} from '../../icons';
import { API_BASE_URL } from '../../services/api';
import { useAuthStore } from '../../store/authStore';
import { subscribeToPlan, getAvailablePaymentMethods } from '../../services/billing.api';
import { subscribeToPlan, getAvailablePaymentMethods, purchaseCredits } from '../../services/billing.api';
interface BankDetails {
bank_name: string;
@@ -38,6 +38,8 @@ interface BankDetails {
interface Invoice {
id: number;
invoice_number: string;
invoice_type?: 'subscription' | 'credit_package' | 'addon' | 'custom';
credit_package_id?: string | number | null;
total?: string;
total_amount?: string;
currency?: string;
@@ -126,6 +128,8 @@ export default function PayInvoiceModal({
const currency = invoice.currency?.toUpperCase() || 'USD';
const planId = invoice.subscription?.plan?.id;
const planSlug = invoice.subscription?.plan?.slug;
const isCreditInvoice = invoice.invoice_type === 'credit_package';
const creditPackageId = invoice.credit_package_id ? String(invoice.credit_package_id) : null;
// Check if user's default method is selected (for showing badge)
const isDefaultMethod = (option: PaymentOption): boolean => {
@@ -181,6 +185,29 @@ export default function PayInvoiceModal({
}, [isOpen, isPakistan, selectedOption, userCountry, bankDetails]);
const handleStripePayment = async () => {
if (isCreditInvoice) {
if (!creditPackageId) {
setError('Unable to process card payment. Credit package not found on invoice. Please contact support.');
return;
}
try {
setLoading(true);
setError('');
const result = await purchaseCredits(creditPackageId, 'stripe', {
return_url: `${window.location.origin}/account/usage?purchase=success`,
cancel_url: `${window.location.origin}/account/usage?purchase=canceled`,
});
window.location.href = result.redirect_url;
} catch (err: any) {
setError(err.message || 'Failed to initiate card payment');
setLoading(false);
}
return;
}
// Use plan slug if available, otherwise fall back to id
const planIdentifier = planSlug || (planId ? String(planId) : null);
@@ -208,6 +235,29 @@ export default function PayInvoiceModal({
};
const handlePayPalPayment = async () => {
if (isCreditInvoice) {
if (!creditPackageId) {
setError('Unable to process PayPal payment. Credit package not found on invoice. Please contact support.');
return;
}
try {
setLoading(true);
setError('');
const result = await purchaseCredits(creditPackageId, 'paypal', {
return_url: `${window.location.origin}/account/usage?purchase=success`,
cancel_url: `${window.location.origin}/account/usage?purchase=canceled`,
});
window.location.href = result.redirect_url;
} catch (err: any) {
setError(err.message || 'Failed to initiate PayPal payment');
setLoading(false);
}
return;
}
// Use plan slug if available, otherwise fall back to id
const planIdentifier = planSlug || (planId ? String(planId) : null);

View File

@@ -368,7 +368,7 @@ export default function SiteDashboard() {
/>
<CreditAvailabilityWidget
availableCredits={balance?.credits_remaining ?? 0}
availableCredits={(balance as any)?.total_credits ?? balance?.credits_remaining ?? 0}
totalCredits={balance?.plan_credits_per_month ?? 0}
usedCredits={balance?.credits_used_this_month ?? 0}
loading={loading}

View File

@@ -921,16 +921,26 @@ export default function PlansAndBillingPage() {
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm">
<div className="flex items-center gap-2 text-sm text-brand-700 dark:text-brand-300 mb-1">
<ZapIcon className="w-4 h-4 text-brand-600" />
Credits
Plan Credits
</div>
<div className="text-2xl font-bold text-brand-900 dark:text-white">
{creditBalance?.credits?.toLocaleString() || 0}
</div>
<div className="text-xs text-brand-600 dark:text-brand-400 mt-1">Available now</div>
<div className="text-xs text-brand-600 dark:text-brand-400 mt-1">Reset on renewal</div>
</div>
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm border-l-4 border-l-success-500">
<div className="flex items-center gap-2 text-sm text-success-700 dark:text-success-300 mb-1">
<ZapIcon className="w-4 h-4 text-success-600" />
Bonus Credits
</div>
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
{((creditBalance as any)?.bonus_credits || 0).toLocaleString()}
</div>
<div className="text-xs text-success-600 dark:text-success-400 mt-1">Never expire</div>
</div>
<div className="p-4 bg-white/80 dark:bg-gray-800/60 rounded-xl shadow-sm">
<div className="flex items-center gap-2 text-sm text-purple-700 dark:text-purple-300 mb-1">
@@ -972,6 +982,21 @@ export default function PlansAndBillingPage() {
</div>
</div>
{/* Total Credits Info Box */}
{((creditBalance as any)?.bonus_credits || 0) > 0 && (
<div className="mt-4 p-3 bg-white/60 dark:bg-gray-800/60 rounded-lg border border-success-200 dark:border-success-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Total Available:</span>
<span className="text-lg font-bold text-brand-700 dark:text-brand-400">
{((creditBalance?.credits || 0) + ((creditBalance as any)?.bonus_credits || 0)).toLocaleString()} credits
</span>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">Plan + Bonus</span>
</div>
</div>
)}
{/* Credit Usage Bar */}
<div className="mt-6 pt-6 border-t border-brand-200/50 dark:border-brand-700/30">
<div className="flex justify-between text-sm mb-2">
@@ -1319,17 +1344,22 @@ export default function PlansAndBillingPage() {
)}
</td>
<td className="px-6 py-3 text-center">
<Badge variant="soft" tone={invoice.status === 'paid' ? 'success' : 'warning'}>
<Badge variant="soft" tone={
invoice.status === 'paid' ? 'success' :
invoice.status === 'failed' ? 'error' :
invoice.status === 'overdue' ? 'error' :
'warning'
}>
{invoice.status}
</Badge>
</td>
<td className="px-6 py-3 text-end">
<div className="flex items-center justify-end gap-2">
{invoice.status === 'pending' && (
{['pending', 'overdue', 'failed', 'sent'].includes(invoice.status) && (
<Button
size="sm"
variant="primary"
tone="brand"
tone={invoice.status === 'overdue' || invoice.status === 'failed' ? 'error' : 'brand'}
startIcon={<DollarLineIcon className="w-4 h-4" />}
onClick={() => {
setSelectedInvoice(invoice);

View File

@@ -469,27 +469,49 @@ export default function UsageDashboardPage() {
</Link>
</div>
<div className="grid grid-cols-3 gap-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<div className="text-4xl font-bold text-brand-700 dark:text-brand-400 mb-1">
<div className="text-3xl font-bold text-brand-700 dark:text-brand-400 mb-1">
{creditBalance?.credits.toLocaleString() || 0}
</div>
<div className="text-sm text-brand-600 dark:text-brand-300">Available Now</div>
<div className="text-sm text-brand-600 dark:text-brand-300">Plan Credits</div>
</div>
<div>
<div className="text-4xl font-bold text-purple-700 dark:text-purple-400 mb-1">
<div className="text-3xl font-bold text-success-600 dark:text-success-400 mb-1">
{((creditBalance as any)?.bonus_credits || 0).toLocaleString()}
</div>
<div className="text-sm text-success-600 dark:text-success-300">Bonus Credits</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Never expire</div>
</div>
<div>
<div className="text-3xl font-bold text-purple-700 dark:text-purple-400 mb-1">
{creditBalance?.credits_used_this_month.toLocaleString() || 0}
</div>
<div className="text-sm text-purple-600 dark:text-purple-300">Used This Month</div>
</div>
<div>
<div className="text-4xl font-bold text-indigo-800 dark:text-white mb-1">
<div className="text-3xl font-bold text-indigo-800 dark:text-white mb-1">
{creditBalance?.plan_credits_per_month.toLocaleString() || 0}
</div>
<div className="text-sm text-indigo-600 dark:text-indigo-300">Monthly Allowance</div>
</div>
</div>
{/* Total Available Credits */}
{((creditBalance as any)?.bonus_credits || 0) > 0 && (
<div className="mt-4 p-3 bg-white/60 dark:bg-gray-800/60 rounded-lg border border-brand-200 dark:border-brand-700">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Total Available</span>
<span className="text-lg font-bold text-brand-700 dark:text-brand-400">
{((creditBalance?.credits || 0) + ((creditBalance as any)?.bonus_credits || 0)).toLocaleString()}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Plan credits are used first, then bonus credits
</p>
</div>
)}
{/* Credit Usage Bar */}
<div className="mt-6">
<div className="flex justify-between text-sm mb-2">

View File

@@ -2087,7 +2087,9 @@ export async function fetchGlobalModuleSettings(): Promise<GlobalModuleSettings>
// Billing API functions
export interface CreditBalance {
credits: number;
credits: number; // Plan credits (reset on renewal)
bonus_credits: number; // Purchased credits (never expire)
total_credits: number; // Sum of plan + bonus credits
plan_credits_per_month: number;
credits_used_this_month: number;
credits_remaining: number;
@@ -2143,6 +2145,8 @@ export async function fetchCreditBalance(): Promise<CreditBalance> {
// Default if response is invalid
return {
credits: 0,
bonus_credits: 0,
total_credits: 0,
plan_credits_per_month: 0,
credits_used_this_month: 0,
credits_remaining: 0,
@@ -2152,6 +2156,8 @@ export async function fetchCreditBalance(): Promise<CreditBalance> {
// Return default balance on error so UI can still render
return {
credits: 0,
bonus_credits: 0,
total_credits: 0,
plan_credits_per_month: 0,
credits_used_this_month: 0,
credits_remaining: 0,