The app confirms a StoreKit signed transaction or Play purchase token. The backend checks it with Apple or Google before writing the ledger, and the RevenueCat webhook no longer grants access.
395 lines
12 KiB
Python
395 lines
12 KiB
Python
from django.conf import settings
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
|
|
from chat_backend.models import Company, TimeInfoBase
|
|
|
|
|
|
class SubscriptionPlan(TimeInfoBase):
|
|
"""Catalog row for a billable (or complimentary) subscription tier."""
|
|
|
|
class Slug(models.TextChoices):
|
|
FOUNDERS = "founders", "Founders"
|
|
STANDARD = "standard", "Standard"
|
|
PRO = "pro", "Pro / Creator"
|
|
BUSINESS = "business", "Business Team"
|
|
BACKER = "backer", "Backer"
|
|
|
|
slug = models.SlugField(max_length=64, unique=True, db_index=True)
|
|
name = models.CharField(max_length=128)
|
|
description = models.TextField(blank=True, default="")
|
|
price_cents = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text="List price in cents (0 for complimentary tiers).",
|
|
)
|
|
currency = models.CharField(max_length=8, default="usd")
|
|
interval = models.CharField(max_length=16, default="month")
|
|
stripe_price_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
default="",
|
|
help_text="Optional Stripe Price id; empty uses price_data at Checkout.",
|
|
)
|
|
revenuecat_product_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
default="",
|
|
db_index=True,
|
|
help_text="Store/RevenueCat product identifier (Play + App Store).",
|
|
)
|
|
is_public = models.BooleanField(
|
|
default=False,
|
|
help_text="Shown in public pricing / plan list APIs.",
|
|
)
|
|
is_selectable = models.BooleanField(
|
|
default=False,
|
|
help_text="Selectable at Checkout. Backer is never selectable.",
|
|
)
|
|
allows_text_generation = models.BooleanField(default=True)
|
|
allows_image_generation = models.BooleanField(default=False)
|
|
allows_rag = models.BooleanField(
|
|
default=False,
|
|
help_text="Drive/RAG document sync (Google Drive, OneDrive, SharePoint).",
|
|
)
|
|
allows_all_future_features = models.BooleanField(
|
|
default=False,
|
|
help_text="Founders/Backer: unlock new capabilities as they ship.",
|
|
)
|
|
prompt_quota_per_window = models.PositiveIntegerField(
|
|
help_text="Max prompts allowed in each rolling window.",
|
|
)
|
|
prompt_window_hours = models.PositiveIntegerField(
|
|
default=6,
|
|
help_text="Length of the rolling prompt quota window in hours.",
|
|
)
|
|
monthly_token_quota = models.PositiveIntegerField(
|
|
null=True,
|
|
blank=True,
|
|
help_text=(
|
|
"Optional billing-period token cap (tokens_in + tokens_out). "
|
|
"Null = no token-period limit (prompt window still applies)."
|
|
),
|
|
)
|
|
sort_order = models.PositiveIntegerField(default=0)
|
|
|
|
class Meta:
|
|
ordering = ["sort_order", "price_cents", "name"]
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.name} ({self.slug})"
|
|
|
|
def allows_feature(self, feature: str) -> bool:
|
|
if self.allows_all_future_features:
|
|
return True
|
|
if feature in ("text", "text_generation"):
|
|
return self.allows_text_generation
|
|
if feature in ("image", "image_generation"):
|
|
return self.allows_image_generation
|
|
if feature in ("rag", "document_rag"):
|
|
return self.allows_rag
|
|
# Agentic multi-step runs (#63): same tier as RAG until a dedicated flag.
|
|
if feature in ("agentic", "agentic_tasks"):
|
|
return self.allows_rag
|
|
return False
|
|
|
|
|
|
class BackerEmail(TimeInfoBase):
|
|
"""Pre-registered emails that receive complimentary Backer (Founders-level) access."""
|
|
|
|
email = models.EmailField(unique=True, db_index=True)
|
|
note = models.CharField(max_length=512, blank=True, default="")
|
|
redeemed_at = models.DateTimeField(null=True, blank=True)
|
|
redeemed_user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="backer_email_entries",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["email"]
|
|
verbose_name = "Backer email"
|
|
verbose_name_plural = "Backer emails"
|
|
|
|
def __str__(self) -> str:
|
|
return self.email
|
|
|
|
def save(self, *args, **kwargs):
|
|
if self.email:
|
|
self.email = self.email.strip().lower()
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class UserSubscription(TimeInfoBase):
|
|
"""Per-user plan assignment (Stripe, RevenueCat, Backer, or admin)."""
|
|
|
|
class Status(models.TextChoices):
|
|
NONE = "none", "None"
|
|
ACTIVE = "active", "Active"
|
|
PAST_DUE = "past_due", "Past due"
|
|
CANCELED = "canceled", "Canceled"
|
|
|
|
class Source(models.TextChoices):
|
|
NONE = "none", "None"
|
|
STRIPE = "stripe", "Stripe"
|
|
REVENUECAT = "revenuecat", "RevenueCat"
|
|
APP_STORE = "app_store", "App Store"
|
|
PLAY_STORE = "play_store", "Play Store"
|
|
BACKER = "backer", "Backer"
|
|
ADMIN = "admin", "Admin"
|
|
|
|
user = models.OneToOneField(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name="subscription",
|
|
)
|
|
plan = models.ForeignKey(
|
|
SubscriptionPlan,
|
|
on_delete=models.PROTECT,
|
|
related_name="subscriptions",
|
|
null=True,
|
|
blank=True,
|
|
)
|
|
status = models.CharField(
|
|
max_length=32,
|
|
choices=Status.choices,
|
|
default=Status.NONE,
|
|
db_index=True,
|
|
)
|
|
source = models.CharField(
|
|
max_length=32,
|
|
choices=Source.choices,
|
|
default=Source.NONE,
|
|
)
|
|
stripe_subscription_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
default="",
|
|
db_index=True,
|
|
)
|
|
revenuecat_original_transaction_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
default="",
|
|
db_index=True,
|
|
help_text="Store original transaction id from RevenueCat events.",
|
|
)
|
|
cancel_at_period_end = models.BooleanField(
|
|
default=False,
|
|
help_text="Subscription will cancel at current_period_end.",
|
|
)
|
|
current_period_end = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Billing period end (access remains until then when canceling).",
|
|
)
|
|
monthly_token_quota_override = models.PositiveIntegerField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Optional per-user override of plan monthly_token_quota.",
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "User subscription"
|
|
verbose_name_plural = "User subscriptions"
|
|
|
|
def __str__(self) -> str:
|
|
plan = self.plan.slug if self.plan_id else "none"
|
|
return f"UserSubscription user={self.user_id} plan={plan} ({self.status})"
|
|
|
|
@property
|
|
def is_active(self) -> bool:
|
|
return self.status == self.Status.ACTIVE and self.plan_id is not None
|
|
|
|
def effective_monthly_token_quota(self):
|
|
if self.monthly_token_quota_override is not None:
|
|
return self.monthly_token_quota_override
|
|
if self.plan_id:
|
|
return self.plan.monthly_token_quota
|
|
return None
|
|
|
|
|
|
class Invoice(TimeInfoBase):
|
|
"""Local ledger row for a billed period (Stripe or RevenueCat/store)."""
|
|
|
|
class Provider(models.TextChoices):
|
|
STRIPE = "stripe", "Stripe"
|
|
REVENUECAT = "revenuecat", "RevenueCat"
|
|
APP_STORE = "app_store", "App Store"
|
|
PLAY_STORE = "play_store", "Play Store"
|
|
|
|
class Status(models.TextChoices):
|
|
DRAFT = "draft", "Draft"
|
|
OPEN = "open", "Open"
|
|
PAID = "paid", "Paid"
|
|
VOID = "void", "Void"
|
|
UNCOLLECTIBLE = "uncollectible", "Uncollectible"
|
|
PAYMENT_FAILED = "payment_failed", "Payment failed"
|
|
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name="invoices",
|
|
)
|
|
company = models.ForeignKey(
|
|
Company,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="invoices",
|
|
)
|
|
provider = models.CharField(
|
|
max_length=32,
|
|
choices=Provider.choices,
|
|
default=Provider.STRIPE,
|
|
)
|
|
status = models.CharField(
|
|
max_length=32,
|
|
choices=Status.choices,
|
|
default=Status.OPEN,
|
|
db_index=True,
|
|
)
|
|
currency = models.CharField(max_length=8, default="usd")
|
|
amount_due = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text="Amount due in the smallest currency unit (e.g. cents).",
|
|
)
|
|
amount_paid = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text="Amount paid in the smallest currency unit (e.g. cents).",
|
|
)
|
|
period_start = models.DateTimeField(null=True, blank=True)
|
|
period_end = models.DateTimeField(null=True, blank=True)
|
|
stripe_invoice_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
null=True,
|
|
unique=True,
|
|
db_index=True,
|
|
)
|
|
stripe_checkout_session_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
null=True,
|
|
unique=True,
|
|
db_index=True,
|
|
)
|
|
stripe_subscription_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
null=True,
|
|
db_index=True,
|
|
)
|
|
stripe_customer_id = models.CharField(max_length=255, blank=True, default="")
|
|
revenuecat_event_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
null=True,
|
|
unique=True,
|
|
db_index=True,
|
|
help_text="RevenueCat webhook event id (idempotency key).",
|
|
)
|
|
revenuecat_store = models.CharField(
|
|
max_length=32,
|
|
blank=True,
|
|
default="",
|
|
help_text="APP_STORE / PLAY_STORE / etc.",
|
|
)
|
|
hosted_invoice_url = models.URLField(blank=True, default="")
|
|
description = models.CharField(max_length=512, blank=True, default="")
|
|
|
|
class Meta:
|
|
ordering = ["-created"]
|
|
|
|
def __str__(self) -> str:
|
|
return f"Invoice {self.pk} ({self.status}) user={self.user_id}"
|
|
|
|
|
|
class Payment(TimeInfoBase):
|
|
"""Local ledger row for a payment attempt (Stripe or store/RevenueCat)."""
|
|
|
|
class Provider(models.TextChoices):
|
|
STRIPE = "stripe", "Stripe"
|
|
REVENUECAT = "revenuecat", "RevenueCat"
|
|
APP_STORE = "app_store", "App Store"
|
|
PLAY_STORE = "play_store", "Play Store"
|
|
|
|
class Status(models.TextChoices):
|
|
PENDING = "pending", "Pending"
|
|
SUCCEEDED = "succeeded", "Succeeded"
|
|
FAILED = "failed", "Failed"
|
|
CANCELED = "canceled", "Canceled"
|
|
REQUIRES_ACTION = "requires_action", "Requires action"
|
|
|
|
invoice = models.ForeignKey(
|
|
Invoice,
|
|
on_delete=models.CASCADE,
|
|
related_name="payments",
|
|
null=True,
|
|
blank=True,
|
|
)
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name="payments",
|
|
)
|
|
company = models.ForeignKey(
|
|
Company,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="payments",
|
|
)
|
|
provider = models.CharField(
|
|
max_length=32,
|
|
choices=Provider.choices,
|
|
default=Provider.STRIPE,
|
|
)
|
|
status = models.CharField(
|
|
max_length=32,
|
|
choices=Status.choices,
|
|
default=Status.PENDING,
|
|
db_index=True,
|
|
)
|
|
currency = models.CharField(max_length=8, default="usd")
|
|
amount = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text="Amount in the smallest currency unit (e.g. cents).",
|
|
)
|
|
stripe_payment_intent_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
null=True,
|
|
unique=True,
|
|
db_index=True,
|
|
)
|
|
stripe_charge_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
null=True,
|
|
unique=True,
|
|
db_index=True,
|
|
)
|
|
revenuecat_transaction_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
null=True,
|
|
unique=True,
|
|
db_index=True,
|
|
help_text="Store transaction id from RevenueCat.",
|
|
)
|
|
paid_at = models.DateTimeField(null=True, blank=True)
|
|
failure_message = models.CharField(max_length=512, blank=True, default="")
|
|
|
|
class Meta:
|
|
ordering = ["-created"]
|
|
|
|
def __str__(self) -> str:
|
|
return f"Payment {self.pk} ({self.status}) user={self.user_id}"
|
|
|
|
def mark_succeeded(self, *, paid_at=None):
|
|
self.status = self.Status.SUCCEEDED
|
|
self.paid_at = paid_at or timezone.now()
|
|
self.save(update_fields=["status", "paid_at", "last_modified"])
|