2025-11-13 23:05:26 +01:00
|
|
|
from datetime import datetime, date, timedelta, timezone
|
2025-11-10 15:46:40 +01:00
|
|
|
from decimal import Decimal
|
|
|
|
|
from typing import Optional
|
|
|
|
|
from calendar import monthrange
|
2025-11-13 23:22:05 +01:00
|
|
|
from .models import Invoice, InvoiceLineItem, PricingConfig, UserSubscription
|
2025-11-10 15:46:40 +01:00
|
|
|
from .usage_tracker import UsageTracker
|
|
|
|
|
from .stripe_client import StripeClient
|
|
|
|
|
from ..models import User
|
|
|
|
|
|
2025-11-13 23:22:05 +01:00
|
|
|
|
2025-11-10 15:46:40 +01:00
|
|
|
class InvoiceGenerator:
|
|
|
|
|
@staticmethod
|
2025-11-13 23:22:05 +01:00
|
|
|
async def generate_monthly_invoice(
|
|
|
|
|
user: User, year: int, month: int
|
|
|
|
|
) -> Optional[Invoice]:
|
2025-11-10 15:46:40 +01:00
|
|
|
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}
|
|
|
|
|
|
2025-11-13 23:22:05 +01:00
|
|
|
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"))
|
2025-11-10 15:46:40 +01:00
|
|
|
|
2025-11-13 23:22:05 +01:00
|
|
|
storage_gb = Decimal(str(usage["storage_gb_avg"]))
|
|
|
|
|
bandwidth_gb = Decimal(str(usage["bandwidth_down_gb"]))
|
2025-11-10 15:46:40 +01:00
|
|
|
|
2025-11-13 23:22:05 +01:00
|
|
|
billable_storage = max(Decimal("0"), storage_gb - free_storage_gb)
|
|
|
|
|
billable_bandwidth = max(Decimal("0"), bandwidth_gb - free_bandwidth_gb)
|
2025-11-10 15:46:40 +01:00
|
|
|
|
|
|
|
|
import math
|
2025-11-13 23:22:05 +01:00
|
|
|
|
2025-11-10 15:46:40 +01:00
|
|
|
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),
|
2025-11-13 23:22:05 +01:00
|
|
|
"bandwidth_per_gb": float(bandwidth_price_per_gb),
|
|
|
|
|
},
|
|
|
|
|
},
|
2025-11-10 15:46:40 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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",
|
2025-11-13 23:22:05 +01:00
|
|
|
metadata={
|
|
|
|
|
"avg_gb": float(storage_gb),
|
|
|
|
|
"free_gb": float(free_storage_gb),
|
|
|
|
|
},
|
2025-11-10 15:46:40 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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",
|
2025-11-13 23:22:05 +01:00
|
|
|
metadata={
|
|
|
|
|
"total_gb": float(bandwidth_gb),
|
|
|
|
|
"free_gb": float(free_bandwidth_gb),
|
|
|
|
|
},
|
2025-11-10 15:46:40 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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,
|
2025-11-13 23:22:05 +01:00
|
|
|
"metadata": item.metadata or {},
|
2025-11-10 15:46:40 +01:00
|
|
|
}
|
|
|
|
|
for item in line_items
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
stripe_invoice = await StripeClient.create_invoice(
|
|
|
|
|
customer_id=subscription.stripe_customer_id,
|
chore: rebrand all user-facing strings from RBox to MyWebdav across docs, emails, and config
Replace every occurrence of "RBox" with "MyWebdav" in README.md, invoice templates, welcome emails, share notifications, FastAPI metadata, default database filename, domain name, S3 bucket name, TOTP issuer, and WebDAV realm header to reflect the commercial rebranding of the application.
2025-11-13 12:05:05 +01:00
|
|
|
description=f"MyWebdav Usage Invoice for {period_start.strftime('%B %Y')}",
|
2025-11-10 15:46:40 +01:00
|
|
|
line_items=stripe_line_items,
|
2025-11-13 23:22:05 +01:00
|
|
|
metadata={"mywebdav_invoice_id": str(invoice.id)},
|
2025-11-10 15:46:40 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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}")
|
|
|
|
|
|
2025-11-11 12:47:26 +01:00
|
|
|
# Send invoice email
|
|
|
|
|
from ..mail import queue_email
|
2025-11-13 23:22:05 +01:00
|
|
|
|
2025-11-11 12:47:26 +01:00
|
|
|
line_items = await invoice.line_items.all()
|
2025-11-13 23:22:05 +01:00
|
|
|
items_text = "\n".join(
|
|
|
|
|
[f"- {item.description}: ${item.amount}" for item in line_items]
|
|
|
|
|
)
|
2025-11-11 12:47:26 +01:00
|
|
|
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,
|
chore: rebrand all user-facing strings from RBox to MyWebdav across docs, emails, and config
Replace every occurrence of "RBox" with "MyWebdav" in README.md, invoice templates, welcome emails, share notifications, FastAPI metadata, default database filename, domain name, S3 bucket name, TOTP issuer, and WebDAV realm header to reflect the commercial rebranding of the application.
2025-11-13 12:05:05 +01:00
|
|
|
The MyWebdav Team
|
2025-11-11 12:47:26 +01:00
|
|
|
"""
|
|
|
|
|
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>
|
chore: rebrand all user-facing strings from RBox to MyWebdav across docs, emails, and config
Replace every occurrence of "RBox" with "MyWebdav" in README.md, invoice templates, welcome emails, share notifications, FastAPI metadata, default database filename, domain name, S3 bucket name, TOTP issuer, and WebDAV realm header to reflect the commercial rebranding of the application.
2025-11-13 12:05:05 +01:00
|
|
|
<p>Best regards,<br>The MyWebdav Team</p>
|
2025-11-11 12:47:26 +01:00
|
|
|
"""
|
|
|
|
|
queue_email(
|
|
|
|
|
to_email=invoice.user.email,
|
2025-11-13 20:50:03 +01:00
|
|
|
subject=f"Your MyWebdav Invoice {invoice.invoice_number}",
|
2025-11-11 12:47:26 +01:00
|
|
|
body=body,
|
2025-11-13 23:22:05 +01:00
|
|
|
html=html,
|
2025-11-11 12:47:26 +01:00
|
|
|
)
|
|
|
|
|
|
2025-11-10 15:46:40 +01:00
|
|
|
return invoice
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
async def mark_invoice_paid(invoice: Invoice) -> Invoice:
|
|
|
|
|
invoice.status = "paid"
|
2025-11-13 23:05:26 +01:00
|
|
|
invoice.paid_at = datetime.now(timezone.utc)
|
2025-11-10 15:46:40 +01:00
|
|
|
await invoice.save()
|
|
|
|
|
return invoice
|