Files
smoothschedule/frontend/src/billing/components/PlanDetailPanel.tsx
poduck b384d9912a Add TenantCustomTier system and fix BusinessEditModal feature loading
Backend:
- Add TenantCustomTier model for per-tenant feature overrides
- Update EntitlementService to check custom tier before plan features
- Add custom_tier action on TenantViewSet (GET/PUT/DELETE)
- Add Celery task for grace period management (30-day expiry)

Frontend:
- Add DynamicFeaturesEditor component for dynamic feature management
- Fix BusinessEditModal to load features from plan defaults when no custom tier
- Update limits (max_users, max_resources, etc.) to use featureValues
- Remove outdated canonical feature check from FeaturePicker (removes warning icons)
- Add useBillingPlans hook for accessing billing system data
- Add custom tier API functions to platform.ts

Features now follow consistent rules:
- Load from plan defaults when no custom tier exists
- Load from custom tier when one exists
- Reset to plan defaults when plan changes
- Save to custom tier on edit

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 21:00:54 -05:00

767 lines
29 KiB
TypeScript

/**
* PlanDetailPanel Component
*
* Detail view for a selected plan or add-on, shown in the main panel
* of the master-detail layout.
*/
import React, { useState } from 'react';
import {
Package,
Pencil,
Copy,
Trash2,
DollarSign,
Users,
Check,
AlertTriangle,
ExternalLink,
ChevronDown,
ChevronRight,
Plus,
Archive,
} from 'lucide-react';
import { Badge, Alert, Modal, ModalFooter } from '../../components/ui';
import {
usePlanVersionSubscribers,
useDeletePlan,
useDeletePlanVersion,
useMarkVersionLegacy,
useForceUpdatePlanVersion,
formatCentsToDollars,
type PlanWithVersions,
type PlanVersion,
type AddOnProduct,
} from '../../hooks/useBillingAdmin';
import { useCurrentUser } from '../../hooks/useAuth';
// =============================================================================
// Types
// =============================================================================
interface PlanDetailPanelProps {
plan: PlanWithVersions | null;
addon: AddOnProduct | null;
onEdit: () => void;
onDuplicate: () => void;
onCreateVersion: () => void;
onEditVersion: (version: PlanVersion) => void;
}
// =============================================================================
// Component
// =============================================================================
export const PlanDetailPanel: React.FC<PlanDetailPanelProps> = ({
plan,
addon,
onEdit,
onDuplicate,
onCreateVersion,
onEditVersion,
}) => {
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['overview', 'pricing', 'features'])
);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [showForcePushModal, setShowForcePushModal] = useState(false);
const [forcePushConfirmText, setForcePushConfirmText] = useState('');
const [forcePushError, setForcePushError] = useState<string | null>(null);
const [forcePushSuccess, setForcePushSuccess] = useState<string | null>(null);
const { data: currentUser } = useCurrentUser();
const isSuperuser = currentUser?.is_superuser ?? false;
const deletePlanMutation = useDeletePlan();
const deleteVersionMutation = useDeletePlanVersion();
const markLegacyMutation = useMarkVersionLegacy();
const forceUpdateMutation = useForceUpdatePlanVersion();
if (!plan && !addon) {
return (
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
<div className="text-center">
<Package className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>Select a plan or add-on from the catalog</p>
</div>
</div>
);
}
const toggleSection = (section: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
const activeVersion = plan?.active_version;
const handleDelete = async () => {
if (!plan) return;
const expectedText = `DELETE ${plan.code}`;
if (deleteConfirmText !== expectedText) {
return;
}
try {
await deletePlanMutation.mutateAsync(plan.id);
setShowDeleteConfirm(false);
setDeleteConfirmText('');
} catch (error) {
console.error('Failed to delete plan:', error);
}
};
const handleForcePush = async () => {
if (!plan || !activeVersion) return;
const expectedText = `FORCE PUSH ${plan.code}`;
if (forcePushConfirmText !== expectedText) {
setForcePushError('Please type the confirmation text exactly.');
return;
}
setForcePushError(null);
try {
const result = await forceUpdateMutation.mutateAsync({
id: activeVersion.id,
confirm: true,
// Pass current version data to ensure it's updated in place
name: activeVersion.name,
});
if ('version' in result) {
setForcePushSuccess(
`Successfully pushed changes to ${result.affected_count} subscriber(s).`
);
setTimeout(() => {
setShowForcePushModal(false);
setForcePushConfirmText('');
setForcePushSuccess(null);
}, 2000);
}
} catch (error: any) {
const errorMessage = error.response?.data?.detail || error.message || 'Failed to force push';
setForcePushError(errorMessage);
}
};
// Render Plan Detail
if (plan) {
return (
<div className="flex-1 overflow-y-auto">
{/* Header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-6 z-10">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{plan.name}
</h2>
<span className="px-2 py-1 text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
{plan.code}
</span>
{!plan.is_active && <Badge variant="warning">Inactive</Badge>}
</div>
{plan.description && (
<p className="text-gray-600 dark:text-gray-400">{plan.description}</p>
)}
{activeVersion && (
<div className="flex items-center gap-4 mt-3 text-sm text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<DollarSign className="w-4 h-4" />
{activeVersion.price_monthly_cents === 0
? 'Free'
: `$${formatCentsToDollars(activeVersion.price_monthly_cents)}/mo`}
</span>
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />
{plan.total_subscribers} subscriber{plan.total_subscribers !== 1 ? 's' : ''}
</span>
<span>Version {activeVersion.version}</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={onEdit}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
<Pencil className="w-4 h-4" />
Edit
</button>
<button
onClick={onDuplicate}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
<Copy className="w-4 h-4" />
Duplicate
</button>
<button
onClick={onCreateVersion}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
New Version
</button>
</div>
</div>
</div>
<div className="p-6 space-y-6">
{/* Overview Section */}
<CollapsibleSection
title="Overview"
isExpanded={expandedSections.has('overview')}
onToggle={() => toggleSection('overview')}
>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Plan Code</label>
<p className="font-medium text-gray-900 dark:text-white">{plan.code}</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Display Order</label>
<p className="font-medium text-gray-900 dark:text-white">{plan.display_order}</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Status</label>
<p className="font-medium text-gray-900 dark:text-white">
{plan.is_active ? 'Active' : 'Inactive'}
</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Total Subscribers</label>
<p className="font-medium text-gray-900 dark:text-white">{plan.total_subscribers}</p>
</div>
</div>
</CollapsibleSection>
{/* Pricing Section */}
{activeVersion && (
<CollapsibleSection
title="Pricing"
isExpanded={expandedSections.has('pricing')}
onToggle={() => toggleSection('pricing')}
>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Monthly</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
${formatCentsToDollars(activeVersion.price_monthly_cents)}
</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Yearly</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
${formatCentsToDollars(activeVersion.price_yearly_cents)}
</p>
{activeVersion.price_yearly_cents > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
${(activeVersion.price_yearly_cents / 12 / 100).toFixed(2)}/mo
</p>
)}
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Trial</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
{activeVersion.trial_days} days
</p>
</div>
</div>
</CollapsibleSection>
)}
{/* Transaction Fees Section */}
{activeVersion && (
<CollapsibleSection
title="Transaction Fees"
isExpanded={expandedSections.has('fees')}
onToggle={() => toggleSection('fees')}
>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Percentage</label>
<p className="font-medium text-gray-900 dark:text-white">
{activeVersion.transaction_fee_percent}%
</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Fixed Fee</label>
<p className="font-medium text-gray-900 dark:text-white">
${(activeVersion.transaction_fee_fixed_cents / 100).toFixed(2)}
</p>
</div>
</div>
</CollapsibleSection>
)}
{/* Features Section */}
{activeVersion && activeVersion.features.length > 0 && (
<CollapsibleSection
title={`Features (${activeVersion.features.length})`}
isExpanded={expandedSections.has('features')}
onToggle={() => toggleSection('features')}
>
<div className="grid grid-cols-2 gap-2">
{activeVersion.features.map((f) => (
<div
key={f.id}
className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/50 rounded"
>
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
<span className="text-sm text-gray-900 dark:text-white">
{f.feature.name}
</span>
{f.int_value !== null && (
<span className="ml-auto text-sm text-gray-500 dark:text-gray-400">
{f.int_value === 0 ? 'Unlimited' : f.int_value}
</span>
)}
</div>
))}
</div>
</CollapsibleSection>
)}
{/* Stripe Section */}
{activeVersion &&
(activeVersion.stripe_product_id ||
activeVersion.stripe_price_id_monthly ||
activeVersion.stripe_price_id_yearly) && (
<CollapsibleSection
title="Stripe Integration"
isExpanded={expandedSections.has('stripe')}
onToggle={() => toggleSection('stripe')}
>
<div className="space-y-2">
{activeVersion.stripe_product_id && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">Product:</span>
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{activeVersion.stripe_product_id}
</code>
</div>
)}
{activeVersion.stripe_price_id_monthly && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">
Monthly Price:
</span>
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{activeVersion.stripe_price_id_monthly}
</code>
</div>
)}
{activeVersion.stripe_price_id_yearly && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">
Yearly Price:
</span>
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{activeVersion.stripe_price_id_yearly}
</code>
</div>
)}
{!activeVersion.stripe_product_id && (
<Alert
variant="warning"
message="No Stripe Product ID configured. This plan cannot be purchased until Stripe is set up."
/>
)}
</div>
</CollapsibleSection>
)}
{/* Versions Section */}
<CollapsibleSection
title={`Versions (${plan.versions.length})`}
isExpanded={expandedSections.has('versions')}
onToggle={() => toggleSection('versions')}
>
<div className="space-y-2">
{plan.versions.map((version) => (
<VersionRow
key={version.id}
version={version}
isActive={!version.is_legacy}
onEdit={() => onEditVersion(version)}
onMarkLegacy={() => markLegacyMutation.mutate(version.id)}
onDelete={() => deleteVersionMutation.mutate(version.id)}
/>
))}
</div>
</CollapsibleSection>
{/* Danger Zone */}
<CollapsibleSection
title="Danger Zone"
isExpanded={expandedSections.has('danger')}
onToggle={() => toggleSection('danger')}
variant="danger"
>
<div className="space-y-4">
{/* Force Push to Subscribers - Superuser Only */}
{isSuperuser && activeVersion && plan.total_subscribers > 0 && (
<div className="p-4 border border-orange-200 dark:border-orange-800 rounded-lg bg-orange-50 dark:bg-orange-900/20">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-orange-600 dark:text-orange-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="font-medium text-orange-800 dark:text-orange-200 mb-1">
Force Push Changes to All Subscribers
</h4>
<p className="text-sm text-orange-700 dark:text-orange-300 mb-3">
This will modify the current plan version in place, immediately affecting
all {plan.total_subscribers} active subscriber(s). This bypasses grandfathering
and cannot be undone. Changes to pricing, features, and limits will take
effect immediately.
</p>
<button
onClick={() => {
setShowForcePushModal(true);
setForcePushError(null);
setForcePushSuccess(null);
setForcePushConfirmText('');
}}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700"
>
<AlertTriangle className="w-4 h-4" />
Force Push to Subscribers
</button>
</div>
</div>
</div>
)}
{/* Delete Plan */}
<div className="p-4 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Deleting a plan is permanent and cannot be undone. Plans with active subscribers
cannot be deleted.
</p>
{plan.total_subscribers > 0 ? (
<Alert
variant="warning"
message={`This plan has ${plan.total_subscribers} active subscriber(s) and cannot be deleted.`}
/>
) : (
<button
onClick={() => setShowDeleteConfirm(true)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700"
>
<Trash2 className="w-4 h-4" />
Delete Plan
</button>
)}
</div>
</div>
</CollapsibleSection>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<Modal
isOpen
onClose={() => {
setShowDeleteConfirm(false);
setDeleteConfirmText('');
}}
title="Delete Plan"
size="sm"
>
<div className="space-y-4">
<Alert
variant="error"
message="This action cannot be undone. This will permanently delete the plan and all its versions."
/>
<p className="text-sm text-gray-600 dark:text-gray-400">
To confirm, type <strong>DELETE {plan.code}</strong> below:
</p>
<input
type="text"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder={`DELETE ${plan.code}`}
/>
</div>
<ModalFooter
onCancel={() => {
setShowDeleteConfirm(false);
setDeleteConfirmText('');
}}
submitText="Delete Plan"
submitVariant="danger"
isDisabled={deleteConfirmText !== `DELETE ${plan.code}`}
isLoading={deletePlanMutation.isPending}
onSubmit={handleDelete}
/>
</Modal>
)}
{/* Force Push Confirmation Modal */}
{showForcePushModal && activeVersion && (
<Modal
isOpen
onClose={() => {
setShowForcePushModal(false);
setForcePushConfirmText('');
setForcePushError(null);
setForcePushSuccess(null);
}}
title="Force Push to All Subscribers"
size="md"
>
<div className="space-y-4">
{forcePushSuccess ? (
<Alert variant="success" message={forcePushSuccess} />
) : (
<>
<Alert
variant="error"
message={
<div>
<strong>DANGER: This action affects paying customers!</strong>
<ul className="mt-2 ml-4 list-disc text-sm">
<li>All {plan.total_subscribers} subscriber(s) will be affected immediately</li>
<li>Changes to pricing will apply to future billing cycles</li>
<li>Feature and limit changes take effect immediately</li>
<li>This bypasses grandfathering protection</li>
<li>This action cannot be undone</li>
</ul>
</div>
}
/>
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
<strong>Current version:</strong> v{activeVersion.version} - {activeVersion.name}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Price:</strong> ${formatCentsToDollars(activeVersion.price_monthly_cents)}/mo
</p>
</div>
{forcePushError && (
<Alert variant="error" message={forcePushError} />
)}
<p className="text-sm text-gray-600 dark:text-gray-400">
To confirm this dangerous action, type <strong>FORCE PUSH {plan.code}</strong> below:
</p>
<input
type="text"
value={forcePushConfirmText}
onChange={(e) => setForcePushConfirmText(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder={`FORCE PUSH ${plan.code}`}
/>
</>
)}
</div>
{!forcePushSuccess && (
<ModalFooter
onCancel={() => {
setShowForcePushModal(false);
setForcePushConfirmText('');
setForcePushError(null);
}}
submitText="Force Push Changes"
submitVariant="danger"
isDisabled={forcePushConfirmText !== `FORCE PUSH ${plan.code}`}
isLoading={forceUpdateMutation.isPending}
onSubmit={handleForcePush}
/>
)}
</Modal>
)}
</div>
);
}
// Render Add-on Detail
if (addon) {
return (
<div className="flex-1 overflow-y-auto">
<div className="p-6">
<div className="flex items-start justify-between mb-6">
<div>
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{addon.name}
</h2>
<span className="px-2 py-1 text-sm font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded">
{addon.code}
</span>
{!addon.is_active && <Badge variant="warning">Inactive</Badge>}
</div>
{addon.description && (
<p className="text-gray-600 dark:text-gray-400">{addon.description}</p>
)}
</div>
<button
onClick={onEdit}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
<Pencil className="w-4 h-4" />
Edit
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<label className="text-sm text-gray-500 dark:text-gray-400">Monthly Price</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
${formatCentsToDollars(addon.price_monthly_cents)}
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<label className="text-sm text-gray-500 dark:text-gray-400">One-time Price</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
${formatCentsToDollars(addon.price_one_time_cents)}
</p>
</div>
</div>
</div>
</div>
);
}
return null;
};
// =============================================================================
// Collapsible Section
// =============================================================================
interface CollapsibleSectionProps {
title: string;
isExpanded: boolean;
onToggle: () => void;
children: React.ReactNode;
variant?: 'default' | 'danger';
}
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
isExpanded,
onToggle,
children,
variant = 'default',
}) => {
return (
<div
className={`border rounded-lg ${
variant === 'danger'
? 'border-red-200 dark:border-red-800'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<button
onClick={onToggle}
className={`w-full flex items-center justify-between p-4 text-left ${
variant === 'danger'
? 'text-red-700 dark:text-red-400'
: 'text-gray-900 dark:text-white'
}`}
>
<span className="font-medium">{title}</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</button>
{isExpanded && (
<div className="px-4 pb-4 border-t border-gray-200 dark:border-gray-700 pt-4">
{children}
</div>
)}
</div>
);
};
// =============================================================================
// Version Row
// =============================================================================
interface VersionRowProps {
version: PlanVersion;
isActive: boolean;
onEdit: () => void;
onMarkLegacy: () => void;
onDelete: () => void;
}
const VersionRow: React.FC<VersionRowProps> = ({
version,
isActive,
onEdit,
onMarkLegacy,
onDelete,
}) => {
return (
<div
className={`flex items-center justify-between p-3 rounded-lg ${
version.is_legacy
? 'bg-gray-50 dark:bg-gray-700/50'
: 'bg-blue-50 dark:bg-blue-900/20'
}`}
>
<div className="flex items-center gap-3">
<span className="font-medium text-gray-900 dark:text-white">v{version.version}</span>
<span className="text-gray-500 dark:text-gray-400">{version.name}</span>
{version.is_legacy && (
<span className="px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded">
Legacy
</span>
)}
{!version.is_public && !version.is_legacy && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
Hidden
</span>
)}
<span className="text-sm text-gray-500 dark:text-gray-400">
{version.subscriber_count} subscriber{version.subscriber_count !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={onEdit}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Edit version"
>
<Pencil className="w-4 h-4" />
</button>
{!version.is_legacy && version.subscriber_count === 0 && (
<button
onClick={onMarkLegacy}
className="p-1 text-gray-400 hover:text-amber-600 dark:hover:text-amber-400"
title="Mark as legacy"
>
<Archive className="w-4 h-4" />
</button>
)}
{version.subscriber_count === 0 && (
<button
onClick={onDelete}
className="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400"
title="Delete version"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
);
};