Implements a complete billing system with: Backend (Django): - New billing app with models: Feature, Plan, PlanVersion, PlanFeature, Subscription, AddOnProduct, AddOnFeature, SubscriptionAddOn, EntitlementOverride, Invoice, InvoiceLine - EntitlementService with resolution order: overrides > add-ons > plan - Invoice generation service with immutable snapshots - DRF API endpoints for entitlements, subscription, plans, invoices - Data migrations to seed initial plans and convert existing tenants - Bridge to legacy Tenant.has_feature() with fallback support - 75 tests covering models, services, and API endpoints Frontend (React): - Billing API client (getEntitlements, getPlans, getInvoices, etc.) - useEntitlements hook with hasFeature() and getLimit() helpers - FeatureGate and LimitGate components for conditional rendering - 29 tests for API, hook, and components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
248 lines
5.7 KiB
TypeScript
248 lines
5.7 KiB
TypeScript
/**
|
|
* FeatureGate Component
|
|
*
|
|
* Conditionally renders children based on entitlement checks.
|
|
* Used to show/hide features based on the business's subscription plan.
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { useEntitlements } from '../hooks/useEntitlements';
|
|
|
|
// ============================================================================
|
|
// FeatureGate - For boolean feature checks
|
|
// ============================================================================
|
|
|
|
interface FeatureGateProps {
|
|
/**
|
|
* Single feature code to check
|
|
*/
|
|
feature?: string;
|
|
|
|
/**
|
|
* Multiple feature codes to check
|
|
*/
|
|
features?: string[];
|
|
|
|
/**
|
|
* If true, ALL features must be enabled. If false, ANY feature being enabled is sufficient.
|
|
* Default: true (all required)
|
|
*/
|
|
requireAll?: boolean;
|
|
|
|
/**
|
|
* Content to render when feature(s) are enabled
|
|
*/
|
|
children: React.ReactNode;
|
|
|
|
/**
|
|
* Content to render when feature(s) are NOT enabled
|
|
*/
|
|
fallback?: React.ReactNode;
|
|
|
|
/**
|
|
* Content to render while entitlements are loading
|
|
*/
|
|
loadingFallback?: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Conditionally render content based on feature entitlements.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* // Single feature check
|
|
* <FeatureGate feature="can_use_sms_reminders">
|
|
* <SMSSettings />
|
|
* </FeatureGate>
|
|
*
|
|
* // With fallback
|
|
* <FeatureGate
|
|
* feature="can_use_sms_reminders"
|
|
* fallback={<UpgradePrompt feature="SMS Reminders" />}
|
|
* >
|
|
* <SMSSettings />
|
|
* </FeatureGate>
|
|
*
|
|
* // Multiple features (all required)
|
|
* <FeatureGate features={['can_use_plugins', 'can_use_tasks']}>
|
|
* <TaskScheduler />
|
|
* </FeatureGate>
|
|
*
|
|
* // Multiple features (any one)
|
|
* <FeatureGate features={['can_use_sms_reminders', 'can_use_webhooks']} requireAll={false}>
|
|
* <NotificationSettings />
|
|
* </FeatureGate>
|
|
* ```
|
|
*/
|
|
export const FeatureGate: React.FC<FeatureGateProps> = ({
|
|
feature,
|
|
features,
|
|
requireAll = true,
|
|
children,
|
|
fallback = null,
|
|
loadingFallback = null,
|
|
}) => {
|
|
const { hasFeature, isLoading } = useEntitlements();
|
|
|
|
// Show loading state if provided
|
|
if (isLoading) {
|
|
return <>{loadingFallback}</>;
|
|
}
|
|
|
|
// Determine which features to check
|
|
const featuresToCheck = features ?? (feature ? [feature] : []);
|
|
|
|
if (featuresToCheck.length === 0) {
|
|
// No features specified, render children
|
|
return <>{children}</>;
|
|
}
|
|
|
|
// Check features
|
|
const hasAccess = requireAll
|
|
? featuresToCheck.every((f) => hasFeature(f))
|
|
: featuresToCheck.some((f) => hasFeature(f));
|
|
|
|
if (hasAccess) {
|
|
return <>{children}</>;
|
|
}
|
|
|
|
return <>{fallback}</>;
|
|
};
|
|
|
|
// ============================================================================
|
|
// LimitGate - For integer limit checks
|
|
// ============================================================================
|
|
|
|
interface LimitGateProps {
|
|
/**
|
|
* The limit feature code to check (e.g., 'max_users')
|
|
*/
|
|
limit: string;
|
|
|
|
/**
|
|
* Current usage count
|
|
*/
|
|
currentUsage: number;
|
|
|
|
/**
|
|
* Content to render when under the limit
|
|
*/
|
|
children: React.ReactNode;
|
|
|
|
/**
|
|
* Content to render when at or over the limit
|
|
*/
|
|
fallback?: React.ReactNode;
|
|
|
|
/**
|
|
* Content to render while entitlements are loading
|
|
*/
|
|
loadingFallback?: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Conditionally render content based on usage limits.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <LimitGate
|
|
* limit="max_users"
|
|
* currentUsage={users.length}
|
|
* fallback={<UpgradePrompt message="You've reached your user limit" />}
|
|
* >
|
|
* <AddUserButton />
|
|
* </LimitGate>
|
|
* ```
|
|
*/
|
|
export const LimitGate: React.FC<LimitGateProps> = ({
|
|
limit,
|
|
currentUsage,
|
|
children,
|
|
fallback = null,
|
|
loadingFallback = null,
|
|
}) => {
|
|
const { getLimit, isLoading } = useEntitlements();
|
|
|
|
// Show loading state if provided
|
|
if (isLoading) {
|
|
return <>{loadingFallback}</>;
|
|
}
|
|
|
|
const maxLimit = getLimit(limit);
|
|
|
|
// If limit is null, treat as unlimited
|
|
if (maxLimit === null) {
|
|
return <>{children}</>;
|
|
}
|
|
|
|
// Check if under limit
|
|
if (currentUsage < maxLimit) {
|
|
return <>{children}</>;
|
|
}
|
|
|
|
return <>{fallback}</>;
|
|
};
|
|
|
|
// ============================================================================
|
|
// Helper Components
|
|
// ============================================================================
|
|
|
|
interface UpgradePromptProps {
|
|
/**
|
|
* Feature name to display
|
|
*/
|
|
feature?: string;
|
|
|
|
/**
|
|
* Custom message
|
|
*/
|
|
message?: string;
|
|
|
|
/**
|
|
* Upgrade URL (defaults to /settings/billing)
|
|
*/
|
|
upgradeUrl?: string;
|
|
}
|
|
|
|
/**
|
|
* Default upgrade prompt component.
|
|
* Can be used as a fallback in FeatureGate/LimitGate.
|
|
*/
|
|
export const UpgradePrompt: React.FC<UpgradePromptProps> = ({
|
|
feature,
|
|
message,
|
|
upgradeUrl = '/settings/billing',
|
|
}) => {
|
|
const displayMessage =
|
|
message || (feature ? `Upgrade your plan to access ${feature}` : 'Upgrade your plan to access this feature');
|
|
|
|
return (
|
|
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
|
<div className="flex items-center gap-2">
|
|
<svg
|
|
className="w-5 h-5 text-yellow-600 dark:text-yellow-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
/>
|
|
</svg>
|
|
<span className="text-yellow-800 dark:text-yellow-200 font-medium">{displayMessage}</span>
|
|
</div>
|
|
<a
|
|
href={upgradeUrl}
|
|
className="mt-2 inline-block text-sm text-yellow-700 dark:text-yellow-300 hover:underline"
|
|
>
|
|
View upgrade options →
|
|
</a>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FeatureGate;
|