feat: implement tiered subscription plans with usage-based pricing and dynamic plan management
Add multi-tier subscription system (Starter, Professional, Enterprise) with per-tier storage and bandwidth pricing, replace static free-tier limits with nullable usage-based fields in SubscriptionPlan model, introduce subscribe/unsubscribe API endpoints with plan validation, update invoice generator to calculate costs based on user's active plan tier, and refactor pricing page to load plans dynamically from backend API instead of hardcoded HTML.
This commit is contained in:
parent
a34668941f
commit
619e320780
10
Makefile
10
Makefile
@ -99,11 +99,13 @@ init-db:
|
||||
await Tortoise.generate_schemas(); \
|
||||
count = await PricingConfig.all().count(); \
|
||||
if count == 0: \
|
||||
await PricingConfig.create(config_key='storage_per_gb_month', config_value=Decimal('0.0045'), description='Storage cost per GB per month', unit='per_gb_month'); \
|
||||
await PricingConfig.create(config_key='bandwidth_egress_per_gb', config_value=Decimal('0.009'), description='Bandwidth egress cost per GB', unit='per_gb'); \
|
||||
await PricingConfig.create(config_key='storage_per_gb_month', config_value=Decimal('0.005'), description='Storage cost per GB per month (Starter tier)', unit='per_gb_month'); \
|
||||
await PricingConfig.create(config_key='storage_per_gb_month_pro', config_value=Decimal('0.004'), description='Storage cost per GB per month (Professional tier)', unit='per_gb_month'); \
|
||||
await PricingConfig.create(config_key='storage_per_gb_month_enterprise', config_value=Decimal('0.003'), description='Storage cost per GB per month (Enterprise tier, 10TB+)', unit='per_gb_month'); \
|
||||
await PricingConfig.create(config_key='bandwidth_egress_per_gb', config_value=Decimal('0.008'), description='Bandwidth egress cost per GB (Starter tier)', unit='per_gb'); \
|
||||
await PricingConfig.create(config_key='bandwidth_egress_per_gb_pro', config_value=Decimal('0.007'), description='Bandwidth egress cost per GB (Professional tier)', unit='per_gb'); \
|
||||
await PricingConfig.create(config_key='bandwidth_egress_per_gb_enterprise', config_value=Decimal('0.005'), description='Bandwidth egress cost per GB (Enterprise tier)', unit='per_gb'); \
|
||||
await PricingConfig.create(config_key='bandwidth_ingress_per_gb', config_value=Decimal('0.0'), description='Bandwidth ingress cost per GB (free)', unit='per_gb'); \
|
||||
await PricingConfig.create(config_key='free_tier_storage_gb', config_value=Decimal('15'), description='Free tier storage in GB', unit='gb'); \
|
||||
await PricingConfig.create(config_key='free_tier_bandwidth_gb', config_value=Decimal('15'), description='Free tier bandwidth in GB per month', unit='gb'); \
|
||||
await PricingConfig.create(config_key='tax_rate_default', config_value=Decimal('0.0'), description='Default tax rate (0 = no tax)', unit='percentage'); \
|
||||
print('Default pricing configuration created'); \
|
||||
else: \
|
||||
|
||||
@ -22,14 +22,49 @@ class InvoiceGenerator:
|
||||
pricing = await PricingConfig.all()
|
||||
pricing_dict = {p.config_key: p.config_value for p in pricing}
|
||||
|
||||
storage_price_per_gb = pricing_dict.get(
|
||||
"storage_per_gb_month", Decimal("0.0045")
|
||||
)
|
||||
bandwidth_price_per_gb = pricing_dict.get(
|
||||
"bandwidth_egress_per_gb", Decimal("0.009")
|
||||
)
|
||||
free_storage_gb = pricing_dict.get("free_tier_storage_gb", Decimal("15"))
|
||||
free_bandwidth_gb = pricing_dict.get("free_tier_bandwidth_gb", Decimal("15"))
|
||||
# Get user's subscription plan
|
||||
user_subscription = await UserSubscription.get_or_none(user=user)
|
||||
plan_name = "starter" # Default to starter
|
||||
if user_subscription and user_subscription.plan:
|
||||
plan_name = user_subscription.plan.name
|
||||
|
||||
# Set pricing based on subscription tier
|
||||
if plan_name == "professional":
|
||||
storage_price_per_gb = pricing_dict.get(
|
||||
"storage_per_gb_month_professional", Decimal("0.004")
|
||||
)
|
||||
bandwidth_price_per_gb = pricing_dict.get(
|
||||
"bandwidth_egress_per_gb_professional", Decimal("0.007")
|
||||
)
|
||||
elif plan_name == "enterprise":
|
||||
storage_price_per_gb = pricing_dict.get(
|
||||
"storage_per_gb_month_enterprise", Decimal("0.003")
|
||||
)
|
||||
bandwidth_price_per_gb = pricing_dict.get(
|
||||
"bandwidth_egress_per_gb_enterprise", Decimal("0.005")
|
||||
)
|
||||
# Check if user meets minimum storage requirement for enterprise pricing
|
||||
enterprise_min_tb = pricing_dict.get("enterprise_min_storage_tb", Decimal("10"))
|
||||
storage_gb = Decimal(str(usage["storage_gb_avg"]))
|
||||
if storage_gb < (enterprise_min_tb * Decimal("1024")): # Convert TB to GB
|
||||
# User doesn't meet enterprise minimum, fall back to professional
|
||||
storage_price_per_gb = pricing_dict.get(
|
||||
"storage_per_gb_month_professional", Decimal("0.004")
|
||||
)
|
||||
bandwidth_price_per_gb = pricing_dict.get(
|
||||
"bandwidth_egress_per_gb_professional", Decimal("0.007")
|
||||
)
|
||||
else: # starter
|
||||
storage_price_per_gb = pricing_dict.get(
|
||||
"storage_per_gb_month_starter", Decimal("0.005")
|
||||
)
|
||||
bandwidth_price_per_gb = pricing_dict.get(
|
||||
"bandwidth_egress_per_gb_starter", Decimal("0.008")
|
||||
)
|
||||
|
||||
# No free tier - charge from first GB
|
||||
free_storage_gb = Decimal("0")
|
||||
free_bandwidth_gb = Decimal("0")
|
||||
tax_rate = pricing_dict.get("tax_rate_default", Decimal("0"))
|
||||
|
||||
storage_gb = Decimal(str(usage["storage_gb_avg"]))
|
||||
|
||||
@ -6,8 +6,8 @@ class SubscriptionPlan(models.Model):
|
||||
name = fields.CharField(max_length=100, unique=True)
|
||||
display_name = fields.CharField(max_length=255)
|
||||
description = fields.TextField(null=True)
|
||||
storage_gb = fields.IntField()
|
||||
bandwidth_gb = fields.IntField()
|
||||
storage_gb = fields.IntField(null=True) # null means unlimited/usage-based
|
||||
bandwidth_gb = fields.IntField(null=True) # null means unlimited/usage-based
|
||||
price_monthly = fields.DecimalField(max_digits=10, decimal_places=2)
|
||||
price_yearly = fields.DecimalField(max_digits=10, decimal_places=2, null=True)
|
||||
stripe_price_id = fields.CharField(max_length=255, null=True)
|
||||
|
||||
@ -146,27 +146,81 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
logger.info("Database connected.")
|
||||
from .billing.scheduler import start_scheduler
|
||||
from .billing.models import PricingConfig
|
||||
from .billing.models import PricingConfig, SubscriptionPlan
|
||||
from .mail import email_service
|
||||
|
||||
start_scheduler()
|
||||
logger.info("Billing scheduler started")
|
||||
await email_service.start()
|
||||
logger.info("Email service started")
|
||||
pricing_count = await PricingConfig.all().count()
|
||||
if pricing_count == 0:
|
||||
plan_count = await SubscriptionPlan.all().count()
|
||||
if plan_count == 0:
|
||||
from decimal import Decimal
|
||||
|
||||
# Create subscription plans
|
||||
await SubscriptionPlan.create(
|
||||
name="starter",
|
||||
display_name="Starter",
|
||||
description="Perfect for individuals and small projects",
|
||||
storage_gb=None,
|
||||
bandwidth_gb=None,
|
||||
price_monthly=Decimal("0.00"),
|
||||
is_active=True
|
||||
)
|
||||
await SubscriptionPlan.create(
|
||||
name="professional",
|
||||
display_name="Professional",
|
||||
description="Best for growing teams and businesses",
|
||||
storage_gb=None,
|
||||
bandwidth_gb=None,
|
||||
price_monthly=Decimal("0.00"),
|
||||
is_active=True
|
||||
)
|
||||
await SubscriptionPlan.create(
|
||||
name="enterprise",
|
||||
display_name="Enterprise",
|
||||
description="For large organizations with high volume needs",
|
||||
storage_gb=None,
|
||||
bandwidth_gb=None,
|
||||
price_monthly=Decimal("0.00"),
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Create tiered pricing configuration
|
||||
await PricingConfig.create(
|
||||
config_key="storage_per_gb_month",
|
||||
config_value=Decimal("0.0045"),
|
||||
description="Storage cost per GB per month",
|
||||
config_key="storage_per_gb_month_starter",
|
||||
config_value=Decimal("0.005"),
|
||||
description="Storage cost per GB per month (Starter tier)",
|
||||
unit="per_gb_month",
|
||||
)
|
||||
await PricingConfig.create(
|
||||
config_key="bandwidth_egress_per_gb",
|
||||
config_value=Decimal("0.009"),
|
||||
description="Bandwidth egress cost per GB",
|
||||
config_key="storage_per_gb_month_professional",
|
||||
config_value=Decimal("0.004"),
|
||||
description="Storage cost per GB per month (Professional tier)",
|
||||
unit="per_gb_month",
|
||||
)
|
||||
await PricingConfig.create(
|
||||
config_key="storage_per_gb_month_enterprise",
|
||||
config_value=Decimal("0.003"),
|
||||
description="Storage cost per GB per month (Enterprise tier, 10TB+)",
|
||||
unit="per_gb_month",
|
||||
)
|
||||
await PricingConfig.create(
|
||||
config_key="bandwidth_egress_per_gb_starter",
|
||||
config_value=Decimal("0.008"),
|
||||
description="Bandwidth egress cost per GB (Starter tier)",
|
||||
unit="per_gb",
|
||||
)
|
||||
await PricingConfig.create(
|
||||
config_key="bandwidth_egress_per_gb_professional",
|
||||
config_value=Decimal("0.007"),
|
||||
description="Bandwidth egress cost per GB (Professional tier)",
|
||||
unit="per_gb",
|
||||
)
|
||||
await PricingConfig.create(
|
||||
config_key="bandwidth_egress_per_gb_enterprise",
|
||||
config_value=Decimal("0.005"),
|
||||
description="Bandwidth egress cost per GB (Enterprise tier)",
|
||||
unit="per_gb",
|
||||
)
|
||||
await PricingConfig.create(
|
||||
@ -175,25 +229,19 @@ async def lifespan(app: FastAPI):
|
||||
description="Bandwidth ingress cost per GB (free)",
|
||||
unit="per_gb",
|
||||
)
|
||||
await PricingConfig.create(
|
||||
config_key="free_tier_storage_gb",
|
||||
config_value=Decimal("15"),
|
||||
description="Free tier storage in GB",
|
||||
unit="gb",
|
||||
)
|
||||
await PricingConfig.create(
|
||||
config_key="free_tier_bandwidth_gb",
|
||||
config_value=Decimal("15"),
|
||||
description="Free tier bandwidth in GB per month",
|
||||
unit="gb",
|
||||
)
|
||||
await PricingConfig.create(
|
||||
config_key="tax_rate_default",
|
||||
config_value=Decimal("0.0"),
|
||||
description="Default tax rate (0 = no tax)",
|
||||
unit="percentage",
|
||||
)
|
||||
logger.info("Default pricing configuration initialized")
|
||||
await PricingConfig.create(
|
||||
config_key="enterprise_min_storage_tb",
|
||||
config_value=Decimal("10"),
|
||||
description="Minimum storage for enterprise pricing (TB)",
|
||||
unit="tb",
|
||||
)
|
||||
logger.info("Subscription plans and tiered pricing configuration initialized")
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@ -234,3 +234,17 @@ async def get_new_recovery_codes(
|
||||
await current_user.save()
|
||||
|
||||
return recovery_codes
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_current_user_info(current_user: User = Depends(get_current_user)):
|
||||
"""Get current user information"""
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"is_active": current_user.is_active,
|
||||
"is_verified": current_user.is_verified,
|
||||
"is_2fa_enabled": current_user.is_2fa_enabled,
|
||||
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
|
||||
}
|
||||
|
||||
@ -408,6 +408,145 @@ async def list_plans():
|
||||
]
|
||||
|
||||
|
||||
class SubscribeRequest(BaseModel):
|
||||
plan_name: str
|
||||
|
||||
|
||||
class UnsubscribeRequest(BaseModel):
|
||||
cancel_immediately: bool = False
|
||||
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe_to_plan(
|
||||
request: SubscribeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
try:
|
||||
# Find the plan
|
||||
plan = await SubscriptionPlan.get_or_none(
|
||||
name=request.plan_name,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Plan '{request.plan_name}' not found"
|
||||
)
|
||||
|
||||
# Check if user already has a subscription
|
||||
existing_subscription = await UserSubscription.get_or_none(
|
||||
user=current_user
|
||||
)
|
||||
|
||||
if existing_subscription:
|
||||
# Update existing subscription
|
||||
existing_subscription.plan = plan
|
||||
existing_subscription.billing_type = "subscription"
|
||||
await existing_subscription.save()
|
||||
|
||||
return {
|
||||
"message": f"Successfully updated to {plan.display_name} plan",
|
||||
"plan": plan.display_name,
|
||||
"billing_type": "subscription"
|
||||
}
|
||||
else:
|
||||
# Create new subscription
|
||||
subscription = await UserSubscription.create(
|
||||
user=current_user,
|
||||
plan=plan,
|
||||
billing_type="subscription",
|
||||
status="active"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Successfully subscribed to {plan.display_name} plan",
|
||||
"plan": plan.display_name,
|
||||
"billing_type": "subscription",
|
||||
"subscription_id": subscription.id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to subscribe to plan: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/unsubscribe")
|
||||
async def unsubscribe_from_plan(
|
||||
request: UnsubscribeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
try:
|
||||
subscription = await UserSubscription.get_or_none(
|
||||
user=current_user
|
||||
)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No active subscription found"
|
||||
)
|
||||
|
||||
if request.cancel_immediately:
|
||||
# Cancel subscription immediately
|
||||
await subscription.delete()
|
||||
return {"message": "Subscription cancelled immediately"}
|
||||
else:
|
||||
# Mark for cancellation at end of billing period
|
||||
subscription.status = "cancelled"
|
||||
await subscription.save()
|
||||
return {"message": "Subscription will be cancelled at end of billing period"}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to unsubscribe: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/subscription")
|
||||
async def get_subscription(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> SubscriptionResponse:
|
||||
try:
|
||||
subscription = await UserSubscription.get_or_none(
|
||||
user=current_user
|
||||
).prefetch_related("plan")
|
||||
|
||||
if not subscription:
|
||||
# Return default starter subscription
|
||||
default_plan = await SubscriptionPlan.get_or_none(
|
||||
name="starter",
|
||||
is_active=True
|
||||
)
|
||||
|
||||
return SubscriptionResponse(
|
||||
id=0,
|
||||
billing_type="pay_as_you_go",
|
||||
plan_name=default_plan.display_name if default_plan else "Starter",
|
||||
status="active",
|
||||
current_period_start=None,
|
||||
current_period_end=None
|
||||
)
|
||||
|
||||
return SubscriptionResponse(
|
||||
id=subscription.id,
|
||||
billing_type=subscription.billing_type,
|
||||
plan_name=subscription.plan.display_name if subscription.plan else None,
|
||||
status=subscription.status,
|
||||
current_period_start=subscription.current_period_start,
|
||||
current_period_end=subscription.current_period_end
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to fetch subscription: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stripe-key")
|
||||
async def get_stripe_key():
|
||||
from ..settings import settings
|
||||
|
||||
@ -238,65 +238,22 @@ endblock %}
|
||||
<p class="page-subtitle">Pay only for what you use. No hidden fees, no surprises.</p>
|
||||
|
||||
<div class="pricing-hero">
|
||||
<h2 style="font-size: 2rem; margin: 0;">Pay-As-You-Go Storage</h2>
|
||||
<div class="pricing-hero-amount">$5/TB</div>
|
||||
<p class="pricing-hero-description">Plus 15GB free tier included</p>
|
||||
<h2 style="font-size: 2rem; margin: 0;">Simple Cloud Storage Pricing</h2>
|
||||
<div class="pricing-hero-amount">$3-$5/TB</div>
|
||||
<p class="pricing-hero-description">Pay only for what you use. No hidden features, just storage.</p>
|
||||
</div>
|
||||
|
||||
<div class="pricing-grid">
|
||||
<div class="pricing-grid" id="pricing-plans">
|
||||
<!-- Plans will be loaded dynamically from API -->
|
||||
<div class="pricing-card">
|
||||
<div class="pricing-tier">Free Tier</div>
|
||||
<div class="pricing-amount">$0</div>
|
||||
<div class="pricing-period">First 15GB included</div>
|
||||
<div class="pricing-tier">Loading...</div>
|
||||
<div class="pricing-amount">$-</div>
|
||||
<div class="pricing-period">Loading plans...</div>
|
||||
<ul class="pricing-features">
|
||||
<li>15GB storage included</li>
|
||||
<li>15GB bandwidth per month</li>
|
||||
<li>All core features</li>
|
||||
<li>WebDAV support</li>
|
||||
<li>File versioning</li>
|
||||
<li>Two-factor authentication</li>
|
||||
<li>EU data residency</li>
|
||||
<li>Loading subscription plans...</li>
|
||||
</ul>
|
||||
<div class="pricing-cta">
|
||||
<a href="/app" class="btn btn-secondary" style="width: 100%;">Get Started Free</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pricing-card featured">
|
||||
<div class="pricing-tier">Pay-As-You-Go</div>
|
||||
<div class="pricing-amount">$5/TB</div>
|
||||
<div class="pricing-period">Billed monthly</div>
|
||||
<ul class="pricing-features">
|
||||
<li>$0.0045 per GB per month storage</li>
|
||||
<li>$0.009 per GB egress bandwidth</li>
|
||||
<li>Free ingress bandwidth</li>
|
||||
<li>All premium features</li>
|
||||
<li>99.9% uptime SLA</li>
|
||||
<li>24/7 support</li>
|
||||
<li>API access</li>
|
||||
<li>Priority support</li>
|
||||
</ul>
|
||||
<div class="pricing-cta">
|
||||
<a href="/app" class="btn btn-primary" style="width: 100%;">Start Using</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pricing-card">
|
||||
<div class="pricing-tier">Enterprise</div>
|
||||
<div class="pricing-amount">Custom</div>
|
||||
<div class="pricing-period">Contact us for pricing</div>
|
||||
<ul class="pricing-features">
|
||||
<li>Volume discounts available</li>
|
||||
<li>Dedicated account manager</li>
|
||||
<li>Custom SLA options</li>
|
||||
<li>Priority support 24/7</li>
|
||||
<li>Custom integrations</li>
|
||||
<li>Training and onboarding</li>
|
||||
<li>Compliance assistance</li>
|
||||
<li>Secure infrastructure</li>
|
||||
</ul>
|
||||
<div class="pricing-cta">
|
||||
<a href="/support" class="btn btn-secondary" style="width: 100%;">Contact Sales</a>
|
||||
<button class="btn btn-secondary" style="width: 100%;" disabled>Loading...</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -327,60 +284,191 @@ endblock %}
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">How is storage calculated?</div>
|
||||
<div class="faq-answer">Storage is calculated based on your average daily usage throughout the month. You're
|
||||
charged $0.0045 per GB per month ($5 per TB). The first 15GB is always free.</div>
|
||||
charged per GB per month starting from the first GB. No free tiers or hidden limits.</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">What is bandwidth egress?</div>
|
||||
<div class="faq-answer">Bandwidth egress is data transferred out of MyWebdav (downloads). You're charged
|
||||
$0.009 per GB. The first 15GB per month is free. Uploads (ingress) are always free.</div>
|
||||
per GB for downloads. Uploads (ingress) are always free.</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">Are there any hidden fees?</div>
|
||||
<div class="faq-answer">No. We believe in transparent pricing. You only pay for storage and egress bandwidth
|
||||
as shown. There are no setup fees, minimum charges, or surprise costs.</div>
|
||||
<div class="faq-answer">No. You only pay for storage usage and download bandwidth. No setup fees,
|
||||
no minimum charges, no surprise costs. The price you see is what you pay.</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">Can I cancel anytime?</div>
|
||||
<div class="faq-answer">Yes. There are no long-term contracts or commitments. You can delete your account at
|
||||
any time and will only be charged for usage up to that point.</div>
|
||||
<div class="faq-answer">Yes. There are no long-term contracts or commitments. You can close your account at
|
||||
any time and will only be charged for actual usage up to that point.</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">What payment methods do you accept?</div>
|
||||
<div class="faq-answer">We accept all major credit cards (Visa, Mastercard, American Express) and SEPA
|
||||
direct debit through our payment processor Stripe.</div>
|
||||
<div class="faq-answer">We accept all major credit cards through our payment processor Stripe.</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">Do you offer volume discounts?</div>
|
||||
<div class="faq-answer">Yes. For storage over 10TB or bandwidth over 5TB per month, please contact our sales
|
||||
team for custom pricing.</div>
|
||||
<div class="faq-answer">Yes. Our Enterprise tier offers lower rates for high volume usage (10TB+).
|
||||
Contact us for details on volume pricing.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadSubscriptionPlans() {
|
||||
try {
|
||||
const response = await fetch('/api/billing/plans');
|
||||
if (!response.ok) throw new Error('Failed to load plans');
|
||||
|
||||
const plans = await response.json();
|
||||
const plansContainer = document.getElementById('pricing-plans');
|
||||
|
||||
plansContainer.innerHTML = plans.map(plan => {
|
||||
const isFeatured = plan.name === 'professional';
|
||||
|
||||
// Set pricing based on plan name
|
||||
let storagePrice, bandwidthPrice, ctaText, ctaAction, features;
|
||||
if (plan.name === 'starter') {
|
||||
storagePrice = '$0.005/GB';
|
||||
bandwidthPrice = '$0.008/GB';
|
||||
ctaText = 'Get Started';
|
||||
ctaAction = () => subscribeToPlan('starter');
|
||||
features = [
|
||||
'Cloud storage for individuals',
|
||||
'Storage: ' + storagePrice,
|
||||
'Bandwidth: ' + bandwidthPrice,
|
||||
'Free uploads (ingress)',
|
||||
'WebDAV access',
|
||||
'SFTP access',
|
||||
'API access',
|
||||
'File versioning',
|
||||
'99.9% uptime SLA',
|
||||
'Email support'
|
||||
];
|
||||
} else if (plan.name === 'professional') {
|
||||
storagePrice = '$0.004/GB';
|
||||
bandwidthPrice = '$0.007/GB';
|
||||
ctaText = 'Choose Plan';
|
||||
ctaAction = () => subscribeToPlan('professional');
|
||||
features = [
|
||||
'Cloud storage for professionals',
|
||||
'Storage: ' + storagePrice,
|
||||
'Bandwidth: ' + bandwidthPrice,
|
||||
'Free uploads (ingress)',
|
||||
'WebDAV access',
|
||||
'SFTP access',
|
||||
'API access',
|
||||
'File versioning',
|
||||
'99.9% uptime SLA',
|
||||
'Priority email support'
|
||||
];
|
||||
} else if (plan.name === 'enterprise') {
|
||||
storagePrice = '$0.003/GB (10TB+)';
|
||||
bandwidthPrice = '$0.005/GB';
|
||||
ctaText = 'Contact Sales';
|
||||
ctaAction = () => window.location.href = '/support';
|
||||
features = [
|
||||
'Cloud storage for enterprises',
|
||||
'Storage: ' + storagePrice,
|
||||
'Bandwidth: ' + bandwidthPrice,
|
||||
'Free uploads (ingress)',
|
||||
'WebDAV access',
|
||||
'SFTP access',
|
||||
'API access',
|
||||
'File versioning',
|
||||
'99.9% uptime SLA',
|
||||
'Priority email support',
|
||||
'Volume discounts available'
|
||||
];
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="pricing-card ${isFeatured ? 'featured' : ''}">
|
||||
<div class="pricing-tier">${plan.display_name}</div>
|
||||
<div class="pricing-amount">${plan.price_monthly > 0 ? '$' + plan.price_monthly : 'Usage-based'}</div>
|
||||
<div class="pricing-period">Billed monthly based on usage</div>
|
||||
<ul class="pricing-features">
|
||||
${features.map(feature => `<li>${feature}</li>`).join('')}
|
||||
</ul>
|
||||
<div class="pricing-cta">
|
||||
<button class="btn ${isFeatured ? 'btn-primary' : 'btn-secondary'}"
|
||||
style="width: 100%;"
|
||||
onclick="(${ctaAction})()">
|
||||
${ctaText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading plans:', error);
|
||||
document.getElementById('pricing-plans').innerHTML = `
|
||||
<div class="pricing-card">
|
||||
<div class="pricing-tier">Error</div>
|
||||
<div class="pricing-amount">-</div>
|
||||
<div class="pricing-period">Failed to load plans</div>
|
||||
<ul class="pricing-features">
|
||||
<li>Please refresh the page and try again</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeToPlan(planName) {
|
||||
try {
|
||||
// Check if user is logged in
|
||||
const response = await fetch('/api/auth/me');
|
||||
if (!response.ok) {
|
||||
// User not logged in, redirect to login
|
||||
window.location.href = '/app#login';
|
||||
return;
|
||||
}
|
||||
|
||||
const subscribeResponse = await fetch('/api/billing/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ plan_name: planName })
|
||||
});
|
||||
|
||||
if (!subscribeResponse.ok) {
|
||||
const error = await subscribeResponse.json();
|
||||
throw new Error(error.detail || 'Failed to subscribe');
|
||||
}
|
||||
|
||||
const result = await subscribeResponse.json();
|
||||
alert(result.message);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Subscription error:', error);
|
||||
alert(error.message || 'Failed to subscribe. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function calculatePrice() {
|
||||
const storage = parseFloat(document.getElementById('storage').value) || 0;
|
||||
const bandwidth = parseFloat(document.getElementById('bandwidth').value) || 0;
|
||||
|
||||
const freeStorage = 15;
|
||||
const freeBandwidth = 15;
|
||||
|
||||
const billableStorage = Math.max(0, storage - freeStorage);
|
||||
const billableBandwidth = Math.max(0, bandwidth - freeBandwidth);
|
||||
|
||||
const storageCost = billableStorage * 0.0045;
|
||||
const bandwidthCost = billableBandwidth * 0.009;
|
||||
// Professional tier pricing (featured)
|
||||
const storageCost = storage * 0.004;
|
||||
const bandwidthCost = bandwidth * 0.007;
|
||||
|
||||
const total = storageCost + bandwidthCost;
|
||||
|
||||
document.getElementById('result').textContent = '$' + total.toFixed(2);
|
||||
}
|
||||
|
||||
calculatePrice();
|
||||
// Load plans when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadSubscriptionPlans();
|
||||
calculatePrice();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -59,7 +59,7 @@ async def pricing_config():
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="storage_per_gb_month",
|
||||
config_value=Decimal("0.0045"),
|
||||
config_value=Decimal("0.005"),
|
||||
description="Storage cost per GB per month",
|
||||
unit="per_gb_month",
|
||||
)
|
||||
@ -67,27 +67,11 @@ async def pricing_config():
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="bandwidth_egress_per_gb",
|
||||
config_value=Decimal("0.009"),
|
||||
config_value=Decimal("0.008"),
|
||||
description="Bandwidth egress cost per GB",
|
||||
unit="per_gb",
|
||||
)
|
||||
)
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="free_tier_storage_gb",
|
||||
config_value=Decimal("15"),
|
||||
description="Free tier storage in GB",
|
||||
unit="gb",
|
||||
)
|
||||
)
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="free_tier_bandwidth_gb",
|
||||
config_value=Decimal("15"),
|
||||
description="Free tier bandwidth in GB per month",
|
||||
unit="gb",
|
||||
)
|
||||
)
|
||||
yield configs
|
||||
for config in configs:
|
||||
await config.delete()
|
||||
|
||||
@ -31,7 +31,7 @@ async def pricing_config():
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="storage_per_gb_month",
|
||||
config_value=Decimal("0.0045"),
|
||||
config_value=Decimal("0.005"),
|
||||
description="Storage cost per GB per month",
|
||||
unit="per_gb_month",
|
||||
)
|
||||
@ -39,27 +39,11 @@ async def pricing_config():
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="bandwidth_egress_per_gb",
|
||||
config_value=Decimal("0.009"),
|
||||
config_value=Decimal("0.008"),
|
||||
description="Bandwidth egress cost per GB",
|
||||
unit="per_gb",
|
||||
)
|
||||
)
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="free_tier_storage_gb",
|
||||
config_value=Decimal("15"),
|
||||
description="Free tier storage in GB",
|
||||
unit="gb",
|
||||
)
|
||||
)
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="free_tier_bandwidth_gb",
|
||||
config_value=Decimal("15"),
|
||||
description="Free tier bandwidth in GB per month",
|
||||
unit="gb",
|
||||
)
|
||||
)
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="tax_rate_default",
|
||||
@ -103,7 +87,7 @@ async def test_generate_monthly_invoice_with_usage(test_user, pricing_config):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_monthly_invoice_below_free_tier(test_user, pricing_config):
|
||||
async def test_generate_monthly_invoice_with_small_usage(test_user, pricing_config):
|
||||
today = date.today()
|
||||
|
||||
await UsageAggregate.create(
|
||||
@ -119,8 +103,11 @@ async def test_generate_monthly_invoice_below_free_tier(test_user, pricing_confi
|
||||
test_user, today.year, today.month
|
||||
)
|
||||
|
||||
assert invoice is None
|
||||
# Should always generate invoice now (no free tier)
|
||||
assert invoice is not None
|
||||
assert invoice.total > 0
|
||||
|
||||
await invoice.delete()
|
||||
await UsageAggregate.filter(user=test_user).delete()
|
||||
|
||||
|
||||
|
||||
@ -143,14 +143,14 @@ async def test_invoice_line_item_creation(test_user):
|
||||
async def test_pricing_config_creation(test_user):
|
||||
config = await PricingConfig.create(
|
||||
config_key="storage_per_gb_month",
|
||||
config_value=Decimal("0.0045"),
|
||||
config_value=Decimal("0.005"),
|
||||
description="Storage cost per GB per month",
|
||||
unit="per_gb_month",
|
||||
updated_by=test_user,
|
||||
)
|
||||
|
||||
assert config.config_key == "storage_per_gb_month"
|
||||
assert config.config_value == Decimal("0.0045")
|
||||
assert config.config_value == Decimal("0.005")
|
||||
await config.delete()
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user