chore: rename all project references from rbox to mywebdav across configs, code, and docs

This commit is contained in:
2025-11-13 19:42:43 +00:00
parent b4a96fa82f
commit 9a7a640400
53 changed files with 74 additions and 73 deletions
View File
+187
View File
@@ -0,0 +1,187 @@
from datetime import datetime, date, timedelta
from decimal import Decimal
from typing import Optional
from calendar import monthrange
from .models import Invoice, InvoiceLineItem, PricingConfig, UsageAggregate, UserSubscription
from .usage_tracker import UsageTracker
from .stripe_client import StripeClient
from ..models import User
class InvoiceGenerator:
@staticmethod
async def generate_monthly_invoice(user: User, year: int, month: int) -> Optional[Invoice]:
period_start = date(year, month, 1)
days_in_month = monthrange(year, month)[1]
period_end = date(year, month, days_in_month)
usage = await UsageTracker.get_monthly_usage(user, year, month)
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'))
tax_rate = pricing_dict.get('tax_rate_default', Decimal('0'))
storage_gb = Decimal(str(usage['storage_gb_avg']))
bandwidth_gb = Decimal(str(usage['bandwidth_down_gb']))
billable_storage = max(Decimal('0'), storage_gb - free_storage_gb)
billable_bandwidth = max(Decimal('0'), bandwidth_gb - free_bandwidth_gb)
import math
billable_storage_rounded = Decimal(math.ceil(float(billable_storage)))
billable_bandwidth_rounded = Decimal(math.ceil(float(billable_bandwidth)))
storage_cost = billable_storage_rounded * storage_price_per_gb
bandwidth_cost = billable_bandwidth_rounded * bandwidth_price_per_gb
subtotal = storage_cost + bandwidth_cost
if subtotal <= 0:
return None
tax_amount = subtotal * tax_rate
total = subtotal + tax_amount
invoice_number = f"INV-{user.id:06d}-{year}{month:02d}"
subscription = await UserSubscription.get_or_none(user=user)
invoice = await Invoice.create(
user=user,
invoice_number=invoice_number,
period_start=period_start,
period_end=period_end,
subtotal=subtotal,
tax=tax_amount,
total=total,
currency="USD",
status="draft",
due_date=period_end + timedelta(days=7),
metadata={
"usage": usage,
"pricing": {
"storage_per_gb": float(storage_price_per_gb),
"bandwidth_per_gb": float(bandwidth_price_per_gb)
}
}
)
if billable_storage_rounded > 0:
await InvoiceLineItem.create(
invoice=invoice,
description=f"Storage usage for {period_start.strftime('%B %Y')} (Average: {storage_gb:.2f} GB, Billable: {billable_storage_rounded} GB)",
quantity=billable_storage_rounded,
unit_price=storage_price_per_gb,
amount=storage_cost,
item_type="storage",
metadata={"avg_gb": float(storage_gb), "free_gb": float(free_storage_gb)}
)
if billable_bandwidth_rounded > 0:
await InvoiceLineItem.create(
invoice=invoice,
description=f"Bandwidth usage for {period_start.strftime('%B %Y')} (Total: {bandwidth_gb:.2f} GB, Billable: {billable_bandwidth_rounded} GB)",
quantity=billable_bandwidth_rounded,
unit_price=bandwidth_price_per_gb,
amount=bandwidth_cost,
item_type="bandwidth",
metadata={"total_gb": float(bandwidth_gb), "free_gb": float(free_bandwidth_gb)}
)
if subscription and subscription.stripe_customer_id:
try:
line_items = await invoice.line_items.all()
stripe_line_items = [
{
"amount": item.amount,
"currency": "usd",
"description": item.description,
"metadata": item.metadata or {}
}
for item in line_items
]
stripe_invoice = await StripeClient.create_invoice(
customer_id=subscription.stripe_customer_id,
description=f"MyWebdav Usage Invoice for {period_start.strftime('%B %Y')}",
line_items=stripe_line_items,
metadata={"mywebdav_invoice_id": str(invoice.id)}
)
invoice.stripe_invoice_id = stripe_invoice.id
await invoice.save()
except Exception as e:
print(f"Failed to create Stripe invoice: {e}")
return invoice
@staticmethod
async def finalize_invoice(invoice: Invoice) -> Invoice:
if invoice.status != "draft":
raise ValueError("Only draft invoices can be finalized")
invoice.status = "open"
await invoice.save()
if invoice.stripe_invoice_id:
try:
await StripeClient.finalize_invoice(invoice.stripe_invoice_id)
except Exception as e:
print(f"Failed to finalize Stripe invoice: {e}")
# Send invoice email
from ..mail import queue_email
line_items = await invoice.line_items.all()
items_text = "\n".join([f"- {item.description}: ${item.amount}" for item in line_items])
body = f"""Dear {invoice.user.username},
Your invoice {invoice.invoice_number} for the period {invoice.period_start} to {invoice.period_end} is now available.
Invoice Details:
{items_text}
Subtotal: ${invoice.subtotal}
Tax: ${invoice.tax}
Total: ${invoice.total}
Due Date: {invoice.due_date}
You can view and pay your invoice at: {invoice.user.email} # Placeholder, should be a link to invoice page
Best regards,
The MyWebdav Team
"""
html = f"""
<h2>Invoice {invoice.invoice_number}</h2>
<p>Dear {invoice.user.username},</p>
<p>Your invoice for the period {invoice.period_start} to {invoice.period_end} is now available.</p>
<table border="1">
<tr><th>Description</th><th>Amount</th></tr>
{"".join([f"<tr><td>{item.description}</td><td>${item.amount}</td></tr>" for item in line_items])}
<tr><td><strong>Subtotal</strong></td><td><strong>${invoice.subtotal}</strong></td></tr>
<tr><td><strong>Tax</strong></td><td><strong>${invoice.tax}</strong></td></tr>
<tr><td><strong>Total</strong></td><td><strong>${invoice.total}</strong></td></tr>
</table>
<p>Due Date: {invoice.due_date}</p>
<p>You can view and pay your invoice at: <a href="#">Invoice Link</a></p>
<p>Best regards,<br>The MyWebdav Team</p>
"""
queue_email(
to_email=invoice.user.email,
subject=f"Your RBox Invoice {invoice.invoice_number}",
body=body,
html=html
)
return invoice
@staticmethod
async def mark_invoice_paid(invoice: Invoice) -> Invoice:
invoice.status = "paid"
invoice.paid_at = datetime.utcnow()
await invoice.save()
return invoice
+141
View File
@@ -0,0 +1,141 @@
from tortoise import fields, models
from decimal import Decimal
class SubscriptionPlan(models.Model):
id = fields.IntField(pk=True)
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()
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)
is_active = fields.BooleanField(default=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "subscription_plans"
class UserSubscription(models.Model):
id = fields.IntField(pk=True)
user = fields.ForeignKeyField("models.User", related_name="subscription")
plan = fields.ForeignKeyField("billing.SubscriptionPlan", related_name="subscriptions", null=True)
billing_type = fields.CharField(max_length=20, default="pay_as_you_go")
stripe_customer_id = fields.CharField(max_length=255, unique=True, null=True)
stripe_subscription_id = fields.CharField(max_length=255, unique=True, null=True)
status = fields.CharField(max_length=50, default="active")
current_period_start = fields.DatetimeField(null=True)
current_period_end = fields.DatetimeField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
canceled_at = fields.DatetimeField(null=True)
class Meta:
table = "user_subscriptions"
class UsageRecord(models.Model):
id = fields.BigIntField(pk=True)
user = fields.ForeignKeyField("models.User", related_name="usage_records")
record_type = fields.CharField(max_length=50, index=True)
amount_bytes = fields.BigIntField()
resource_type = fields.CharField(max_length=50, null=True)
resource_id = fields.IntField(null=True)
timestamp = fields.DatetimeField(auto_now_add=True, index=True)
idempotency_key = fields.CharField(max_length=255, unique=True, null=True)
metadata = fields.JSONField(null=True)
class Meta:
table = "usage_records"
indexes = [("user_id", "record_type", "timestamp")]
class UsageAggregate(models.Model):
id = fields.IntField(pk=True)
user = fields.ForeignKeyField("models.User", related_name="usage_aggregates")
date = fields.DateField()
storage_bytes_avg = fields.BigIntField(default=0)
storage_bytes_peak = fields.BigIntField(default=0)
bandwidth_up_bytes = fields.BigIntField(default=0)
bandwidth_down_bytes = fields.BigIntField(default=0)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "usage_aggregates"
unique_together = (("user", "date"),)
class Invoice(models.Model):
id = fields.IntField(pk=True)
user = fields.ForeignKeyField("models.User", related_name="invoices")
invoice_number = fields.CharField(max_length=50, unique=True)
stripe_invoice_id = fields.CharField(max_length=255, unique=True, null=True)
period_start = fields.DateField(index=True)
period_end = fields.DateField()
subtotal = fields.DecimalField(max_digits=10, decimal_places=4)
tax = fields.DecimalField(max_digits=10, decimal_places=4, default=0)
total = fields.DecimalField(max_digits=10, decimal_places=4)
currency = fields.CharField(max_length=3, default="USD")
status = fields.CharField(max_length=50, default="draft", index=True)
due_date = fields.DateField(null=True)
paid_at = fields.DatetimeField(null=True)
created_at = fields.DatetimeField(auto_now_add=True, index=True)
updated_at = fields.DatetimeField(auto_now=True)
metadata = fields.JSONField(null=True)
class Meta:
table = "invoices"
indexes = [("user_id", "status", "created_at")]
class InvoiceLineItem(models.Model):
id = fields.IntField(pk=True)
invoice = fields.ForeignKeyField("billing.Invoice", related_name="line_items")
description = fields.TextField()
quantity = fields.DecimalField(max_digits=15, decimal_places=6)
unit_price = fields.DecimalField(max_digits=10, decimal_places=6)
amount = fields.DecimalField(max_digits=10, decimal_places=4)
item_type = fields.CharField(max_length=50, null=True)
metadata = fields.JSONField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "invoice_line_items"
class PricingConfig(models.Model):
id = fields.IntField(pk=True)
config_key = fields.CharField(max_length=100, unique=True)
config_value = fields.DecimalField(max_digits=10, decimal_places=6)
description = fields.TextField(null=True)
unit = fields.CharField(max_length=50, null=True)
updated_by = fields.ForeignKeyField("models.User", related_name="pricing_updates", null=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "pricing_config"
class PaymentMethod(models.Model):
id = fields.IntField(pk=True)
user = fields.ForeignKeyField("models.User", related_name="payment_methods")
stripe_payment_method_id = fields.CharField(max_length=255)
type = fields.CharField(max_length=50)
is_default = fields.BooleanField(default=False)
last4 = fields.CharField(max_length=4, null=True)
brand = fields.CharField(max_length=50, null=True)
exp_month = fields.IntField(null=True)
exp_year = fields.IntField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "payment_methods"
class BillingEvent(models.Model):
id = fields.BigIntField(pk=True)
user = fields.ForeignKeyField("models.User", related_name="billing_events", null=True)
event_type = fields.CharField(max_length=100)
stripe_event_id = fields.CharField(max_length=255, unique=True, null=True)
data = fields.JSONField(null=True)
processed = fields.BooleanField(default=False)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "billing_events"
+57
View File
@@ -0,0 +1,57 @@
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from datetime import datetime, date, timedelta
import asyncio
from .usage_tracker import UsageTracker
from .invoice_generator import InvoiceGenerator
from ..models import User
scheduler = AsyncIOScheduler()
async def aggregate_daily_usage_for_all_users():
users = await User.filter(is_active=True).all()
yesterday = date.today() - timedelta(days=1)
for user in users:
try:
await UsageTracker.aggregate_daily_usage(user, yesterday)
except Exception as e:
print(f"Failed to aggregate usage for user {user.id}: {e}")
async def generate_monthly_invoices():
now = datetime.now()
last_month = now.month - 1 if now.month > 1 else 12
year = now.year if now.month > 1 else now.year - 1
users = await User.filter(is_active=True).all()
for user in users:
try:
invoice = await InvoiceGenerator.generate_monthly_invoice(user, year, last_month)
if invoice:
await InvoiceGenerator.finalize_invoice(invoice)
except Exception as e:
print(f"Failed to generate invoice for user {user.id}: {e}")
def start_scheduler():
scheduler.add_job(
aggregate_daily_usage_for_all_users,
CronTrigger(hour=1, minute=0),
id="aggregate_daily_usage",
name="Aggregate daily usage for all users",
replace_existing=True
)
scheduler.add_job(
generate_monthly_invoices,
CronTrigger(day=1, hour=2, minute=0),
id="generate_monthly_invoices",
name="Generate monthly invoices",
replace_existing=True
)
scheduler.start()
def stop_scheduler():
scheduler.shutdown()
+119
View File
@@ -0,0 +1,119 @@
import stripe
from decimal import Decimal
from typing import Optional, Dict, Any
from ..settings import settings
class StripeClient:
@staticmethod
def _ensure_api_key():
if not stripe.api_key:
if settings.STRIPE_SECRET_KEY:
stripe.api_key = settings.STRIPE_SECRET_KEY
else:
raise ValueError("Stripe API key not configured")
@staticmethod
async def create_customer(email: str, name: str, metadata: Dict = None) -> str:
StripeClient._ensure_api_key()
customer = stripe.Customer.create(
email=email,
name=name,
metadata=metadata or {}
)
return customer.id
@staticmethod
async def create_payment_intent(
amount: int,
currency: str = "usd",
customer_id: str = None,
metadata: Dict = None
) -> stripe.PaymentIntent:
StripeClient._ensure_api_key()
return stripe.PaymentIntent.create(
amount=amount,
currency=currency,
customer=customer_id,
metadata=metadata or {},
automatic_payment_methods={"enabled": True}
)
@staticmethod
async def create_invoice(
customer_id: str,
description: str,
line_items: list,
metadata: Dict = None
) -> stripe.Invoice:
StripeClient._ensure_api_key()
for item in line_items:
stripe.InvoiceItem.create(
customer=customer_id,
amount=int(item['amount'] * 100),
currency=item.get('currency', 'usd'),
description=item['description'],
metadata=item.get('metadata', {})
)
invoice = stripe.Invoice.create(
customer=customer_id,
description=description,
auto_advance=True,
collection_method='charge_automatically',
metadata=metadata or {}
)
return invoice
@staticmethod
async def finalize_invoice(invoice_id: str) -> stripe.Invoice:
StripeClient._ensure_api_key()
return stripe.Invoice.finalize_invoice(invoice_id)
@staticmethod
async def pay_invoice(invoice_id: str) -> stripe.Invoice:
StripeClient._ensure_api_key()
return stripe.Invoice.pay(invoice_id)
@staticmethod
async def attach_payment_method(
payment_method_id: str,
customer_id: str
) -> stripe.PaymentMethod:
StripeClient._ensure_api_key()
payment_method = stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id
)
stripe.Customer.modify(
customer_id,
invoice_settings={'default_payment_method': payment_method_id}
)
return payment_method
@staticmethod
async def list_payment_methods(customer_id: str, type: str = "card"):
StripeClient._ensure_api_key()
return stripe.PaymentMethod.list(
customer=customer_id,
type=type
)
@staticmethod
async def create_subscription(
customer_id: str,
price_id: str,
metadata: Dict = None
) -> stripe.Subscription:
StripeClient._ensure_api_key()
return stripe.Subscription.create(
customer=customer_id,
items=[{'price': price_id}],
metadata=metadata or {}
)
@staticmethod
async def cancel_subscription(subscription_id: str) -> stripe.Subscription:
StripeClient._ensure_api_key()
return stripe.Subscription.delete(subscription_id)
+149
View File
@@ -0,0 +1,149 @@
import uuid
from datetime import datetime, date
from decimal import Decimal
from typing import Optional
from tortoise.transactions import in_transaction
from .models import UsageRecord, UsageAggregate
from ..models import User
class UsageTracker:
@staticmethod
async def track_storage(
user: User,
amount_bytes: int,
resource_type: str = None,
resource_id: int = None,
metadata: dict = None
):
idempotency_key = f"storage_{user.id}_{datetime.utcnow().timestamp()}_{uuid.uuid4().hex[:8]}"
await UsageRecord.create(
user=user,
record_type="storage",
amount_bytes=amount_bytes,
resource_type=resource_type,
resource_id=resource_id,
idempotency_key=idempotency_key,
metadata=metadata
)
@staticmethod
async def track_bandwidth(
user: User,
amount_bytes: int,
direction: str = "down",
resource_type: str = None,
resource_id: int = None,
metadata: dict = None
):
record_type = f"bandwidth_{direction}"
idempotency_key = f"{record_type}_{user.id}_{datetime.utcnow().timestamp()}_{uuid.uuid4().hex[:8]}"
await UsageRecord.create(
user=user,
record_type=record_type,
amount_bytes=amount_bytes,
resource_type=resource_type,
resource_id=resource_id,
idempotency_key=idempotency_key,
metadata=metadata
)
@staticmethod
async def aggregate_daily_usage(user: User, target_date: date = None):
if target_date is None:
target_date = date.today()
start_of_day = datetime.combine(target_date, datetime.min.time())
end_of_day = datetime.combine(target_date, datetime.max.time())
storage_records = await UsageRecord.filter(
user=user,
record_type="storage",
timestamp__gte=start_of_day,
timestamp__lte=end_of_day
).all()
storage_avg = sum(r.amount_bytes for r in storage_records) // max(len(storage_records), 1)
storage_peak = max((r.amount_bytes for r in storage_records), default=0)
bandwidth_up = await UsageRecord.filter(
user=user,
record_type="bandwidth_up",
timestamp__gte=start_of_day,
timestamp__lte=end_of_day
).all()
bandwidth_down = await UsageRecord.filter(
user=user,
record_type="bandwidth_down",
timestamp__gte=start_of_day,
timestamp__lte=end_of_day
).all()
total_up = sum(r.amount_bytes for r in bandwidth_up)
total_down = sum(r.amount_bytes for r in bandwidth_down)
async with in_transaction():
aggregate, created = await UsageAggregate.get_or_create(
user=user,
date=target_date,
defaults={
"storage_bytes_avg": storage_avg,
"storage_bytes_peak": storage_peak,
"bandwidth_up_bytes": total_up,
"bandwidth_down_bytes": total_down
}
)
if not created:
aggregate.storage_bytes_avg = storage_avg
aggregate.storage_bytes_peak = storage_peak
aggregate.bandwidth_up_bytes = total_up
aggregate.bandwidth_down_bytes = total_down
await aggregate.save()
return aggregate
@staticmethod
async def get_current_storage(user: User) -> int:
from ..models import File
files = await File.filter(owner=user, is_deleted=False).all()
return sum(f.size for f in files)
@staticmethod
async def get_monthly_usage(user: User, year: int, month: int) -> dict:
from datetime import date
from calendar import monthrange
start_date = date(year, month, 1)
_, last_day = monthrange(year, month)
end_date = date(year, month, last_day)
aggregates = await UsageAggregate.filter(
user=user,
date__gte=start_date,
date__lte=end_date
).all()
if not aggregates:
return {
"storage_gb_avg": 0,
"storage_gb_peak": 0,
"bandwidth_up_gb": 0,
"bandwidth_down_gb": 0,
"total_bandwidth_gb": 0
}
storage_avg = sum(a.storage_bytes_avg for a in aggregates) / len(aggregates)
storage_peak = max(a.storage_bytes_peak for a in aggregates)
bandwidth_up = sum(a.bandwidth_up_bytes for a in aggregates)
bandwidth_down = sum(a.bandwidth_down_bytes for a in aggregates)
return {
"storage_gb_avg": round(storage_avg / (1024**3), 4),
"storage_gb_peak": round(storage_peak / (1024**3), 4),
"bandwidth_up_gb": round(bandwidth_up / (1024**3), 4),
"bandwidth_down_gb": round(bandwidth_down / (1024**3), 4),
"total_bandwidth_gb": round((bandwidth_up + bandwidth_down) / (1024**3), 4)
}