feat: Quota overage system, updated tier pricing, and communication addons

Quota Overage System:
- Add QuotaOverage model for tracking resource/user quota overages
- Implement 30-day grace period with email notifications (immediate, 7-day, 1-day)
- Add QuotaWarningBanner component in BusinessLayout
- Add QuotaSettings page for managing overages and archiving resources
- Add Celery tasks for automated quota checks and expiration handling
- Add quota management API endpoints

Updated Tier Pricing (Stripe: 2.9% + $0.30):
- Free: No payments (requires addon)
- Starter: 4% + $0.40
- Professional: 3.5% + $0.35
- Business: 3.25% + $0.32
- Enterprise: 3% + $0.30

New Subscription Addons:
- Online Payments ($5/mo + 5% + $0.50) - for Free tier
- SMS Notifications ($10/mo) - enables SMS reminders
- Masked Calling ($15/mo) - enables anonymous calling

BusinessEditModal Improvements:
- Increased width to match PlanModal (max-w-3xl)
- Added all tier options with auto-update on tier change
- Added limits configuration and permissions sections

Backend Fixes:
- Fixed SubscriptionPlan serializer to include all communication fields
- Allow blank business_tier for addon plans
- Added migration for business_tier field changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-02 13:05:02 -05:00
parent dc3210927a
commit 08b51d1a5f
36 changed files with 3469 additions and 350 deletions

View File

@@ -0,0 +1,131 @@
import React from 'react';
import { AlertTriangle, X, ExternalLink } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { QuotaOverage } from '../api/auth';
interface QuotaWarningBannerProps {
overages: QuotaOverage[];
onDismiss?: () => void;
}
const QuotaWarningBanner: React.FC<QuotaWarningBannerProps> = ({ overages, onDismiss }) => {
const { t } = useTranslation();
if (!overages || overages.length === 0) {
return null;
}
// Find the most urgent overage (least days remaining)
const mostUrgent = overages.reduce((prev, curr) =>
curr.days_remaining < prev.days_remaining ? curr : prev
);
const isUrgent = mostUrgent.days_remaining <= 7;
const isCritical = mostUrgent.days_remaining <= 1;
const getBannerStyles = () => {
if (isCritical) {
return 'bg-red-600 text-white border-red-700';
}
if (isUrgent) {
return 'bg-amber-500 text-white border-amber-600';
}
return 'bg-amber-100 text-amber-900 border-amber-300';
};
const getIconColor = () => {
if (isCritical || isUrgent) {
return 'text-white';
}
return 'text-amber-600';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
return (
<div className={`border-b ${getBannerStyles()}`}>
<div className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<AlertTriangle className={`h-5 w-5 flex-shrink-0 ${getIconColor()}`} />
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
<span className="font-medium">
{isCritical
? t('quota.banner.critical', 'URGENT: Automatic archiving tomorrow!')
: isUrgent
? t('quota.banner.urgent', 'Action Required: {{days}} days left', { days: mostUrgent.days_remaining })
: t('quota.banner.warning', 'Quota exceeded for {{count}} item(s)', { count: overages.length })
}
</span>
<span className="text-sm opacity-90">
{t('quota.banner.details',
'You have {{overage}} {{type}} over your plan limit. Grace period ends {{date}}.',
{
overage: mostUrgent.overage_amount,
type: mostUrgent.display_name,
date: formatDate(mostUrgent.grace_period_ends_at)
}
)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Link
to="/settings/quota"
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
isCritical || isUrgent
? 'bg-white/20 hover:bg-white/30 text-white'
: 'bg-amber-600 hover:bg-amber-700 text-white'
}`}
>
{t('quota.banner.manage', 'Manage Quota')}
<ExternalLink className="h-4 w-4" />
</Link>
{onDismiss && (
<button
onClick={onDismiss}
className={`p-1 rounded-md transition-colors ${
isCritical || isUrgent
? 'hover:bg-white/20'
: 'hover:bg-amber-200'
}`}
aria-label={t('common.dismiss', 'Dismiss')}
>
<X className="h-5 w-5" />
</button>
)}
</div>
</div>
{/* Show additional overages if there are more than one */}
{overages.length > 1 && (
<div className="mt-2 text-sm opacity-90">
<span className="font-medium">{t('quota.banner.allOverages', 'All overages:')}</span>
<ul className="ml-4 mt-1 space-y-0.5">
{overages.map((overage) => (
<li key={overage.id}>
{overage.display_name}: {overage.current_usage}/{overage.allowed_limit}
({t('quota.banner.overBy', 'over by {{amount}}', { amount: overage.overage_amount })})
{' - '}
{overage.days_remaining <= 0
? t('quota.banner.expiredToday', 'expires today!')
: t('quota.banner.daysLeft', '{{days}} days left', { days: overage.days_remaining })
}
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
};
export default QuotaWarningBanner;