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>
This commit is contained in:
@@ -3,12 +3,11 @@
|
||||
*
|
||||
* A searchable picker for selecting features to include in a plan or version.
|
||||
* Features are grouped by type (boolean capabilities vs integer limits).
|
||||
* Non-canonical features (not in the catalog) are flagged with a warning.
|
||||
* Features are loaded dynamically from the billing API.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Check, Sliders, Search, X, AlertTriangle } from 'lucide-react';
|
||||
import { isCanonicalFeature } from '../featureCatalog';
|
||||
import { Check, Sliders, Search, X } from 'lucide-react';
|
||||
import type { Feature, PlanFeatureWrite } from '../../hooks/useBillingAdmin';
|
||||
|
||||
export interface FeaturePickerProps {
|
||||
@@ -151,7 +150,6 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
||||
<div className={`grid ${compact ? 'grid-cols-1' : 'grid-cols-2'} gap-2`}>
|
||||
{filteredBooleanFeatures.map((feature) => {
|
||||
const selected = isSelected(feature.code);
|
||||
const isCanonical = isCanonicalFeature(feature.code);
|
||||
|
||||
return (
|
||||
<label
|
||||
@@ -170,16 +168,9 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
||||
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{feature.name}
|
||||
</span>
|
||||
{!isCanonical && (
|
||||
<span title="Not in canonical catalog">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.description && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
|
||||
{feature.description}
|
||||
@@ -206,7 +197,6 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
||||
{filteredIntegerFeatures.map((feature) => {
|
||||
const selectedFeature = getSelectedFeature(feature.code);
|
||||
const selected = !!selectedFeature;
|
||||
const isCanonical = isCanonicalFeature(feature.code);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -225,18 +215,9 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
||||
aria-label={feature.name}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{feature.name}
|
||||
</span>
|
||||
{!isCanonical && (
|
||||
<span title="Not in canonical catalog">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1 min-w-0">
|
||||
{feature.name}
|
||||
</span>
|
||||
</label>
|
||||
{selected && (
|
||||
<input
|
||||
|
||||
@@ -27,11 +27,13 @@ import {
|
||||
useDeletePlan,
|
||||
useDeletePlanVersion,
|
||||
useMarkVersionLegacy,
|
||||
useForceUpdatePlanVersion,
|
||||
formatCentsToDollars,
|
||||
type PlanWithVersions,
|
||||
type PlanVersion,
|
||||
type AddOnProduct,
|
||||
} from '../../hooks/useBillingAdmin';
|
||||
import { useCurrentUser } from '../../hooks/useAuth';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -63,10 +65,18 @@ export const PlanDetailPanel: React.FC<PlanDetailPanelProps> = ({
|
||||
);
|
||||
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 (
|
||||
@@ -110,6 +120,41 @@ export const PlanDetailPanel: React.FC<PlanDetailPanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
@@ -364,25 +409,60 @@ export const PlanDetailPanel: React.FC<PlanDetailPanelProps> = ({
|
||||
onToggle={() => toggleSection('danger')}
|
||||
variant="danger"
|
||||
>
|
||||
<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 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>
|
||||
@@ -427,6 +507,83 @@ export const PlanDetailPanel: React.FC<PlanDetailPanelProps> = ({
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
Star,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Modal, Alert } from '../../components/ui';
|
||||
import { FeaturePicker } from './FeaturePicker';
|
||||
|
||||
Reference in New Issue
Block a user