Add monetization app with RevenueCat webhooks alongside Stripe.
CI / test (pull_request) Successful in 10s
Unit Tests / test (pull_request) Successful in 10s

Rename finance → monetization (keep finance_* tables via app label), add
RevenueCat webhook + ledger upserts so store IAP syncs subscriptions and
billing history like Stripe. Companion to chat_web_app#100 / #68.
This commit is contained in:
2026-08-04 03:32:51 -07:00
parent 2aeb95136a
commit 29c69b91f7
44 changed files with 965 additions and 102 deletions
+385
View File
@@ -0,0 +1,385 @@
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
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"
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"
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"
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"])