Monetization app + RevenueCat webhooks (store IAP ledger) (#69)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 11s
Deploy Beta / docker (push) Successful in 21s
Deploy Beta / deploy-beta (push) Successful in 50s

## Summary
- Rename `finance` → **`monetization`** Django app (keep `finance_*` tables via `label = "finance"`)
- Add `services/stripe.py` + `services/revenuecat.py`; RevenueCat webhook upserts **subscription + Invoice/Payment** (billing history parity with Stripe)
- Mount `/api/monetization/` + keep `/api/finance/` alias
- Extend `Source`/`Provider` with `revenuecat`; product→plan mapping via `revenuecat_product_id` / `REVENUECAT_PRODUCT_PLAN_MAP`

Closes #68. Companion to [chat_web_app#100](ai_ml_operations/chat_web_app#100).

## Test plan
- [x] `manage.py test monetization.tests` (54 OK)
- [x] Smoke `chat_backend.tests.test_views_documents` + `test_oauth`
- [ ] Deploy: set `REVENUECAT_WEBHOOK_SECRET`; point RC webhook at `/api/finance/webhooks/revenuecat/`
- [ ] Map store product IDs on `SubscriptionPlan.revenuecat_product_id` (or env JSON map)
- [ ] Sandbox INITIAL_PURCHASE → subscription `source=revenuecat` + invoice in `/finance/invoices/`Reviewed-on: #69
This commit was merged in pull request #69.
This commit is contained in:
2026-08-04 03:40:15 -07:00
parent 2aeb95136a
commit e1e086a474
44 changed files with 965 additions and 102 deletions
View File
+133
View File
@@ -0,0 +1,133 @@
from django.contrib import admin
from monetization.models import BackerEmail, Invoice, Payment, SubscriptionPlan, UserSubscription
@admin.register(SubscriptionPlan)
class SubscriptionPlanAdmin(admin.ModelAdmin):
list_display = (
"slug",
"name",
"price_cents",
"is_public",
"is_selectable",
"allows_image_generation",
"allows_rag",
"allows_all_future_features",
"prompt_quota_per_window",
"prompt_window_hours",
"monthly_token_quota",
"sort_order",
)
list_filter = ("is_public", "is_selectable", "allows_image_generation", "allows_rag")
search_fields = ("slug", "name", "stripe_price_id")
readonly_fields = ("created", "last_modified")
prepopulated_fields = {"slug": ("name",)}
@admin.register(BackerEmail)
class BackerEmailAdmin(admin.ModelAdmin):
list_display = ("email", "note", "redeemed_at", "redeemed_user", "created")
search_fields = ("email", "note", "redeemed_user__email")
raw_id_fields = ("redeemed_user",)
readonly_fields = ("created", "last_modified", "redeemed_at", "redeemed_user")
@admin.register(UserSubscription)
class UserSubscriptionAdmin(admin.ModelAdmin):
list_display = (
"user",
"plan",
"status",
"source",
"stripe_subscription_id",
"monthly_token_quota_override",
"created",
)
list_filter = ("status", "source", "plan")
search_fields = ("user__email", "user__username", "stripe_subscription_id")
raw_id_fields = ("user", "plan")
readonly_fields = ("created", "last_modified")
class PaymentInline(admin.TabularInline):
model = Payment
extra = 0
readonly_fields = (
"provider",
"status",
"amount",
"currency",
"stripe_payment_intent_id",
"stripe_charge_id",
"paid_at",
"failure_message",
"created",
"last_modified",
)
can_delete = False
show_change_link = True
@admin.register(Invoice)
class InvoiceAdmin(admin.ModelAdmin):
list_display = (
"id",
"user",
"company",
"provider",
"status",
"amount_due",
"amount_paid",
"currency",
"period_start",
"period_end",
"stripe_invoice_id",
"stripe_checkout_session_id",
"created",
)
list_filter = ("provider", "status", "currency")
search_fields = (
"user__email",
"user__username",
"company__name",
"stripe_invoice_id",
"stripe_checkout_session_id",
"stripe_subscription_id",
"stripe_customer_id",
"description",
)
readonly_fields = ("created", "last_modified")
raw_id_fields = ("user", "company")
inlines = [PaymentInline]
date_hierarchy = "created"
@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
list_display = (
"id",
"user",
"company",
"invoice",
"provider",
"status",
"amount",
"currency",
"stripe_payment_intent_id",
"stripe_charge_id",
"paid_at",
"created",
)
list_filter = ("provider", "status", "currency")
search_fields = (
"user__email",
"user__username",
"company__name",
"stripe_payment_intent_id",
"stripe_charge_id",
"invoice__stripe_invoice_id",
)
readonly_fields = ("created", "last_modified")
raw_id_fields = ("user", "company", "invoice")
date_hierarchy = "created"
+22
View File
@@ -0,0 +1,22 @@
from django.apps import AppConfig
class MonetizationConfig(AppConfig):
"""Paid access: plans, quotas, Stripe, RevenueCat.
``label`` stays ``finance`` so existing ``finance_*`` tables and
``django_migrations`` / contenttypes rows keep working after the package
rename from ``finance`` → ``monetization``.
"""
default_auto_field = "django.db.models.BigAutoField"
name = "monetization"
label = "finance"
verbose_name = "Monetization"
def ready(self):
from django.db.models.signals import post_migrate
from monetization.signals import seed_plans_on_migrate
post_migrate.connect(seed_plans_on_migrate, sender=self)
@@ -0,0 +1,237 @@
# Generated by Django 6.0 on 2026-07-27 00:16
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("chat_backend", "0023_promptmetric_tokens_in_promptmetric_tokens_out"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Invoice",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(default=django.utils.timezone.now)),
(
"last_modified",
models.DateTimeField(default=django.utils.timezone.now),
),
(
"provider",
models.CharField(
choices=[("stripe", "Stripe")], default="stripe", max_length=32
),
),
(
"status",
models.CharField(
choices=[
("draft", "Draft"),
("open", "Open"),
("paid", "Paid"),
("void", "Void"),
("uncollectible", "Uncollectible"),
("payment_failed", "Payment failed"),
],
db_index=True,
default="open",
max_length=32,
),
),
("currency", models.CharField(default="usd", max_length=8)),
(
"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(blank=True, null=True)),
("period_end", models.DateTimeField(blank=True, null=True)),
(
"stripe_invoice_id",
models.CharField(
blank=True,
db_index=True,
max_length=255,
null=True,
unique=True,
),
),
(
"stripe_checkout_session_id",
models.CharField(
blank=True,
db_index=True,
max_length=255,
null=True,
unique=True,
),
),
(
"stripe_subscription_id",
models.CharField(
blank=True, db_index=True, max_length=255, null=True
),
),
(
"stripe_customer_id",
models.CharField(blank=True, default="", max_length=255),
),
("hosted_invoice_url", models.URLField(blank=True, default="")),
(
"description",
models.CharField(blank=True, default="", max_length=512),
),
(
"company",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="invoices",
to="chat_backend.company",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="invoices",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created"],
},
),
migrations.CreateModel(
name="Payment",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(default=django.utils.timezone.now)),
(
"last_modified",
models.DateTimeField(default=django.utils.timezone.now),
),
(
"provider",
models.CharField(
choices=[("stripe", "Stripe")], default="stripe", max_length=32
),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("succeeded", "Succeeded"),
("failed", "Failed"),
("canceled", "Canceled"),
("requires_action", "Requires action"),
],
db_index=True,
default="pending",
max_length=32,
),
),
("currency", models.CharField(default="usd", max_length=8)),
(
"amount",
models.PositiveIntegerField(
default=0,
help_text="Amount in the smallest currency unit (e.g. cents).",
),
),
(
"stripe_payment_intent_id",
models.CharField(
blank=True,
db_index=True,
max_length=255,
null=True,
unique=True,
),
),
(
"stripe_charge_id",
models.CharField(
blank=True,
db_index=True,
max_length=255,
null=True,
unique=True,
),
),
("paid_at", models.DateTimeField(blank=True, null=True)),
(
"failure_message",
models.CharField(blank=True, default="", max_length=512),
),
(
"company",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="payments",
to="chat_backend.company",
),
),
(
"invoice",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="payments",
to="finance.invoice",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="payments",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created"],
},
),
]
@@ -0,0 +1,190 @@
# Generated by Django 6.0 on 2026-07-31 11:14
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
def seed_plans(apps, schema_editor):
"""Seed the original 5-plan catalog frozen at this migration's schema.
Deliberately does NOT import ``finance.services.plans`` — that module's
``PLAN_SEED``/model class reflect the *current* code, so a later required
field (e.g. ``allows_rag`` added in #43) would make this historical
RunPython try to write a column that doesn't exist yet when a fresh
database replays migrations in order. Live code re-seeds (and adds any
new fields) via ``seed_subscription_plans()`` calls elsewhere (app
startup, quota checks, test setUp), so this only needs to create the
original rows.
"""
SubscriptionPlan = apps.get_model("finance", "SubscriptionPlan")
seed = [
{
"slug": "founders",
"name": "Founders",
"description": (
"Unlimited product access for early supporters: text plus all future "
"capabilities as they ship. $10/mo."
),
"price_cents": 1000,
"is_public": True,
"is_selectable": True,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_all_future_features": True,
"prompt_quota_per_window": 300,
"prompt_window_hours": 6,
"monthly_token_quota": None,
"sort_order": 10,
},
{
"slug": "standard",
"name": "Standard",
"description": (
"Secure conversational chat and coding assistance for developers "
"and privacy-conscious individuals."
),
"price_cents": 1500,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": False,
"allows_all_future_features": False,
"prompt_quota_per_window": 100,
"prompt_window_hours": 6,
"monthly_token_quota": 1_000_000,
"sort_order": 20,
},
{
"slug": "pro",
"name": "Pro / Creator",
"description": (
"Higher message caps and multi-modal workflows for heavy users, "
"including image generation when available."
),
"price_cents": 4000,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_all_future_features": False,
"prompt_quota_per_window": 200,
"prompt_window_hours": 6,
"monthly_token_quota": 3_000_000,
"sort_order": 30,
},
{
"slug": "business",
"name": "Business Team",
"description": (
"Team seats, centralized auth, priority support, and absolute data "
"privacy for local companies handling sensitive data."
),
"price_cents": 9900,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_all_future_features": False,
"prompt_quota_per_window": 300,
"prompt_window_hours": 6,
"monthly_token_quota": 5_000_000,
"sort_order": 40,
},
{
"slug": "backer",
"name": "Backer",
"description": (
"Complimentary Founders-level access for pre-approved emails. "
"Not shown at checkout."
),
"price_cents": 0,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_all_future_features": True,
"prompt_quota_per_window": 300,
"prompt_window_hours": 6,
"monthly_token_quota": None,
"sort_order": 5,
},
]
for row in seed:
slug = row.pop("slug")
SubscriptionPlan.objects.get_or_create(slug=slug, defaults=row)
class Migration(migrations.Migration):
dependencies = [
('finance', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SubscriptionPlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(default=django.utils.timezone.now)),
('last_modified', models.DateTimeField(default=django.utils.timezone.now)),
('slug', models.SlugField(max_length=64, unique=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(default='usd', max_length=8)),
('interval', models.CharField(default='month', max_length=16)),
('stripe_price_id', models.CharField(blank=True, default='', help_text='Optional Stripe Price id; empty uses price_data at Checkout.', max_length=255)),
('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_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(blank=True, help_text='Optional billing-period token cap (tokens_in + tokens_out). Null = no token-period limit (prompt window still applies).', null=True)),
('sort_order', models.PositiveIntegerField(default=0)),
],
options={
'ordering': ['sort_order', 'price_cents', 'name'],
},
),
migrations.CreateModel(
name='BackerEmail',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(default=django.utils.timezone.now)),
('last_modified', models.DateTimeField(default=django.utils.timezone.now)),
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
('note', models.CharField(blank=True, default='', max_length=512)),
('redeemed_at', models.DateTimeField(blank=True, null=True)),
('redeemed_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='backer_email_entries', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Backer email',
'verbose_name_plural': 'Backer emails',
'ordering': ['email'],
},
),
migrations.CreateModel(
name='UserSubscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(default=django.utils.timezone.now)),
('last_modified', models.DateTimeField(default=django.utils.timezone.now)),
('status', models.CharField(choices=[('none', 'None'), ('active', 'Active'), ('past_due', 'Past due'), ('canceled', 'Canceled')], db_index=True, default='none', max_length=32)),
('source', models.CharField(choices=[('none', 'None'), ('stripe', 'Stripe'), ('backer', 'Backer'), ('admin', 'Admin')], default='none', max_length=32)),
('stripe_subscription_id', models.CharField(blank=True, db_index=True, default='', max_length=255)),
('monthly_token_quota_override', models.PositiveIntegerField(blank=True, help_text='Optional per-user override of plan monthly_token_quota.', null=True)),
('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='finance.subscriptionplan')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'User subscription',
'verbose_name_plural': 'User subscriptions',
},
),
migrations.RunPython(seed_plans, migrations.RunPython.noop),
]
@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-08-01 19:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0002_subscription_plans_quotas"),
]
operations = [
migrations.AddField(
model_name="usersubscription",
name="cancel_at_period_end",
field=models.BooleanField(
default=False,
help_text="Stripe: subscription will cancel at current_period_end.",
),
),
migrations.AddField(
model_name="usersubscription",
name="current_period_end",
field=models.DateTimeField(
blank=True,
help_text="Stripe billing period end (access remains until then when canceling).",
null=True,
),
),
]
@@ -0,0 +1,21 @@
# Generated by Django 6.0 on 2026-08-01 20:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0003_subscription_cancel_period_fields"),
]
operations = [
migrations.AddField(
model_name="subscriptionplan",
name="allows_rag",
field=models.BooleanField(
default=False,
help_text="Drive/RAG document sync (Google Drive, OneDrive, SharePoint).",
),
),
]
@@ -0,0 +1,63 @@
# Generated by Django 6.0 on 2026-08-03 19:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0004_subscriptionplan_allows_rag'),
]
operations = [
migrations.AddField(
model_name='invoice',
name='revenuecat_event_id',
field=models.CharField(blank=True, db_index=True, help_text='RevenueCat webhook event id (idempotency key).', max_length=255, null=True, unique=True),
),
migrations.AddField(
model_name='invoice',
name='revenuecat_store',
field=models.CharField(blank=True, default='', help_text='APP_STORE / PLAY_STORE / etc.', max_length=32),
),
migrations.AddField(
model_name='payment',
name='revenuecat_transaction_id',
field=models.CharField(blank=True, db_index=True, help_text='Store transaction id from RevenueCat.', max_length=255, null=True, unique=True),
),
migrations.AddField(
model_name='subscriptionplan',
name='revenuecat_product_id',
field=models.CharField(blank=True, db_index=True, default='', help_text='Store/RevenueCat product identifier (Play + App Store).', max_length=255),
),
migrations.AddField(
model_name='usersubscription',
name='revenuecat_original_transaction_id',
field=models.CharField(blank=True, db_index=True, default='', help_text='Store original transaction id from RevenueCat events.', max_length=255),
),
migrations.AlterField(
model_name='invoice',
name='provider',
field=models.CharField(choices=[('stripe', 'Stripe'), ('revenuecat', 'RevenueCat')], default='stripe', max_length=32),
),
migrations.AlterField(
model_name='payment',
name='provider',
field=models.CharField(choices=[('stripe', 'Stripe'), ('revenuecat', 'RevenueCat')], default='stripe', max_length=32),
),
migrations.AlterField(
model_name='usersubscription',
name='cancel_at_period_end',
field=models.BooleanField(default=False, help_text='Subscription will cancel at current_period_end.'),
),
migrations.AlterField(
model_name='usersubscription',
name='current_period_end',
field=models.DateTimeField(blank=True, help_text='Billing period end (access remains until then when canceling).', null=True),
),
migrations.AlterField(
model_name='usersubscription',
name='source',
field=models.CharField(choices=[('none', 'None'), ('stripe', 'Stripe'), ('revenuecat', 'RevenueCat'), ('backer', 'Backer'), ('admin', 'Admin')], default='none', max_length=32),
),
]
+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"])
+90
View File
@@ -0,0 +1,90 @@
from rest_framework import serializers
from monetization.models import Invoice, Payment, SubscriptionPlan
class InvoiceSerializer(serializers.ModelSerializer):
class Meta:
model = Invoice
fields = [
"id",
"provider",
"status",
"currency",
"amount_due",
"amount_paid",
"period_start",
"period_end",
"stripe_invoice_id",
"stripe_checkout_session_id",
"stripe_subscription_id",
"revenuecat_event_id",
"revenuecat_store",
"hosted_invoice_url",
"description",
"created",
"last_modified",
]
read_only_fields = fields
class PaymentSerializer(serializers.ModelSerializer):
class Meta:
model = Payment
fields = [
"id",
"invoice",
"provider",
"status",
"currency",
"amount",
"stripe_payment_intent_id",
"stripe_charge_id",
"revenuecat_transaction_id",
"paid_at",
"failure_message",
"created",
"last_modified",
]
read_only_fields = fields
class CheckoutSessionSerializer(serializers.Serializer):
success_url = serializers.URLField(required=False, allow_blank=False)
cancel_url = serializers.URLField(required=False, allow_blank=False)
plan_slug = serializers.SlugField(required=False, allow_blank=False)
class PortalSessionSerializer(serializers.Serializer):
return_url = serializers.URLField(required=False, allow_blank=False)
class SubscriptionPlanSerializer(serializers.ModelSerializer):
features = serializers.SerializerMethodField()
class Meta:
model = SubscriptionPlan
fields = [
"slug",
"name",
"description",
"price_cents",
"currency",
"interval",
"is_public",
"is_selectable",
"features",
"prompt_quota_per_window",
"prompt_window_hours",
"monthly_token_quota",
"sort_order",
]
read_only_fields = fields
def get_features(self, obj):
return {
"text_generation": obj.allows_feature("text_generation"),
"image_generation": obj.allows_feature("image_generation"),
"rag": obj.allows_feature("rag"),
"all_future_features": obj.allows_all_future_features,
}
+576
View File
@@ -0,0 +1,576 @@
"""Subscription plan catalog helpers, seeding, and user assignment."""
from __future__ import annotations
import logging
from typing import Any
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from chat_backend.models import UserAuthEvent
from monetization.models import BackerEmail, SubscriptionPlan, UserSubscription
logger = logging.getLogger(__name__)
def log_subscription_auth_event(
user,
*,
started: bool,
detail: str,
) -> None:
event_type = (
UserAuthEvent.EventType.SUBSCRIPTION_STARTED
if started
else UserAuthEvent.EventType.SUBSCRIPTION_UPDATED
)
UserAuthEvent.log(user, event_type, detail=detail[:512])
# Seed catalog for #36. Standard/Pro/Business stay hidden until explicitly enabled.
PLAN_SEED: list[dict[str, Any]] = [
{
"slug": SubscriptionPlan.Slug.FOUNDERS,
"name": "Founders",
"description": (
"Unlimited product access for early supporters: text plus all future "
"capabilities as they ship. $10/mo."
),
"price_cents": 1000,
"is_public": True,
"is_selectable": True,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_rag": True,
"allows_all_future_features": True,
"prompt_quota_per_window": 300,
"prompt_window_hours": 6,
"monthly_token_quota": None,
"sort_order": 10,
},
{
"slug": SubscriptionPlan.Slug.STANDARD,
"name": "Standard",
"description": (
"Secure conversational chat and coding assistance for developers "
"and privacy-conscious individuals."
),
"price_cents": 1500,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": False,
"allows_rag": False,
"allows_all_future_features": False,
"prompt_quota_per_window": 100,
"prompt_window_hours": 6,
"monthly_token_quota": 1_000_000,
"sort_order": 20,
},
{
"slug": SubscriptionPlan.Slug.PRO,
"name": "Pro / Creator",
"description": (
"Higher message caps and multi-modal workflows for heavy users, "
"including image generation and Drive/RAG sync when available."
),
"price_cents": 4000,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_rag": True,
"allows_all_future_features": False,
"prompt_quota_per_window": 200,
"prompt_window_hours": 6,
"monthly_token_quota": 3_000_000,
"sort_order": 30,
},
{
"slug": SubscriptionPlan.Slug.BUSINESS,
"name": "Business Team",
"description": (
"Team seats, centralized auth, priority support, company Drive/RAG "
"sync, and absolute data privacy for local companies handling "
"sensitive data."
),
"price_cents": 9900,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_rag": True,
"allows_all_future_features": False,
"prompt_quota_per_window": 300,
"prompt_window_hours": 6,
"monthly_token_quota": 5_000_000,
"sort_order": 40,
},
{
"slug": SubscriptionPlan.Slug.BACKER,
"name": "Backer",
"description": (
"Complimentary Founders-level access for pre-approved emails. "
"Not shown at checkout."
),
"price_cents": 0,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_rag": True,
"allows_all_future_features": True,
"prompt_quota_per_window": 300,
"prompt_window_hours": 6,
"monthly_token_quota": None,
"sort_order": 5,
},
]
def seed_subscription_plans(*, update_existing: bool = True) -> list[SubscriptionPlan]:
"""Idempotently create/update the canonical plan catalog."""
plans: list[SubscriptionPlan] = []
for row in PLAN_SEED:
slug = row["slug"]
defaults = {k: v for k, v in row.items() if k != "slug"}
plan, created = SubscriptionPlan.objects.get_or_create(
slug=slug,
defaults=defaults,
)
if not created and update_existing:
for key, value in defaults.items():
setattr(plan, key, value)
plan.save()
plans.append(plan)
return plans
def get_plan(slug: str) -> SubscriptionPlan | None:
return SubscriptionPlan.objects.filter(slug=slug).first()
def get_or_create_user_subscription(user) -> UserSubscription:
sub, _ = UserSubscription.objects.get_or_create(user=user)
return sub
def assign_plan(
user,
*,
plan: SubscriptionPlan,
source: str,
status: str = UserSubscription.Status.ACTIVE,
stripe_subscription_id: str = "",
revenuecat_original_transaction_id: str = "",
log_auth_event: bool = True,
) -> UserSubscription:
sub = get_or_create_user_subscription(user)
prev_plan_id = sub.plan_id
prev_status = sub.status
prev_source = sub.source
prev_stripe_sub = sub.stripe_subscription_id or ""
prev_rc_txn = sub.revenuecat_original_transaction_id or ""
had_active = (
prev_status == UserSubscription.Status.ACTIVE and prev_plan_id is not None
)
sub.plan = plan
sub.source = source
sub.status = status
if stripe_subscription_id:
sub.stripe_subscription_id = stripe_subscription_id
if revenuecat_original_transaction_id:
sub.revenuecat_original_transaction_id = revenuecat_original_transaction_id
sub.save()
if log_auth_event:
became_active = (
status == UserSubscription.Status.ACTIVE and plan is not None
)
changed = (
prev_plan_id != sub.plan_id
or prev_status != sub.status
or prev_source != sub.source
or (
bool(stripe_subscription_id)
and prev_stripe_sub != (sub.stripe_subscription_id or "")
)
or (
bool(revenuecat_original_transaction_id)
and prev_rc_txn != (sub.revenuecat_original_transaction_id or "")
)
)
extra = ""
if stripe_subscription_id:
extra += f" stripe_subscription_id={stripe_subscription_id}"
if revenuecat_original_transaction_id:
extra += (
f" revenuecat_original_transaction_id="
f"{revenuecat_original_transaction_id}"
)
if became_active and not had_active:
log_subscription_auth_event(
user,
started=True,
detail=f"plan={plan.slug} source={source} status={status}{extra}",
)
elif changed:
log_subscription_auth_event(
user,
started=False,
detail=f"plan={plan.slug} source={source} status={status}{extra}",
)
return sub
def user_has_active_plan(user) -> bool:
try:
sub = user.subscription
except UserSubscription.DoesNotExist:
return False
return sub.is_active
def needs_checkout(user) -> bool:
"""True when the user must complete paid Checkout to use the product."""
try:
sub = user.subscription
except UserSubscription.DoesNotExist:
return True
if not sub.is_active:
return True
# Complimentary / already-paid tiers skip Checkout.
if sub.source in (
UserSubscription.Source.BACKER,
UserSubscription.Source.ADMIN,
UserSubscription.Source.STRIPE,
UserSubscription.Source.REVENUECAT,
):
return False
return True
@transaction.atomic
def try_redeem_backer_email(user) -> UserSubscription | None:
"""
If the user's email is on the Backer whitelist and unused, assign Backer plan.
Returns the UserSubscription when redeemed, else None.
"""
email = (getattr(user, "email", "") or "").strip().lower()
if not email:
return None
entry = (
BackerEmail.objects.select_for_update()
.filter(email__iexact=email, redeemed_at__isnull=True)
.first()
)
if entry is None:
return None
seed_subscription_plans(update_existing=False)
plan = get_plan(SubscriptionPlan.Slug.BACKER)
if plan is None:
logger.error("Backer plan missing from catalog; cannot redeem %s", email)
return None
sub = assign_plan(
user,
plan=plan,
source=UserSubscription.Source.BACKER,
status=UserSubscription.Status.ACTIVE,
)
entry.redeemed_at = timezone.now()
entry.redeemed_user = user
entry.save(update_fields=["redeemed_at", "redeemed_user", "last_modified"])
logger.info("Redeemed Backer email %s for user %s", email, user.pk)
return sub
def assign_founders_from_stripe(
user,
*,
stripe_subscription_id: str = "",
) -> UserSubscription:
"""Backward-compatible helper; prefer ``assign_plan_from_stripe``."""
return assign_plan_from_stripe(
user,
plan_slug=SubscriptionPlan.Slug.FOUNDERS,
stripe_subscription_id=stripe_subscription_id,
)
def assign_plan_from_stripe(
user,
*,
plan_slug: str | None = None,
stripe_subscription_id: str = "",
status: str = UserSubscription.Status.ACTIVE,
cancel_at_period_end: bool | None = None,
current_period_end=None,
keep_existing_plan_if_unknown: bool = False,
) -> UserSubscription:
"""Assign a catalog plan from a Stripe Checkout / subscription event."""
seed_subscription_plans(update_existing=False)
existing = (
UserSubscription.objects.filter(user=user).select_related("plan").first()
)
prev_cancel = bool(existing.cancel_at_period_end) if existing else False
prev_period_end = existing.current_period_end if existing else None
had_active = bool(
existing
and existing.status == UserSubscription.Status.ACTIVE
and existing.plan_id
)
slug = (plan_slug or "").strip().lower()
plan = get_plan(slug) if slug else None
if plan is None and keep_existing_plan_if_unknown and existing and existing.plan_id:
plan = existing.plan
if plan is None:
if slug:
logger.warning(
"Unknown plan_slug=%s; falling back to Founders for user=%s",
slug,
getattr(user, "pk", None),
)
plan = get_plan(SubscriptionPlan.Slug.FOUNDERS)
if plan is None:
raise RuntimeError("Founders plan missing from catalog")
# Single auth-event log after plan + cancel fields are applied.
sub = assign_plan(
user,
plan=plan,
source=UserSubscription.Source.STRIPE,
status=status,
stripe_subscription_id=stripe_subscription_id or "",
log_auth_event=False,
)
update_fields: list[str] = []
if cancel_at_period_end is not None:
sub.cancel_at_period_end = bool(cancel_at_period_end)
update_fields.append("cancel_at_period_end")
if current_period_end is not None:
sub.current_period_end = current_period_end
update_fields.append("current_period_end")
if update_fields:
sub.save(update_fields=update_fields)
became_active = (
sub.status == UserSubscription.Status.ACTIVE and sub.plan_id is not None
)
cancel_changed = (
cancel_at_period_end is not None
and bool(cancel_at_period_end) != prev_cancel
)
period_changed = (
current_period_end is not None and current_period_end != prev_period_end
)
plan_or_status_changed = (
not existing
or existing.plan_id != sub.plan_id
or existing.status != sub.status
or (existing.source != sub.source)
or (
bool(stripe_subscription_id)
and (existing.stripe_subscription_id or "")
!= (sub.stripe_subscription_id or "")
)
)
if became_active and not had_active:
log_subscription_auth_event(
user,
started=True,
detail=(
f"plan={sub.plan.slug} source={sub.source} status={sub.status}"
f" cancel_at_period_end={sub.cancel_at_period_end}"
),
)
elif plan_or_status_changed or cancel_changed or period_changed:
log_subscription_auth_event(
user,
started=False,
detail=(
f"plan={sub.plan.slug if sub.plan_id else 'none'} "
f"source={sub.source} status={sub.status} "
f"cancel_at_period_end={sub.cancel_at_period_end}"
),
)
return sub
def resolve_plan_from_stripe_price(price_id: str | None) -> SubscriptionPlan | None:
"""Map a Stripe Price id to a local SubscriptionPlan when configured."""
if not price_id:
return None
return SubscriptionPlan.objects.filter(stripe_price_id=price_id).first()
def resolve_plan_from_revenuecat_product(
product_id: str | None,
) -> SubscriptionPlan | None:
"""Map a store/RevenueCat product id to a local SubscriptionPlan."""
if not product_id:
return None
seed_subscription_plans(update_existing=False)
plan = SubscriptionPlan.objects.filter(
revenuecat_product_id=product_id
).first()
if plan:
return plan
# Optional env map: {"com.app.pro.monthly": "pro", ...}
mapping = getattr(settings, "REVENUECAT_PRODUCT_PLAN_MAP", None) or {}
if isinstance(mapping, dict):
slug = mapping.get(product_id)
if slug:
plan = get_plan(str(slug))
if plan:
return plan
# Heuristic: product id contains a known plan slug.
lowered = product_id.lower()
for slug in (
SubscriptionPlan.Slug.FOUNDERS,
SubscriptionPlan.Slug.BUSINESS,
SubscriptionPlan.Slug.STANDARD,
SubscriptionPlan.Slug.PRO,
):
if slug in lowered:
plan = get_plan(slug)
if plan:
return plan
return None
def assign_plan_from_revenuecat(
user,
*,
plan_slug: str | None = None,
product_id: str | None = None,
revenuecat_original_transaction_id: str = "",
status: str = UserSubscription.Status.ACTIVE,
cancel_at_period_end: bool | None = None,
current_period_end=None,
keep_existing_plan_if_unknown: bool = False,
) -> UserSubscription:
"""Assign a catalog plan from a RevenueCat store purchase event."""
seed_subscription_plans(update_existing=False)
existing = (
UserSubscription.objects.filter(user=user).select_related("plan").first()
)
prev_cancel = bool(existing.cancel_at_period_end) if existing else False
prev_period_end = existing.current_period_end if existing else None
had_active = bool(
existing
and existing.status == UserSubscription.Status.ACTIVE
and existing.plan_id
)
slug = (plan_slug or "").strip().lower()
plan = get_plan(slug) if slug else None
if plan is None and product_id:
plan = resolve_plan_from_revenuecat_product(product_id)
if plan is None and keep_existing_plan_if_unknown and existing and existing.plan_id:
plan = existing.plan
if plan is None:
if slug or product_id:
logger.warning(
"Unknown RC product/plan product_id=%s plan_slug=%s; "
"falling back to Founders for user=%s",
product_id,
slug,
getattr(user, "pk", None),
)
plan = get_plan(SubscriptionPlan.Slug.FOUNDERS)
if plan is None:
raise RuntimeError("Founders plan missing from catalog")
sub = assign_plan(
user,
plan=plan,
source=UserSubscription.Source.REVENUECAT,
status=status,
revenuecat_original_transaction_id=revenuecat_original_transaction_id or "",
log_auth_event=False,
)
update_fields: list[str] = []
if cancel_at_period_end is not None:
sub.cancel_at_period_end = bool(cancel_at_period_end)
update_fields.append("cancel_at_period_end")
if current_period_end is not None:
sub.current_period_end = current_period_end
update_fields.append("current_period_end")
if update_fields:
sub.save(update_fields=update_fields)
became_active = (
sub.status == UserSubscription.Status.ACTIVE and sub.plan_id is not None
)
cancel_changed = (
cancel_at_period_end is not None
and bool(cancel_at_period_end) != prev_cancel
)
period_changed = (
current_period_end is not None and current_period_end != prev_period_end
)
plan_or_status_changed = (
not existing
or existing.plan_id != sub.plan_id
or existing.status != sub.status
or (existing.source != sub.source)
or (
bool(revenuecat_original_transaction_id)
and (existing.revenuecat_original_transaction_id or "")
!= (sub.revenuecat_original_transaction_id or "")
)
)
if became_active and not had_active:
log_subscription_auth_event(
user,
started=True,
detail=(
f"plan={sub.plan.slug} source={sub.source} status={sub.status}"
f" cancel_at_period_end={sub.cancel_at_period_end}"
),
)
elif plan_or_status_changed or cancel_changed or period_changed:
log_subscription_auth_event(
user,
started=False,
detail=(
f"plan={sub.plan.slug if sub.plan_id else 'none'} "
f"source={sub.source} status={sub.status} "
f"cancel_at_period_end={sub.cancel_at_period_end}"
),
)
return sub
def plan_to_dict(plan: SubscriptionPlan | None) -> dict[str, Any] | None:
if plan is None:
return None
return {
"slug": plan.slug,
"name": plan.name,
"description": plan.description,
"price_cents": plan.price_cents,
"currency": plan.currency,
"interval": plan.interval,
"is_public": plan.is_public,
"is_selectable": plan.is_selectable,
"features": {
"text_generation": plan.allows_feature("text_generation"),
"image_generation": plan.allows_feature("image_generation"),
"rag": plan.allows_feature("rag"),
"all_future_features": plan.allows_all_future_features,
},
"prompt_quota_per_window": plan.prompt_quota_per_window,
"prompt_window_hours": plan.prompt_window_hours,
"monthly_token_quota": plan.monthly_token_quota,
"sort_order": plan.sort_order,
}
+254
View File
@@ -0,0 +1,254 @@
"""Prompt-window and token-period quota checks (shared by chat + finance APIs)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from django.conf import settings
from django.db.models import Count, Q, Sum
from django.utils import timezone
from chat_backend.models import PromptMetric
from monetization.models import UserSubscription
from monetization.services.plans import get_or_create_user_subscription, seed_subscription_plans
class QuotaExceeded(Exception):
"""Raised when a generation turn is blocked by quota."""
def __init__(self, code: str, message: str, *, details: dict | None = None):
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
class FeatureNotAllowed(Exception):
"""Raised when the user's plan cannot use a feature."""
def __init__(self, code: str, message: str, *, details: dict | None = None):
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
@dataclass
class UsageSnapshot:
prompts_in_window: int
prompt_quota: int | None
prompts_remaining: int | None
window_hours: int
tokens_in_period: int | None
tokens_out_period: int | None
tokens_total_period: int | None
turns_missing_token_usage: int
monthly_token_quota: int | None
tokens_remaining: int | None
period_start: Any
period_end: Any
def to_dict(self) -> dict[str, Any]:
return {
"prompts_in_window": self.prompts_in_window,
"prompt_quota": self.prompt_quota,
"prompts_remaining": self.prompts_remaining,
"window_hours": self.window_hours,
"tokens_in_period": self.tokens_in_period,
"tokens_out_period": self.tokens_out_period,
"tokens_total_period": self.tokens_total_period,
"turns_missing_token_usage": self.turns_missing_token_usage,
"monthly_token_quota": self.monthly_token_quota,
"tokens_remaining": self.tokens_remaining,
"period_start": self.period_start.isoformat() if self.period_start else None,
"period_end": self.period_end.isoformat() if self.period_end else None,
}
def _user_conversation_ids(user) -> list[int]:
from chat_backend.models import Conversation
return list(
Conversation.objects.filter(user=user, deleted=False).values_list("id", flat=True)
)
def _billing_period_bounds():
"""Calendar-month UTC window for token-period aggregation (#17)."""
now = timezone.now()
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if start.month == 12:
end = start.replace(year=start.year + 1, month=1)
else:
end = start.replace(month=start.month + 1)
return start, end
def _sum_tokens(qs) -> tuple[int | None, int | None]:
"""
Sum tokens_in / tokens_out.
Returns (None, None) when *no* rows reported usage — never fabricate 0.
When some rows reported usage, sum only those (nulls ignored by Sum).
"""
agg = qs.aggregate(
tin=Sum("tokens_in"),
tout=Sum("tokens_out"),
with_in=Count("id", filter=Q(tokens_in__isnull=False)),
with_out=Count("id", filter=Q(tokens_out__isnull=False)),
)
tokens_in = agg["tin"] if agg["with_in"] else None
tokens_out = agg["tout"] if agg["with_out"] else None
return tokens_in, tokens_out
def get_usage_snapshot(user) -> UsageSnapshot:
seed_subscription_plans(update_existing=False)
sub = (
UserSubscription.objects.select_related("plan")
.filter(user_id=user.pk)
.first()
)
if sub is None:
sub = get_or_create_user_subscription(user)
plan = sub.plan if sub.is_active else None
window_hours = plan.prompt_window_hours if plan else 6
prompt_quota = plan.prompt_quota_per_window if plan else None
monthly_token_quota = sub.effective_monthly_token_quota() if sub.is_active else None
conversation_ids = _user_conversation_ids(user)
now = timezone.now()
window_start = now - timedelta(hours=window_hours)
period_start, period_end = _billing_period_bounds()
base = PromptMetric.objects.filter(conversation_id__in=conversation_ids)
prompts_in_window = base.filter(created__gte=window_start).count()
period_qs = base.filter(created__gte=period_start, created__lt=period_end)
tokens_in, tokens_out = _sum_tokens(period_qs)
missing = period_qs.filter(
Q(tokens_in__isnull=True) | Q(tokens_out__isnull=True)
).count()
if tokens_in is None and tokens_out is None:
tokens_total = None
else:
tokens_total = (tokens_in or 0) + (tokens_out or 0)
prompts_remaining = None
if prompt_quota is not None:
prompts_remaining = max(prompt_quota - prompts_in_window, 0)
tokens_remaining = None
if monthly_token_quota is not None and tokens_total is not None:
tokens_remaining = max(monthly_token_quota - tokens_total, 0)
elif monthly_token_quota is not None and tokens_total is None:
# No provider usage yet — do not treat as 0 consumed.
tokens_remaining = monthly_token_quota
return UsageSnapshot(
prompts_in_window=prompts_in_window,
prompt_quota=prompt_quota,
prompts_remaining=prompts_remaining,
window_hours=window_hours,
tokens_in_period=tokens_in,
tokens_out_period=tokens_out,
tokens_total_period=tokens_total,
turns_missing_token_usage=missing,
monthly_token_quota=monthly_token_quota,
tokens_remaining=tokens_remaining,
period_start=period_start,
period_end=period_end,
)
def assert_feature_allowed(user, feature: str) -> None:
if not getattr(settings, "ENFORCE_SUBSCRIPTION_GATES", True):
return
seed_subscription_plans(update_existing=False)
sub = (
UserSubscription.objects.select_related("plan")
.filter(user_id=user.pk)
.first()
)
if sub is None or not sub.is_active or sub.plan is None:
raise FeatureNotAllowed(
"subscription_required",
"An active subscription is required to use this feature.",
details={"feature": feature},
)
if not sub.plan.allows_feature(feature):
raise FeatureNotAllowed(
"feature_not_allowed",
f"Your plan ({sub.plan.name}) does not include {feature.replace('_', ' ')}.",
details={
"feature": feature,
"plan": sub.plan.slug,
},
)
def assert_within_quotas(user) -> UsageSnapshot:
"""
Enforce prompt-window (#36) and token-period (#17) limits.
Precedence: either limit may block. Missing provider token usage does not
silently under-count toward a token cap — turns with null tokens are tracked
in `turns_missing_token_usage` and token-cap enforcement only uses reported
sums; if quota is set and usage is entirely unknown, we allow the turn but
surface the gap (callers/admin can tighten later).
"""
seed_subscription_plans(update_existing=False)
sub = (
UserSubscription.objects.select_related("plan")
.filter(user_id=user.pk)
.first()
)
if sub is None or not sub.is_active or sub.plan is None:
raise QuotaExceeded(
"subscription_required",
"An active subscription is required before sending prompts.",
)
usage = get_usage_snapshot(user)
if usage.prompt_quota is not None and usage.prompts_in_window >= usage.prompt_quota:
raise QuotaExceeded(
"prompt_quota_exceeded",
(
f"Prompt limit reached ({usage.prompt_quota} per "
f"{usage.window_hours} hours). Try again later."
),
details=usage.to_dict(),
)
if (
usage.monthly_token_quota is not None
and usage.tokens_total_period is not None
and usage.tokens_total_period >= usage.monthly_token_quota
):
raise QuotaExceeded(
"token_quota_exceeded",
(
f"Monthly token limit reached ({usage.monthly_token_quota}). "
"Upgrade or wait for the next billing period."
),
details=usage.to_dict(),
)
return usage
def check_generation_allowed(user, *, feature: str = "text_generation") -> UsageSnapshot:
"""Combined feature + quota gate for a chat turn."""
if not getattr(settings, "ENFORCE_SUBSCRIPTION_GATES", True):
return get_usage_snapshot(user)
assert_feature_allowed(user, feature)
return assert_within_quotas(user)
+319
View File
@@ -0,0 +1,319 @@
"""RevenueCat store IAP helpers and webhook dispatch (ledger + entitlements)."""
from __future__ import annotations
import logging
from datetime import datetime, timezone as dt_timezone
from typing import Any
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from monetization.models import Invoice, Payment, UserSubscription
from monetization.services.plans import (
assign_plan_from_revenuecat,
get_or_create_user_subscription,
log_subscription_auth_event,
resolve_plan_from_revenuecat_product,
)
logger = logging.getLogger(__name__)
User = get_user_model()
# Events that grant or refresh paid access.
_ACTIVE_EVENT_TYPES = frozenset(
{
"INITIAL_PURCHASE",
"RENEWAL",
"UNCANCELLATION",
"NON_RENEWING_PURCHASE",
"PRODUCT_CHANGE",
"SUBSCRIPTION_EXTENDED",
}
)
# Still entitled until period end (cancel scheduled).
_CANCEL_AT_PERIOD_END_TYPES = frozenset({"CANCELLATION"})
# Access ended / payment problems.
_EXPIRED_EVENT_TYPES = frozenset({"EXPIRATION"})
_BILLING_ISSUE_TYPES = frozenset({"BILLING_ISSUE"})
class RevenueCatWebhookAuthError(ValueError):
"""Invalid or missing RevenueCat webhook Authorization header."""
def verify_revenuecat_authorization(
*,
authorization_header: str | None,
expected_secret: str,
) -> None:
"""Validate ``Authorization: Bearer <secret>`` (or raw secret)."""
if not expected_secret:
raise RevenueCatWebhookAuthError("REVENUECAT_WEBHOOK_SECRET is not configured")
header = (authorization_header or "").strip()
if not header:
raise RevenueCatWebhookAuthError("Missing Authorization header")
token = header
if header.lower().startswith("bearer "):
token = header[7:].strip()
if token != expected_secret:
raise RevenueCatWebhookAuthError("Invalid Authorization token")
def _ms_to_dt(value: int | float | None):
if value is None:
return None
try:
ms = int(value)
except (TypeError, ValueError):
return None
if ms <= 0:
return None
return datetime.fromtimestamp(ms / 1000.0, tz=dt_timezone.utc)
def _price_to_cents(event: dict[str, Any]) -> int:
"""RevenueCat ``price`` is major units in USD; prefer purchased currency."""
raw = event.get("price_in_purchased_currency")
if raw is None:
raw = event.get("price")
try:
return max(0, int(round(float(raw or 0) * 100)))
except (TypeError, ValueError):
return 0
def _resolve_user_from_app_user_id(app_user_id: str | None):
if not app_user_id:
return None
# Prefer numeric PK (what the Capacitor client should send via Purchases.logIn).
try:
return User.objects.get(pk=int(str(app_user_id).strip()))
except (User.DoesNotExist, TypeError, ValueError):
pass
# Fallback: email as app user id.
user = User.objects.filter(email__iexact=str(app_user_id).strip()).first()
if user:
return user
logger.warning("RevenueCat webhook: app_user_id=%s not found", app_user_id)
return None
def _store_label(store: str | None) -> str:
return (store or "").strip().upper()
def _description_for_event(event: dict[str, Any]) -> str:
store = _store_label(event.get("store"))
product = event.get("product_id") or "subscription"
etype = event.get("type") or "purchase"
parts = [f"Store IAP ({store})" if store else "Store IAP", product, etype]
return "".join(p for p in parts if p)
@transaction.atomic
def upsert_invoice_from_revenuecat(
*,
user,
event: dict[str, Any],
status: str,
) -> Invoice:
event_id = event.get("id")
if not event_id:
raise ValueError("RevenueCat event missing id")
amount = _price_to_cents(event)
currency = (event.get("currency") or "usd").lower()
period_start = _ms_to_dt(event.get("purchased_at_ms"))
period_end = _ms_to_dt(event.get("expiration_at_ms"))
amount_paid = amount if status == Invoice.Status.PAID else 0
invoice, _created = Invoice.objects.update_or_create(
revenuecat_event_id=event_id,
defaults={
"user": user,
"company": getattr(user, "company", None),
"provider": Invoice.Provider.REVENUECAT,
"status": status,
"currency": currency,
"amount_due": amount,
"amount_paid": amount_paid,
"period_start": period_start,
"period_end": period_end,
"revenuecat_store": _store_label(event.get("store")),
"description": _description_for_event(event),
"hosted_invoice_url": "",
},
)
return invoice
@transaction.atomic
def upsert_payment_from_revenuecat(
*,
user,
invoice: Invoice | None,
event: dict[str, Any],
status: str,
failure_message: str = "",
) -> Payment | None:
txn_id = event.get("transaction_id") or event.get("id")
if not txn_id:
return None
amount = _price_to_cents(event)
currency = (event.get("currency") or "usd").lower()
paid_at = (
_ms_to_dt(event.get("purchased_at_ms"))
if status == Payment.Status.SUCCEEDED
else None
) or (timezone.now() if status == Payment.Status.SUCCEEDED else None)
payment, _created = Payment.objects.update_or_create(
revenuecat_transaction_id=str(txn_id),
defaults={
"user": user,
"company": getattr(user, "company", None),
"invoice": invoice,
"provider": Payment.Provider.REVENUECAT,
"status": status,
"currency": currency,
"amount": amount,
"paid_at": paid_at,
"failure_message": failure_message or "",
},
)
return payment
def handle_revenuecat_event(event: dict[str, Any]):
"""Apply one RevenueCat ``event`` object: subscription + invoice/payment."""
event_type = (event.get("type") or "").upper()
app_user_id = event.get("app_user_id") or event.get("original_app_user_id")
user = _resolve_user_from_app_user_id(app_user_id)
if user is None:
# TRANSFER may use different fields; still log.
logger.error(
"RevenueCat %s: cannot resolve user app_user_id=%s event=%s",
event_type,
app_user_id,
event.get("id"),
)
return None
product_id = event.get("product_id")
original_txn = (
event.get("original_transaction_id")
or event.get("transaction_id")
or ""
)
period_end = _ms_to_dt(event.get("expiration_at_ms"))
plan = resolve_plan_from_revenuecat_product(product_id)
if event_type in _ACTIVE_EVENT_TYPES:
invoice = upsert_invoice_from_revenuecat(
user=user, event=event, status=Invoice.Status.PAID
)
upsert_payment_from_revenuecat(
user=user,
invoice=invoice,
event=event,
status=Payment.Status.SUCCEEDED,
)
return assign_plan_from_revenuecat(
user,
plan_slug=plan.slug if plan else None,
product_id=product_id,
revenuecat_original_transaction_id=str(original_txn),
status=UserSubscription.Status.ACTIVE,
cancel_at_period_end=False,
current_period_end=period_end,
keep_existing_plan_if_unknown=True,
)
if event_type in _CANCEL_AT_PERIOD_END_TYPES:
# User canceled in store; access continues until expiration.
invoice = upsert_invoice_from_revenuecat(
user=user, event=event, status=Invoice.Status.OPEN
)
return assign_plan_from_revenuecat(
user,
plan_slug=plan.slug if plan else None,
product_id=product_id,
revenuecat_original_transaction_id=str(original_txn),
status=UserSubscription.Status.ACTIVE,
cancel_at_period_end=True,
current_period_end=period_end,
keep_existing_plan_if_unknown=True,
)
if event_type in _BILLING_ISSUE_TYPES:
invoice = upsert_invoice_from_revenuecat(
user=user, event=event, status=Invoice.Status.PAYMENT_FAILED
)
upsert_payment_from_revenuecat(
user=user,
invoice=invoice,
event=event,
status=Payment.Status.FAILED,
failure_message="Store billing issue",
)
return assign_plan_from_revenuecat(
user,
plan_slug=plan.slug if plan else None,
product_id=product_id,
revenuecat_original_transaction_id=str(original_txn),
status=UserSubscription.Status.PAST_DUE,
current_period_end=period_end,
keep_existing_plan_if_unknown=True,
)
if event_type in _EXPIRED_EVENT_TYPES:
upsert_invoice_from_revenuecat(
user=user, event=event, status=Invoice.Status.VOID
)
sub = get_or_create_user_subscription(user)
prev_status = sub.status
sub.status = UserSubscription.Status.CANCELED
sub.cancel_at_period_end = False
if period_end:
sub.current_period_end = period_end
if original_txn:
sub.revenuecat_original_transaction_id = str(original_txn)
if sub.source == UserSubscription.Source.NONE:
sub.source = UserSubscription.Source.REVENUECAT
elif sub.source != UserSubscription.Source.REVENUECAT:
# Only expire if this was a store sub; leave Stripe alone.
if sub.source == UserSubscription.Source.STRIPE:
logger.info(
"Ignoring RC EXPIRATION for Stripe-sourced user=%s", user.pk
)
return sub
sub.source = UserSubscription.Source.REVENUECAT
sub.save()
if prev_status != UserSubscription.Status.CANCELED:
log_subscription_auth_event(
user,
started=False,
detail=(
f"plan={sub.plan.slug if sub.plan_id else 'none'} "
f"source={sub.source} status={sub.status} "
f"revenuecat_original_transaction_id="
f"{sub.revenuecat_original_transaction_id}"
),
)
return sub
logger.info("Ignoring unhandled RevenueCat event type: %s", event_type)
return None
def dispatch_revenuecat_event(payload: dict[str, Any]):
"""Route a verified RevenueCat webhook JSON body."""
event = payload.get("event") if isinstance(payload.get("event"), dict) else payload
if not isinstance(event, dict):
raise ValueError("RevenueCat payload missing event object")
return handle_revenuecat_event(event)
+139
View File
@@ -0,0 +1,139 @@
"""Stripe Checkout, Billing Portal, and webhook dispatch."""
from __future__ import annotations
from typing import Any
import stripe
from django.conf import settings
from monetization.models import Invoice, SubscriptionPlan
from monetization.services.plans import get_plan, seed_subscription_plans
from monetization.services.webhooks import dispatch_stripe_event
__all__ = [
"StripeNotConfiguredError",
"configure_stripe",
"resolve_checkout_plan",
"subscription_line_items",
"create_checkout_session",
"resolve_stripe_customer_id",
"create_billing_portal_session",
"dispatch_stripe_event",
]
class StripeNotConfiguredError(RuntimeError):
"""Raised when Stripe secret key is missing."""
def configure_stripe() -> str:
secret = settings.STRIPE_SECRET_KEY
if not secret:
raise StripeNotConfiguredError(
"STRIPE_SECRET_KEY is not configured. Set it in the environment."
)
stripe.api_key = secret
return secret
def resolve_checkout_plan(plan_slug: str | None = None) -> SubscriptionPlan:
"""Return the plan for Checkout (defaults to public Founders)."""
seed_subscription_plans(update_existing=False)
slug = (plan_slug or SubscriptionPlan.Slug.FOUNDERS).strip().lower()
plan = get_plan(slug)
if plan is None:
raise ValueError(f"Unknown plan: {slug}")
if not plan.is_selectable:
raise ValueError(f"Plan '{plan.slug}' is not available for checkout.")
return plan
def subscription_line_items(plan: SubscriptionPlan) -> list[dict[str, Any]]:
"""Build Checkout line_items from a SubscriptionPlan (or legacy settings)."""
price_id = (plan.stripe_price_id or "").strip() or (
settings.STRIPE_PRICE_ID if plan.slug == SubscriptionPlan.Slug.FOUNDERS else ""
)
if price_id:
return [{"price": price_id, "quantity": 1}]
# Founders without a plan stripe_price_id may still use legacy env amount.
unit_amount = plan.price_cents
product_name = plan.name
currency = plan.currency or settings.SUBSCRIPTION_PRICE_CURRENCY
interval = plan.interval or settings.SUBSCRIPTION_PRICE_INTERVAL
if plan.slug == SubscriptionPlan.Slug.FOUNDERS and not plan.stripe_price_id:
# Keep env overrides working for the live Founders price.
unit_amount = int(
getattr(settings, "SUBSCRIPTION_PRICE_AMOUNT_CENTS", None) or unit_amount
)
product_name = (
getattr(settings, "SUBSCRIPTION_PRODUCT_NAME", None) or product_name
)
return [
{
"price_data": {
"currency": currency,
"unit_amount": unit_amount,
"recurring": {"interval": interval},
"product_data": {"name": product_name},
},
"quantity": 1,
}
]
def create_checkout_session(
*,
user,
success_url: str | None = None,
cancel_url: str | None = None,
plan_slug: str | None = None,
):
"""Create a Stripe Checkout Session for a selectable subscription plan."""
configure_stripe()
plan = resolve_checkout_plan(plan_slug)
metadata = {
"user_id": str(user.pk),
"company_id": str(user.company_id) if user.company_id else "",
"plan_slug": plan.slug,
}
customer_email = getattr(user, "email", None) or None
session = stripe.checkout.Session.create(
mode="subscription",
line_items=subscription_line_items(plan),
success_url=success_url or settings.STRIPE_CHECKOUT_SUCCESS_URL,
cancel_url=cancel_url or settings.STRIPE_CHECKOUT_CANCEL_URL,
customer_email=customer_email,
client_reference_id=str(user.pk),
metadata=metadata,
subscription_data={"metadata": metadata},
)
return session, plan
def resolve_stripe_customer_id(*, user) -> str | None:
"""Return the most recent Stripe customer id stored on the user's invoices."""
return (
Invoice.objects.filter(user=user)
.exclude(stripe_customer_id="")
.order_by("-created")
.values_list("stripe_customer_id", flat=True)
.first()
)
def create_billing_portal_session(
*,
customer_id: str,
return_url: str | None = None,
):
"""Create a Stripe Customer Portal session for plan/payment/cancel management."""
configure_stripe()
return stripe.billing_portal.Session.create(
customer=customer_id,
return_url=return_url or settings.STRIPE_PORTAL_RETURN_URL,
)
+465
View File
@@ -0,0 +1,465 @@
"""Idempotent Stripe webhook handlers that upsert Invoice / Payment rows."""
from __future__ import annotations
import logging
from datetime import datetime, timezone as dt_timezone
from typing import Any
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from monetization.models import Invoice, Payment, UserSubscription
from monetization.services.plans import (
assign_plan_from_stripe,
get_or_create_user_subscription,
log_subscription_auth_event,
resolve_plan_from_stripe_price,
)
logger = logging.getLogger(__name__)
User = get_user_model()
def _stripe_status_to_local(stripe_status: str | None) -> str:
mapping = {
"active": UserSubscription.Status.ACTIVE,
"trialing": UserSubscription.Status.ACTIVE,
"past_due": UserSubscription.Status.PAST_DUE,
"unpaid": UserSubscription.Status.PAST_DUE,
"canceled": UserSubscription.Status.CANCELED,
"incomplete_expired": UserSubscription.Status.CANCELED,
}
return mapping.get((stripe_status or "").lower(), UserSubscription.Status.NONE)
def _plan_slug_from_subscription(subscription: dict[str, Any]) -> str | None:
metadata = subscription.get("metadata") or {}
if metadata.get("plan_slug"):
return metadata.get("plan_slug")
items = (subscription.get("items") or {}).get("data") or []
if not items:
return None
price = (items[0] or {}).get("price") or {}
price_id = price.get("id") if isinstance(price, dict) else None
plan = resolve_plan_from_stripe_price(price_id)
return plan.slug if plan else None
def _user_from_subscription(subscription: dict[str, Any]):
metadata = subscription.get("metadata") or {}
user = _user_from_metadata(metadata)
if user is not None:
return user
sub_id = subscription.get("id")
if sub_id:
existing = (
Invoice.objects.filter(stripe_subscription_id=sub_id)
.select_related("user")
.order_by("-created")
.first()
)
if existing:
return existing.user
local_sub = (
UserSubscription.objects.filter(stripe_subscription_id=sub_id)
.select_related("user")
.first()
)
if local_sub:
return local_sub.user
return None
def _ts_to_dt(value: int | None):
if not value:
return None
return datetime.fromtimestamp(value, tz=dt_timezone.utc)
def _resolve_user(*, user_id: str | None = None, customer_email: str | None = None):
if user_id:
try:
return User.objects.get(pk=int(user_id))
except (User.DoesNotExist, TypeError, ValueError):
logger.warning("Webhook: user_id=%s not found", user_id)
if customer_email:
user = User.objects.filter(email__iexact=customer_email).first()
if user:
return user
logger.warning("Webhook: email=%s not found", customer_email)
return None
def _user_from_metadata(metadata: dict | None, *, email: str | None = None):
metadata = metadata or {}
return _resolve_user(
user_id=metadata.get("user_id") or metadata.get("client_reference_id"),
customer_email=email,
)
@transaction.atomic
def upsert_invoice_from_stripe(
*,
user,
stripe_invoice: dict[str, Any] | None = None,
stripe_checkout_session_id: str | None = None,
stripe_subscription_id: str | None = None,
stripe_customer_id: str | None = None,
status: str,
amount_due: int = 0,
amount_paid: int = 0,
currency: str = "usd",
period_start=None,
period_end=None,
hosted_invoice_url: str = "",
description: str = "",
) -> Invoice:
stripe_invoice_id = None
if stripe_invoice:
stripe_invoice_id = stripe_invoice.get("id")
stripe_subscription_id = (
stripe_subscription_id or stripe_invoice.get("subscription") or None
)
stripe_customer_id = (
stripe_customer_id or stripe_invoice.get("customer") or None
)
amount_due = int(stripe_invoice.get("amount_due") or amount_due or 0)
amount_paid = int(stripe_invoice.get("amount_paid") or amount_paid or 0)
currency = (stripe_invoice.get("currency") or currency or "usd").lower()
period_start = period_start or _ts_to_dt(
(stripe_invoice.get("period_start") or stripe_invoice.get("created"))
)
period_end = period_end or _ts_to_dt(stripe_invoice.get("period_end"))
hosted_invoice_url = (
hosted_invoice_url or stripe_invoice.get("hosted_invoice_url") or ""
)
description = description or stripe_invoice.get("description") or ""
lookup: dict[str, Any] = {}
if stripe_invoice_id:
lookup["stripe_invoice_id"] = stripe_invoice_id
elif stripe_checkout_session_id:
lookup["stripe_checkout_session_id"] = stripe_checkout_session_id
else:
raise ValueError("Need stripe_invoice_id or stripe_checkout_session_id")
defaults = {
"user": user,
"company": getattr(user, "company", None),
"provider": Invoice.Provider.STRIPE,
"status": status,
"currency": currency,
"amount_due": amount_due,
"amount_paid": amount_paid,
"period_start": period_start,
"period_end": period_end,
"stripe_subscription_id": stripe_subscription_id or None,
"stripe_customer_id": stripe_customer_id or "",
"hosted_invoice_url": hosted_invoice_url or "",
"description": description or "",
}
if stripe_invoice_id:
defaults["stripe_invoice_id"] = stripe_invoice_id
if stripe_checkout_session_id:
defaults["stripe_checkout_session_id"] = stripe_checkout_session_id
invoice, _created = Invoice.objects.update_or_create(
**lookup,
defaults=defaults,
)
if stripe_invoice_id and invoice.stripe_invoice_id != stripe_invoice_id:
invoice.stripe_invoice_id = stripe_invoice_id
invoice.save(update_fields=["stripe_invoice_id", "last_modified"])
return invoice
@transaction.atomic
def upsert_payment_from_stripe(
*,
user,
invoice: Invoice | None,
amount: int,
currency: str = "usd",
status: str,
stripe_payment_intent_id: str | None = None,
stripe_charge_id: str | None = None,
paid_at=None,
failure_message: str = "",
) -> Payment:
if not stripe_payment_intent_id and not stripe_charge_id:
raise ValueError("Need stripe_payment_intent_id or stripe_charge_id")
lookup: dict[str, Any] = {}
if stripe_payment_intent_id:
lookup["stripe_payment_intent_id"] = stripe_payment_intent_id
else:
lookup["stripe_charge_id"] = stripe_charge_id
defaults = {
"user": user,
"company": getattr(user, "company", None),
"invoice": invoice,
"provider": Payment.Provider.STRIPE,
"status": status,
"currency": (currency or "usd").lower(),
"amount": int(amount or 0),
"paid_at": paid_at,
"failure_message": failure_message or "",
}
if stripe_payment_intent_id:
defaults["stripe_payment_intent_id"] = stripe_payment_intent_id
if stripe_charge_id:
defaults["stripe_charge_id"] = stripe_charge_id
payment, _created = Payment.objects.update_or_create(
**lookup,
defaults=defaults,
)
return payment
def handle_checkout_session_completed(session: dict[str, Any]) -> Invoice | None:
metadata = session.get("metadata") or {}
customer_details = session.get("customer_details") or {}
user = _user_from_metadata(
metadata,
email=customer_details.get("email") or session.get("customer_email"),
)
if user is None and session.get("client_reference_id"):
user = _resolve_user(user_id=session.get("client_reference_id"))
if user is None:
logger.error(
"checkout.session.completed: cannot resolve user for session %s",
session.get("id"),
)
return None
amount_total = int(session.get("amount_total") or 0)
invoice = upsert_invoice_from_stripe(
user=user,
stripe_checkout_session_id=session.get("id"),
stripe_subscription_id=session.get("subscription") or None,
stripe_customer_id=session.get("customer") or None,
status=(
Invoice.Status.PAID
if session.get("payment_status") == "paid"
else Invoice.Status.OPEN
),
amount_due=amount_total,
amount_paid=amount_total if session.get("payment_status") == "paid" else 0,
currency=(session.get("currency") or "usd").lower(),
description="Subscription checkout",
)
payment_intent = session.get("payment_intent")
if payment_intent and session.get("payment_status") == "paid":
upsert_payment_from_stripe(
user=user,
invoice=invoice,
amount=amount_total,
currency=(session.get("currency") or "usd").lower(),
status=Payment.Status.SUCCEEDED,
stripe_payment_intent_id=(
payment_intent if isinstance(payment_intent, str) else None
),
paid_at=timezone.now(),
)
if session.get("payment_status") == "paid" or session.get("subscription"):
assign_plan_from_stripe(
user,
plan_slug=metadata.get("plan_slug"),
stripe_subscription_id=session.get("subscription") or "",
)
return invoice
def handle_invoice_paid(stripe_invoice: dict[str, Any]) -> Invoice | None:
metadata = stripe_invoice.get("metadata") or {}
user = _user_from_metadata(
metadata,
email=stripe_invoice.get("customer_email"),
)
if user is None:
existing = None
if stripe_invoice.get("id"):
existing = (
Invoice.objects.filter(stripe_invoice_id=stripe_invoice["id"])
.select_related("user")
.first()
)
if existing is None and stripe_invoice.get("subscription"):
existing = (
Invoice.objects.filter(
stripe_subscription_id=stripe_invoice["subscription"]
)
.select_related("user")
.order_by("-created")
.first()
)
if existing:
user = existing.user
if user is None:
logger.error(
"invoice.paid: cannot resolve user for invoice %s",
stripe_invoice.get("id"),
)
return None
invoice = upsert_invoice_from_stripe(
user=user,
stripe_invoice=stripe_invoice,
status=Invoice.Status.PAID,
)
payment_intent = stripe_invoice.get("payment_intent")
charge = stripe_invoice.get("charge")
if payment_intent or charge:
paid_at = _ts_to_dt(
(stripe_invoice.get("status_transitions") or {}).get("paid_at")
) or timezone.now()
upsert_payment_from_stripe(
user=user,
invoice=invoice,
amount=int(stripe_invoice.get("amount_paid") or 0),
currency=(stripe_invoice.get("currency") or "usd").lower(),
status=Payment.Status.SUCCEEDED,
stripe_payment_intent_id=(
payment_intent if isinstance(payment_intent, str) else None
),
stripe_charge_id=charge if isinstance(charge, str) else None,
paid_at=paid_at,
)
assign_plan_from_stripe(
user,
plan_slug=metadata.get("plan_slug"),
stripe_subscription_id=stripe_invoice.get("subscription") or "",
)
return invoice
def handle_invoice_payment_failed(stripe_invoice: dict[str, Any]) -> Invoice | None:
metadata = stripe_invoice.get("metadata") or {}
user = _user_from_metadata(
metadata,
email=stripe_invoice.get("customer_email"),
)
if user is None:
existing = (
Invoice.objects.filter(stripe_invoice_id=stripe_invoice.get("id"))
.select_related("user")
.first()
)
if existing:
user = existing.user
if user is None:
logger.error(
"invoice.payment_failed: cannot resolve user for invoice %s",
stripe_invoice.get("id"),
)
return None
invoice = upsert_invoice_from_stripe(
user=user,
stripe_invoice=stripe_invoice,
status=Invoice.Status.PAYMENT_FAILED,
)
payment_intent = stripe_invoice.get("payment_intent")
if payment_intent:
upsert_payment_from_stripe(
user=user,
invoice=invoice,
amount=int(stripe_invoice.get("amount_due") or 0),
currency=(stripe_invoice.get("currency") or "usd").lower(),
status=Payment.Status.FAILED,
stripe_payment_intent_id=(
payment_intent if isinstance(payment_intent, str) else None
),
failure_message="Stripe invoice payment failed",
)
return invoice
def handle_customer_subscription_updated(subscription: dict[str, Any]):
"""Sync local UserSubscription after portal plan change / cancel schedule."""
user = _user_from_subscription(subscription)
if user is None:
logger.error(
"customer.subscription.updated: cannot resolve user for %s",
subscription.get("id"),
)
return None
local_status = _stripe_status_to_local(subscription.get("status"))
if subscription.get("cancel_at_period_end") and local_status == (
UserSubscription.Status.ACTIVE
):
# Still active until period end; keep ACTIVE and surface cancel flag.
pass
return assign_plan_from_stripe(
user,
plan_slug=_plan_slug_from_subscription(subscription),
stripe_subscription_id=subscription.get("id") or "",
status=local_status or UserSubscription.Status.ACTIVE,
cancel_at_period_end=bool(subscription.get("cancel_at_period_end")),
current_period_end=_ts_to_dt(subscription.get("current_period_end")),
keep_existing_plan_if_unknown=True,
)
def handle_customer_subscription_deleted(subscription: dict[str, Any]):
"""Mark local subscription canceled when Stripe subscription ends."""
user = _user_from_subscription(subscription)
if user is None:
logger.error(
"customer.subscription.deleted: cannot resolve user for %s",
subscription.get("id"),
)
return None
sub = get_or_create_user_subscription(user)
prev_status = sub.status
sub.status = UserSubscription.Status.CANCELED
sub.cancel_at_period_end = False
sub.current_period_end = _ts_to_dt(subscription.get("current_period_end"))
if subscription.get("id"):
sub.stripe_subscription_id = subscription["id"]
# Preserve plan so UI can show what ended; source stays stripe.
if sub.source == UserSubscription.Source.NONE:
sub.source = UserSubscription.Source.STRIPE
sub.save()
if prev_status != UserSubscription.Status.CANCELED:
log_subscription_auth_event(
user,
started=False,
detail=(
f"plan={sub.plan.slug if sub.plan_id else 'none'} "
f"source={sub.source} status={sub.status} "
f"stripe_subscription_id={sub.stripe_subscription_id}"
),
)
return sub
def dispatch_stripe_event(event: dict[str, Any]):
"""Route a verified Stripe event to the appropriate handler."""
event_type = event.get("type")
data_object = (event.get("data") or {}).get("object") or {}
if event_type == "checkout.session.completed":
return handle_checkout_session_completed(data_object)
if event_type == "invoice.paid":
return handle_invoice_paid(data_object)
if event_type == "invoice.payment_failed":
return handle_invoice_payment_failed(data_object)
if event_type == "customer.subscription.updated":
return handle_customer_subscription_updated(data_object)
if event_type == "customer.subscription.deleted":
return handle_customer_subscription_deleted(data_object)
logger.info("Ignoring unhandled Stripe event type: %s", event_type)
return None
+8
View File
@@ -0,0 +1,8 @@
"""Finance app signals."""
def seed_plans_on_migrate(sender, **kwargs):
"""Ensure the subscription catalog exists after migrate."""
from monetization.services.plans import seed_subscription_plans
seed_subscription_plans(update_existing=False)
+122
View File
@@ -0,0 +1,122 @@
"""Tests for Stripe Checkout Session API (mocked Stripe SDK)."""
from unittest.mock import MagicMock, patch
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from chat_backend.tests.factories import make_company, make_user
from monetization.models import Invoice
class CreateCheckoutSessionViewTestCase(APITestCase):
def setUp(self):
self.company = make_company()
self.user = make_user(company=self.company)
self.client.force_authenticate(user=self.user)
self.url = reverse("finance_checkout")
@override_settings(
STRIPE_SECRET_KEY="sk_test_fake",
SUBSCRIPTION_PRICE_AMOUNT_CENTS=1000,
SUBSCRIPTION_PRICE_CURRENCY="usd",
SUBSCRIPTION_PRICE_INTERVAL="month",
SUBSCRIPTION_PRODUCT_NAME="Chat Subscription",
STRIPE_PRICE_ID="",
STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok",
STRIPE_CHECKOUT_CANCEL_URL="http://localhost:3000/cancel",
)
@patch("monetization.services.stripe.stripe.checkout.Session.create")
def test_creates_checkout_session_and_draft_invoice(self, mock_create):
mock_session = MagicMock()
mock_session.id = "cs_test_abc"
mock_session.url = "https://checkout.stripe.com/c/pay/cs_test_abc"
mock_session.customer = None
mock_create.return_value = mock_session
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(
response.data["checkout_url"],
"https://checkout.stripe.com/c/pay/cs_test_abc",
)
self.assertEqual(response.data["session_id"], "cs_test_abc")
mock_create.assert_called_once()
kwargs = mock_create.call_args.kwargs
self.assertEqual(kwargs["mode"], "subscription")
line_item = kwargs["line_items"][0]
self.assertEqual(line_item["price_data"]["unit_amount"], 1000)
self.assertEqual(line_item["price_data"]["currency"], "usd")
self.assertEqual(
line_item["price_data"]["recurring"]["interval"], "month"
)
self.assertEqual(kwargs["metadata"]["user_id"], str(self.user.pk))
invoice = Invoice.objects.get(stripe_checkout_session_id="cs_test_abc")
self.assertEqual(invoice.user, self.user)
self.assertEqual(invoice.company, self.company)
self.assertEqual(invoice.amount_due, 1000)
self.assertEqual(invoice.status, Invoice.Status.OPEN)
@override_settings(STRIPE_SECRET_KEY="", STRIPE_PRICE_ID="")
def test_missing_stripe_key_returns_503(self):
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
def test_unauthenticated_rejected(self):
self.client.force_authenticate(user=None)
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(
STRIPE_SECRET_KEY="sk_test_fake",
STRIPE_PRICE_ID="price_abc123",
STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok",
STRIPE_CHECKOUT_CANCEL_URL="http://localhost:3000/cancel",
)
@patch("monetization.services.stripe.stripe.checkout.Session.create")
def test_uses_stripe_price_id_when_set(self, mock_create):
mock_session = MagicMock()
mock_session.id = "cs_test_price"
mock_session.url = "https://checkout.stripe.com/c/pay/cs_test_price"
mock_session.customer = None
mock_create.return_value = mock_session
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
line_item = mock_create.call_args.kwargs["line_items"][0]
self.assertEqual(line_item, {"price": "price_abc123", "quantity": 1})
class InvoicePaymentListViewTestCase(APITestCase):
def setUp(self):
self.company = make_company()
self.user = make_user(company=self.company)
self.other = make_user(
email="other@test.com",
username="other@test.com",
company=self.company,
)
self.client.force_authenticate(user=self.user)
Invoice.objects.create(
user=self.user,
company=self.company,
amount_due=1000,
stripe_checkout_session_id="cs_mine",
)
Invoice.objects.create(
user=self.other,
company=self.company,
amount_due=1000,
stripe_checkout_session_id="cs_other",
)
def test_list_own_invoices_only(self):
response = self.client.get(reverse("finance_invoices"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["stripe_checkout_session_id"], "cs_mine")
+66
View File
@@ -0,0 +1,66 @@
"""Tests for finance Invoice / Payment models and admin registration."""
from django.contrib import admin
from django.test import TestCase
from chat_backend.tests.factories import make_company, make_user
from monetization.models import Invoice, Payment
class InvoicePaymentModelTestCase(TestCase):
def setUp(self):
self.company = make_company()
self.user = make_user(company=self.company)
def test_create_invoice_and_payment(self):
invoice = Invoice.objects.create(
user=self.user,
company=self.company,
status=Invoice.Status.OPEN,
amount_due=1000,
currency="usd",
stripe_checkout_session_id="cs_test_1",
)
payment = Payment.objects.create(
user=self.user,
company=self.company,
invoice=invoice,
amount=1000,
currency="usd",
status=Payment.Status.PENDING,
stripe_payment_intent_id="pi_test_1",
)
self.assertEqual(invoice.provider, Invoice.Provider.STRIPE)
self.assertEqual(payment.invoice_id, invoice.pk)
self.assertEqual(Invoice.objects.count(), 1)
self.assertEqual(Payment.objects.count(), 1)
def test_mark_payment_succeeded(self):
payment = Payment.objects.create(
user=self.user,
amount=1000,
stripe_payment_intent_id="pi_test_2",
)
payment.mark_succeeded()
payment.refresh_from_db()
self.assertEqual(payment.status, Payment.Status.SUCCEEDED)
self.assertIsNotNone(payment.paid_at)
def test_unique_stripe_checkout_session_id(self):
Invoice.objects.create(
user=self.user,
stripe_checkout_session_id="cs_unique",
amount_due=1000,
)
with self.assertRaises(Exception):
Invoice.objects.create(
user=self.user,
stripe_checkout_session_id="cs_unique",
amount_due=1000,
)
class FinanceAdminRegistrationTestCase(TestCase):
def test_invoice_and_payment_registered(self):
self.assertIn(Invoice, admin.site._registry)
self.assertIn(Payment, admin.site._registry)
@@ -0,0 +1,296 @@
"""Tests for multi-plan catalog, Backer whitelist, quotas, and subscription API."""
from datetime import timedelta
from unittest.mock import MagicMock, patch
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
from chat_backend.models import PromptMetric
from chat_backend.tests.factories import make_company, make_conversation, make_user
from monetization.models import BackerEmail, SubscriptionPlan, UserSubscription
from monetization.services.plans import (
assign_plan,
needs_checkout,
seed_subscription_plans,
try_redeem_backer_email,
)
from monetization.services.quotas import (
FeatureNotAllowed,
QuotaExceeded,
assert_feature_allowed,
assert_within_quotas,
get_usage_snapshot,
)
class PlanCatalogTestCase(TestCase):
def test_seed_creates_expected_plans(self):
plans = {p.slug: p for p in seed_subscription_plans()}
self.assertEqual(
set(plans),
{"founders", "standard", "pro", "business", "backer"},
)
self.assertTrue(plans["founders"].is_public)
self.assertTrue(plans["founders"].is_selectable)
self.assertEqual(plans["founders"].price_cents, 1000)
self.assertEqual(plans["founders"].prompt_quota_per_window, 300)
self.assertFalse(plans["standard"].is_public)
self.assertEqual(plans["standard"].price_cents, 1500)
self.assertEqual(plans["standard"].prompt_quota_per_window, 100)
self.assertFalse(plans["standard"].allows_image_generation)
self.assertEqual(plans["pro"].price_cents, 4000)
self.assertEqual(plans["pro"].prompt_quota_per_window, 200)
self.assertTrue(plans["pro"].allows_image_generation)
self.assertEqual(plans["business"].price_cents, 9900)
self.assertEqual(plans["business"].prompt_quota_per_window, 300)
self.assertEqual(plans["backer"].price_cents, 0)
self.assertFalse(plans["backer"].is_selectable)
self.assertTrue(plans["backer"].allows_all_future_features)
def test_seed_allows_rag_matrix(self):
"""#43: RAG is gated per-plan — standard is the only tier without it."""
plans = {p.slug: p for p in seed_subscription_plans()}
self.assertTrue(plans["founders"].allows_rag)
self.assertFalse(plans["standard"].allows_rag)
self.assertTrue(plans["pro"].allows_rag)
self.assertTrue(plans["business"].allows_rag)
self.assertTrue(plans["backer"].allows_rag)
def test_allows_feature_recognizes_rag_aliases(self):
plans = {p.slug: p for p in seed_subscription_plans()}
self.assertTrue(plans["pro"].allows_feature("rag"))
self.assertTrue(plans["pro"].allows_feature("document_rag"))
self.assertFalse(plans["standard"].allows_feature("rag"))
self.assertFalse(plans["standard"].allows_feature("document_rag"))
class BackerRedeemTestCase(TestCase):
def setUp(self):
seed_subscription_plans()
self.company = make_company()
def test_redeem_assigns_backer_and_skips_checkout(self):
BackerEmail.objects.create(email="backer@example.com")
user = make_user(email="backer@example.com", company=self.company)
sub = try_redeem_backer_email(user)
self.assertIsNotNone(sub)
self.assertEqual(sub.plan.slug, "backer")
self.assertEqual(sub.source, UserSubscription.Source.BACKER)
self.assertFalse(needs_checkout(user))
entry = BackerEmail.objects.get(email="backer@example.com")
self.assertIsNotNone(entry.redeemed_at)
self.assertEqual(entry.redeemed_user_id, user.pk)
def test_redeem_is_one_shot(self):
BackerEmail.objects.create(email="once@example.com")
user = make_user(email="once@example.com", company=self.company)
self.assertIsNotNone(try_redeem_backer_email(user))
self.assertIsNone(try_redeem_backer_email(user))
@override_settings(ENFORCE_SUBSCRIPTION_GATES=True)
class QuotaGateTestCase(TestCase):
def setUp(self):
seed_subscription_plans()
self.company = make_company()
self.user = make_user(company=self.company)
self.plan = SubscriptionPlan.objects.get(slug="standard")
assign_plan(
self.user,
plan=self.plan,
source=UserSubscription.Source.ADMIN,
)
self.conversation = make_conversation(user=self.user)
def _add_metrics(self, count, *, tokens_in=None, tokens_out=None):
now = timezone.now()
for i in range(count):
PromptMetric.objects.create(
prompt_id=1000 + i,
conversation_id=self.conversation.id,
start_time=now,
prompt_length=10,
tokens_in=tokens_in,
tokens_out=tokens_out,
has_file=False,
model_name="test",
)
def test_prompt_quota_blocks(self):
self._add_metrics(100)
with self.assertRaises(QuotaExceeded) as ctx:
assert_within_quotas(self.user)
self.assertEqual(ctx.exception.code, "prompt_quota_exceeded")
def test_feature_gate_blocks_image_on_standard(self):
with self.assertRaises(FeatureNotAllowed) as ctx:
assert_feature_allowed(self.user, "image_generation")
self.assertEqual(ctx.exception.code, "feature_not_allowed")
def test_pro_allows_image(self):
pro = SubscriptionPlan.objects.get(slug="pro")
assign_plan(
self.user, plan=pro, source=UserSubscription.Source.ADMIN
)
assert_feature_allowed(self.user, "image_generation")
def test_business_allows_rag(self):
business = SubscriptionPlan.objects.get(slug="business")
assign_plan(self.user, plan=business, source=UserSubscription.Source.ADMIN)
assert_feature_allowed(self.user, "rag")
def test_feature_gate_blocks_rag_on_standard(self):
with self.assertRaises(FeatureNotAllowed) as ctx:
assert_feature_allowed(self.user, "rag")
self.assertEqual(ctx.exception.code, "feature_not_allowed")
def test_pro_allows_rag(self):
pro = SubscriptionPlan.objects.get(slug="pro")
assign_plan(
self.user, plan=pro, source=UserSubscription.Source.ADMIN
)
assert_feature_allowed(self.user, "rag")
def test_token_quota_blocks_when_reported(self):
self.plan.monthly_token_quota = 50
self.plan.prompt_quota_per_window = 1000
self.plan.save()
self._add_metrics(1, tokens_in=30, tokens_out=30)
with self.assertRaises(QuotaExceeded) as ctx:
assert_within_quotas(self.user)
self.assertEqual(ctx.exception.code, "token_quota_exceeded")
def test_null_tokens_do_not_fabricate_zero_usage(self):
self._add_metrics(3, tokens_in=None, tokens_out=None)
usage = get_usage_snapshot(self.user)
self.assertIsNone(usage.tokens_in_period)
self.assertIsNone(usage.tokens_out_period)
self.assertIsNone(usage.tokens_total_period)
self.assertGreaterEqual(usage.turns_missing_token_usage, 3)
class PlanListAndSubscriptionApiTestCase(APITestCase):
def setUp(self):
seed_subscription_plans()
self.company = make_company()
self.user = make_user(company=self.company)
def test_public_plans_only_founders(self):
url = reverse("finance_plans")
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
slugs = [row["slug"] for row in response.data]
self.assertEqual(slugs, ["founders"])
def test_subscription_me_includes_usage_nulls(self):
self.client.force_authenticate(user=self.user)
founders = SubscriptionPlan.objects.get(slug="founders")
assign_plan(
self.user,
plan=founders,
source=UserSubscription.Source.STRIPE,
)
url = reverse("finance_subscription_me")
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["plan"]["slug"], "founders")
self.assertFalse(response.data["needs_checkout"])
self.assertIsNone(response.data["usage"]["tokens_in_period"])
self.assertEqual(response.data["usage"]["prompt_quota"], 300)
class CheckoutUsesFoundersPlanTestCase(APITestCase):
def setUp(self):
seed_subscription_plans()
self.company = make_company()
self.user = make_user(company=self.company)
self.client.force_authenticate(user=self.user)
self.url = reverse("finance_checkout")
@override_settings(
STRIPE_SECRET_KEY="sk_test_fake",
SUBSCRIPTION_PRICE_AMOUNT_CENTS=1000,
SUBSCRIPTION_PRICE_CURRENCY="usd",
SUBSCRIPTION_PRICE_INTERVAL="month",
SUBSCRIPTION_PRODUCT_NAME="Founders",
STRIPE_PRICE_ID="",
STRIPE_CHECKOUT_SUCCESS_URL="http://localhost:3000/ok",
STRIPE_CHECKOUT_CANCEL_URL="http://localhost:3000/cancel",
)
@patch("monetization.services.stripe.stripe.checkout.Session.create")
def test_checkout_defaults_to_founders(self, mock_create):
mock_session = MagicMock()
mock_session.id = "cs_test_founders"
mock_session.url = "https://checkout.stripe.com/c/pay/cs_test_founders"
mock_session.customer = None
mock_create.return_value = mock_session
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["plan_slug"], "founders")
kwargs = mock_create.call_args.kwargs
self.assertEqual(kwargs["metadata"]["plan_slug"], "founders")
self.assertEqual(
kwargs["line_items"][0]["price_data"]["unit_amount"], 1000
)
def test_backer_cannot_checkout(self):
backer = SubscriptionPlan.objects.get(slug="backer")
assign_plan(
self.user, plan=backer, source=UserSubscription.Source.BACKER
)
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse(response.data["needs_checkout"])
class TokenSerializerApiTestCase(APITestCase):
def setUp(self):
self.company = make_company()
self.user = make_user(company=self.company)
self.client.force_authenticate(user=self.user)
self.conversation = make_conversation(user=self.user, title="Tok")
def test_conversation_tokens_null_when_unreported(self):
PromptMetric.objects.create(
prompt_id=1,
conversation_id=self.conversation.id,
start_time=timezone.now(),
prompt_length=5,
tokens_in=None,
tokens_out=None,
has_file=False,
model_name="t",
)
response = self.client.get(reverse("conversations"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
row = next(r for r in response.data if r["id"] == self.conversation.id)
self.assertIsNone(row["tokens_in"])
self.assertIsNone(row["tokens_out"])
def test_conversation_tokens_sum_when_reported(self):
for tin, tout, pid in ((10, 20, 1), (5, None, 2), (None, 7, 3)):
PromptMetric.objects.create(
prompt_id=pid,
conversation_id=self.conversation.id,
start_time=timezone.now(),
prompt_length=5,
tokens_in=tin,
tokens_out=tout,
has_file=False,
model_name="t",
)
response = self.client.get(reverse("conversations"))
row = next(r for r in response.data if r["id"] == self.conversation.id)
self.assertEqual(row["tokens_in"], 15)
self.assertEqual(row["tokens_out"], 27)
+102
View File
@@ -0,0 +1,102 @@
"""Tests for Stripe Customer Portal session API (mocked Stripe SDK)."""
from unittest.mock import MagicMock, patch
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from chat_backend.tests.factories import make_company, make_user
from monetization.models import Invoice
class CreateBillingPortalSessionViewTestCase(APITestCase):
def setUp(self):
self.company = make_company()
self.user = make_user(company=self.company)
self.client.force_authenticate(user=self.user)
self.url = reverse("finance_portal")
def _create_invoice_with_customer(self, customer_id="cus_test_abc"):
return Invoice.objects.create(
user=self.user,
company=self.company,
amount_due=1000,
amount_paid=1000,
status=Invoice.Status.PAID,
stripe_checkout_session_id="cs_portal_test",
stripe_customer_id=customer_id,
stripe_subscription_id="sub_test_abc",
description="Chat Subscription",
)
@override_settings(
STRIPE_SECRET_KEY="sk_test_fake",
STRIPE_PORTAL_RETURN_URL="http://localhost:3000/account/",
)
@patch("monetization.services.stripe.stripe.billing_portal.Session.create")
def test_creates_portal_session(self, mock_create):
self._create_invoice_with_customer()
mock_session = MagicMock()
mock_session.url = "https://billing.stripe.com/p/session/test_portal"
mock_create.return_value = mock_session
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(
response.data["portal_url"],
"https://billing.stripe.com/p/session/test_portal",
)
mock_create.assert_called_once_with(
customer="cus_test_abc",
return_url="http://localhost:3000/account/",
)
@override_settings(
STRIPE_SECRET_KEY="sk_test_fake",
STRIPE_PORTAL_RETURN_URL="http://localhost:3000/account/",
)
@patch("monetization.services.stripe.stripe.billing_portal.Session.create")
def test_accepts_custom_return_url(self, mock_create):
self._create_invoice_with_customer()
mock_session = MagicMock()
mock_session.url = "https://billing.stripe.com/p/session/custom"
mock_create.return_value = mock_session
response = self.client.post(
self.url,
{"return_url": "http://localhost:3000/account/#billing"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(
mock_create.call_args.kwargs["return_url"],
"http://localhost:3000/account/#billing",
)
@override_settings(STRIPE_SECRET_KEY="sk_test_fake")
def test_no_stripe_customer_returns_400(self):
Invoice.objects.create(
user=self.user,
company=self.company,
amount_due=1000,
stripe_checkout_session_id="cs_no_customer",
stripe_customer_id="",
)
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("No Stripe customer", response.data["detail"])
@override_settings(STRIPE_SECRET_KEY="")
def test_missing_stripe_key_returns_503(self):
self._create_invoice_with_customer()
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
def test_unauthenticated_rejected(self):
self.client.force_authenticate(user=None)
response = self.client.post(self.url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@@ -0,0 +1,178 @@
"""Tests for RevenueCat webhook auth, ledger upserts, and plan assignment."""
from __future__ import annotations
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from chat_backend.tests.factories import make_user
from monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription
from monetization.services.plans import seed_subscription_plans
from monetization.services.revenuecat import (
RevenueCatWebhookAuthError,
dispatch_revenuecat_event,
verify_revenuecat_authorization,
)
def _rc_event(**overrides):
base = {
"id": "rc_evt_1",
"type": "INITIAL_PURCHASE",
"app_user_id": "1",
"product_id": "hesychia_founders_monthly",
"store": "PLAY_STORE",
"price": 10.0,
"currency": "USD",
"purchased_at_ms": 1_700_000_000_000,
"expiration_at_ms": 1_702_592_000_000,
"transaction_id": "GPA.1234",
"original_transaction_id": "GPA.1234",
}
base.update(overrides)
return base
class RevenueCatAuthTests(TestCase):
def test_bearer_token_ok(self):
verify_revenuecat_authorization(
authorization_header="Bearer secret-token",
expected_secret="secret-token",
)
def test_raw_token_ok(self):
verify_revenuecat_authorization(
authorization_header="secret-token",
expected_secret="secret-token",
)
def test_bad_token(self):
with self.assertRaises(RevenueCatWebhookAuthError):
verify_revenuecat_authorization(
authorization_header="Bearer nope",
expected_secret="secret-token",
)
class RevenueCatDispatchTests(TestCase):
def setUp(self):
seed_subscription_plans(update_existing=False)
self.user = make_user(email="rc@example.com")
founders = SubscriptionPlan.objects.get(slug="founders")
founders.revenuecat_product_id = "hesychia_founders_monthly"
founders.save(update_fields=["revenuecat_product_id", "last_modified"])
def test_initial_purchase_assigns_plan_and_ledger(self):
event = _rc_event(app_user_id=str(self.user.pk))
dispatch_revenuecat_event({"api_version": "1.0", "event": event})
sub = UserSubscription.objects.get(user=self.user)
self.assertEqual(sub.source, UserSubscription.Source.REVENUECAT)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
self.assertEqual(sub.plan.slug, "founders")
self.assertEqual(sub.revenuecat_original_transaction_id, "GPA.1234")
invoice = Invoice.objects.get(revenuecat_event_id="rc_evt_1")
self.assertEqual(invoice.provider, Invoice.Provider.REVENUECAT)
self.assertEqual(invoice.status, Invoice.Status.PAID)
self.assertEqual(invoice.amount_paid, 1000)
self.assertEqual(invoice.revenuecat_store, "PLAY_STORE")
self.assertIn("PLAY_STORE", invoice.description)
payment = Payment.objects.get(revenuecat_transaction_id="GPA.1234")
self.assertEqual(payment.provider, Payment.Provider.REVENUECAT)
self.assertEqual(payment.status, Payment.Status.SUCCEEDED)
self.assertEqual(payment.invoice_id, invoice.pk)
def test_idempotent_replay(self):
event = _rc_event(app_user_id=str(self.user.pk))
dispatch_revenuecat_event({"event": event})
dispatch_revenuecat_event({"event": event})
self.assertEqual(Invoice.objects.filter(user=self.user).count(), 1)
self.assertEqual(Payment.objects.filter(user=self.user).count(), 1)
def test_cancellation_sets_cancel_at_period_end(self):
dispatch_revenuecat_event(
{"event": _rc_event(app_user_id=str(self.user.pk))}
)
dispatch_revenuecat_event(
{
"event": _rc_event(
id="rc_evt_cancel",
type="CANCELLATION",
app_user_id=str(self.user.pk),
transaction_id="GPA.999",
)
}
)
sub = UserSubscription.objects.get(user=self.user)
self.assertTrue(sub.cancel_at_period_end)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
def test_expiration_cancels(self):
dispatch_revenuecat_event(
{"event": _rc_event(app_user_id=str(self.user.pk))}
)
dispatch_revenuecat_event(
{
"event": _rc_event(
id="rc_evt_exp",
type="EXPIRATION",
app_user_id=str(self.user.pk),
transaction_id="GPA.exp",
)
}
)
sub = UserSubscription.objects.get(user=self.user)
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
@override_settings(REVENUECAT_WEBHOOK_SECRET="test-rc-secret")
class RevenueCatWebhookViewTests(TestCase):
def setUp(self):
seed_subscription_plans(update_existing=False)
self.client = APIClient()
self.url = reverse("finance_revenuecat_webhook")
self.user = make_user(email="rcview@example.com")
founders = SubscriptionPlan.objects.get(slug="founders")
founders.revenuecat_product_id = "hesychia_founders_monthly"
founders.save(update_fields=["revenuecat_product_id", "last_modified"])
def test_missing_secret_config(self):
with override_settings(REVENUECAT_WEBHOOK_SECRET=""):
response = self.client.post(
self.url, {"event": _rc_event()}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
def test_unauthorized(self):
response = self.client.post(
self.url,
{"event": _rc_event(app_user_id=str(self.user.pk))},
format="json",
HTTP_AUTHORIZATION="Bearer wrong",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_success(self):
response = self.client.post(
self.url,
{"api_version": "1.0", "event": _rc_event(app_user_id=str(self.user.pk))},
format="json",
HTTP_AUTHORIZATION="Bearer test-rc-secret",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(
UserSubscription.objects.filter(
user=self.user,
source=UserSubscription.Source.REVENUECAT,
status=UserSubscription.Status.ACTIVE,
).exists()
)
self.assertTrue(
Invoice.objects.filter(
user=self.user, provider=Invoice.Provider.REVENUECAT
).exists()
)
+238
View File
@@ -0,0 +1,238 @@
"""Tests for Stripe webhook verification and ledger upserts."""
from unittest.mock import patch
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from chat_backend.tests.factories import make_company, make_user
from chat_backend.models import UserAuthEvent
from monetization.models import Invoice, Payment, UserSubscription
from monetization.services.plans import assign_plan_from_stripe, seed_subscription_plans
from monetization.services.webhooks import (
dispatch_stripe_event,
handle_checkout_session_completed,
handle_customer_subscription_deleted,
handle_customer_subscription_updated,
handle_invoice_paid,
handle_invoice_payment_failed,
)
class WebhookHandlerUnitTestCase(APITestCase):
def setUp(self):
self.company = make_company()
self.user = make_user(company=self.company)
def test_checkout_session_completed_creates_invoice_and_payment(self):
session = {
"id": "cs_test_completed",
"metadata": {"user_id": str(self.user.pk)},
"customer": "cus_123",
"subscription": "sub_123",
"payment_intent": "pi_123",
"payment_status": "paid",
"amount_total": 1000,
"currency": "usd",
"customer_email": self.user.email,
}
invoice = handle_checkout_session_completed(session)
self.assertIsNotNone(invoice)
self.assertEqual(invoice.status, Invoice.Status.PAID)
self.assertEqual(invoice.amount_paid, 1000)
self.assertEqual(invoice.stripe_subscription_id, "sub_123")
payment = Payment.objects.get(stripe_payment_intent_id="pi_123")
self.assertEqual(payment.status, Payment.Status.SUCCEEDED)
self.assertEqual(payment.invoice_id, invoice.pk)
def test_checkout_session_completed_is_idempotent(self):
session = {
"id": "cs_test_idem",
"metadata": {"user_id": str(self.user.pk)},
"payment_status": "paid",
"amount_total": 1000,
"currency": "usd",
"payment_intent": "pi_idem",
}
handle_checkout_session_completed(session)
handle_checkout_session_completed(session)
self.assertEqual(
Invoice.objects.filter(stripe_checkout_session_id="cs_test_idem").count(),
1,
)
self.assertEqual(
Payment.objects.filter(stripe_payment_intent_id="pi_idem").count(),
1,
)
def test_invoice_paid_upserts(self):
stripe_invoice = {
"id": "in_paid_1",
"metadata": {"user_id": str(self.user.pk)},
"customer": "cus_1",
"subscription": "sub_1",
"amount_due": 1000,
"amount_paid": 1000,
"currency": "usd",
"status": "paid",
"payment_intent": "pi_paid_1",
"charge": "ch_paid_1",
"period_start": 1_700_000_000,
"period_end": 1_700_259_200,
"hosted_invoice_url": "https://invoice.stripe.com/i/test",
"status_transitions": {"paid_at": 1_700_000_100},
}
invoice = handle_invoice_paid(stripe_invoice)
self.assertEqual(invoice.status, Invoice.Status.PAID)
self.assertEqual(invoice.stripe_invoice_id, "in_paid_1")
payment = Payment.objects.get(stripe_payment_intent_id="pi_paid_1")
self.assertEqual(payment.stripe_charge_id, "ch_paid_1")
self.assertEqual(payment.status, Payment.Status.SUCCEEDED)
def test_invoice_payment_failed(self):
stripe_invoice = {
"id": "in_fail_1",
"metadata": {"user_id": str(self.user.pk)},
"amount_due": 1000,
"amount_paid": 0,
"currency": "usd",
"payment_intent": "pi_fail_1",
}
invoice = handle_invoice_payment_failed(stripe_invoice)
self.assertEqual(invoice.status, Invoice.Status.PAYMENT_FAILED)
payment = Payment.objects.get(stripe_payment_intent_id="pi_fail_1")
self.assertEqual(payment.status, Payment.Status.FAILED)
def test_dispatch_ignores_unknown_events(self):
result = dispatch_stripe_event(
{"type": "customer.created", "data": {"object": {}}}
)
self.assertIsNone(result)
def test_checkout_assigns_plan_from_metadata(self):
seed_subscription_plans(update_existing=False)
session = {
"id": "cs_test_plan_meta",
"metadata": {"user_id": str(self.user.pk), "plan_slug": "founders"},
"customer": "cus_meta",
"subscription": "sub_meta",
"payment_intent": "pi_meta",
"payment_status": "paid",
"amount_total": 1000,
"currency": "usd",
}
handle_checkout_session_completed(session)
sub = UserSubscription.objects.get(user=self.user)
self.assertEqual(sub.plan.slug, "founders")
self.assertEqual(sub.source, UserSubscription.Source.STRIPE)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
started = UserAuthEvent.objects.get(
user=self.user,
event_type=UserAuthEvent.EventType.SUBSCRIPTION_STARTED,
)
self.assertIn("founders", started.detail)
def test_subscription_updated_sets_cancel_at_period_end(self):
seed_subscription_plans(update_existing=False)
assign_plan_from_stripe(
self.user,
plan_slug="founders",
stripe_subscription_id="sub_cancel",
)
result = handle_customer_subscription_updated(
{
"id": "sub_cancel",
"status": "active",
"cancel_at_period_end": True,
"current_period_end": 1_700_259_200,
"metadata": {"user_id": str(self.user.pk), "plan_slug": "founders"},
}
)
self.assertIsNotNone(result)
sub = UserSubscription.objects.get(user=self.user)
self.assertTrue(sub.cancel_at_period_end)
self.assertEqual(sub.status, UserSubscription.Status.ACTIVE)
self.assertIsNotNone(sub.current_period_end)
updated = UserAuthEvent.objects.filter(
user=self.user,
event_type=UserAuthEvent.EventType.SUBSCRIPTION_UPDATED,
).latest("created")
self.assertIn("cancel_at_period_end=True", updated.detail)
def test_subscription_deleted_marks_canceled(self):
seed_subscription_plans(update_existing=False)
assign_plan_from_stripe(
self.user,
plan_slug="founders",
stripe_subscription_id="sub_gone",
)
result = handle_customer_subscription_deleted(
{
"id": "sub_gone",
"status": "canceled",
"current_period_end": 1_700_259_200,
"metadata": {"user_id": str(self.user.pk)},
}
)
self.assertIsNotNone(result)
sub = UserSubscription.objects.get(user=self.user)
self.assertEqual(sub.status, UserSubscription.Status.CANCELED)
self.assertFalse(sub.cancel_at_period_end)
updated = UserAuthEvent.objects.filter(
user=self.user,
event_type=UserAuthEvent.EventType.SUBSCRIPTION_UPDATED,
).latest("created")
self.assertIn("status=canceled", updated.detail)
class StripeWebhookViewTestCase(APITestCase):
def setUp(self):
self.url = reverse("finance_stripe_webhook")
self.company = make_company()
self.user = make_user(company=self.company)
@override_settings(STRIPE_WEBHOOK_SECRET="")
def test_missing_webhook_secret_returns_503(self):
response = self.client.post(
self.url,
data=b"{}",
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
@override_settings(STRIPE_WEBHOOK_SECRET="whsec_test")
@patch("monetization.views.stripe.Webhook.construct_event")
def test_invalid_signature_returns_400(self, mock_construct):
import stripe
mock_construct.side_effect = stripe.SignatureVerificationError(
"bad sig", "sig_header"
)
response = self.client.post(
self.url,
data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=1,v1=bad",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@override_settings(STRIPE_WEBHOOK_SECRET="whsec_test")
@patch("monetization.views.dispatch_stripe_event")
@patch("monetization.views.stripe.Webhook.construct_event")
def test_valid_event_dispatched(self, mock_construct, mock_dispatch):
mock_construct.return_value = {
"id": "evt_1",
"type": "checkout.session.completed",
"data": {"object": {"id": "cs_x"}},
}
response = self.client.post(
self.url,
data=b'{"id":"evt_1"}',
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=1,v1=good",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data["received"])
mock_dispatch.assert_called_once()
+55
View File
@@ -0,0 +1,55 @@
from django.urls import path
from monetization.views import (
CreateBillingPortalSessionView,
CreateCheckoutSessionView,
InvoiceListView,
PaymentListView,
PlanListView,
RevenueCatWebhookView,
StripeWebhookView,
SubscriptionMeView,
)
urlpatterns = [
path(
"checkout/",
CreateCheckoutSessionView.as_view(),
name="finance_checkout",
),
path(
"portal/",
CreateBillingPortalSessionView.as_view(),
name="finance_portal",
),
path(
"invoices/",
InvoiceListView.as_view(),
name="finance_invoices",
),
path(
"payments/",
PaymentListView.as_view(),
name="finance_payments",
),
path(
"plans/",
PlanListView.as_view(),
name="finance_plans",
),
path(
"subscription/",
SubscriptionMeView.as_view(),
name="finance_subscription_me",
),
path(
"webhooks/stripe/",
StripeWebhookView.as_view(),
name="finance_stripe_webhook",
),
path(
"webhooks/revenuecat/",
RevenueCatWebhookView.as_view(),
name="finance_revenuecat_webhook",
),
]
+315
View File
@@ -0,0 +1,315 @@
import logging
import stripe
from django.conf import settings
from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from monetization.models import Invoice, Payment, SubscriptionPlan, UserSubscription
from monetization.serializers import (
CheckoutSessionSerializer,
InvoiceSerializer,
PaymentSerializer,
PortalSessionSerializer,
SubscriptionPlanSerializer,
)
from monetization.services.plans import (
needs_checkout,
plan_to_dict,
seed_subscription_plans,
)
from monetization.services.quotas import get_usage_snapshot
from monetization.services.revenuecat import (
RevenueCatWebhookAuthError,
dispatch_revenuecat_event,
verify_revenuecat_authorization,
)
from monetization.services.stripe import (
StripeNotConfiguredError,
create_billing_portal_session,
create_checkout_session,
dispatch_stripe_event,
resolve_stripe_customer_id,
)
logger = logging.getLogger(__name__)
class CreateCheckoutSessionView(APIView):
"""Create a Stripe Checkout Session and return the hosted redirect URL."""
def post(self, request):
serializer = CheckoutSessionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
sub = request.user.subscription
except UserSubscription.DoesNotExist:
sub = None
if (
sub is not None
and sub.is_active
and sub.source == UserSubscription.Source.BACKER
):
return Response(
{
"detail": (
"This account has complimentary Backer access and "
"does not require payment."
),
"needs_checkout": False,
},
status=status.HTTP_400_BAD_REQUEST,
)
try:
session, plan = create_checkout_session(
user=request.user,
success_url=serializer.validated_data.get("success_url"),
cancel_url=serializer.validated_data.get("cancel_url"),
plan_slug=serializer.validated_data.get("plan_slug"),
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except StripeNotConfiguredError as exc:
return Response(
{"detail": str(exc)},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
except stripe.StripeError as exc:
logger.exception("Stripe Checkout Session creation failed")
return Response(
{"detail": str(getattr(exc, "user_message", None) or exc)},
status=status.HTTP_502_BAD_GATEWAY,
)
# Persist a draft invoice keyed by checkout session for admin visibility
# before the webhook fires.
Invoice.objects.update_or_create(
stripe_checkout_session_id=session.id,
defaults={
"user": request.user,
"company": request.user.company,
"provider": Invoice.Provider.STRIPE,
"status": Invoice.Status.OPEN,
"currency": plan.currency or settings.SUBSCRIPTION_PRICE_CURRENCY,
"amount_due": plan.price_cents,
"amount_paid": 0,
"stripe_customer_id": getattr(session, "customer", None) or "",
"description": plan.name,
},
)
return Response(
{
"checkout_url": session.url,
"session_id": session.id,
"plan_slug": plan.slug,
},
status=status.HTTP_201_CREATED,
)
class CreateBillingPortalSessionView(APIView):
"""Create a Stripe Customer Portal session and return the hosted URL."""
def post(self, request):
serializer = PortalSessionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
customer_id = resolve_stripe_customer_id(user=request.user)
if not customer_id:
return Response(
{
"detail": (
"No Stripe customer found for this account. "
"Complete Checkout first to manage billing."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
try:
session = create_billing_portal_session(
customer_id=customer_id,
return_url=serializer.validated_data.get("return_url"),
)
except StripeNotConfiguredError as exc:
return Response(
{"detail": str(exc)},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
except stripe.StripeError as exc:
logger.exception("Stripe Billing Portal Session creation failed")
return Response(
{"detail": str(getattr(exc, "user_message", None) or exc)},
status=status.HTTP_502_BAD_GATEWAY,
)
return Response(
{"portal_url": session.url},
status=status.HTTP_201_CREATED,
)
class InvoiceListView(APIView):
def get(self, request):
invoices = Invoice.objects.filter(user=request.user)
return Response(InvoiceSerializer(invoices, many=True).data)
class PaymentListView(APIView):
def get(self, request):
payments = Payment.objects.filter(user=request.user)
return Response(PaymentSerializer(payments, many=True).data)
class PlanListView(APIView):
"""Public-facing plan catalog (only `is_public` rows by default)."""
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
def get(self, request):
seed_subscription_plans(update_existing=False)
include_all = (
request.user
and request.user.is_authenticated
and request.user.is_staff
and request.query_params.get("all") == "1"
)
qs = SubscriptionPlan.objects.all()
if not include_all:
qs = qs.filter(is_public=True)
return Response(SubscriptionPlanSerializer(qs, many=True).data)
class SubscriptionMeView(APIView):
"""Current user's plan, checkout need, and usage snapshot (#16/#17/#36)."""
def get(self, request):
seed_subscription_plans(update_existing=False)
try:
sub = request.user.subscription
except UserSubscription.DoesNotExist:
sub = None
usage = get_usage_snapshot(request.user)
payload = {
"plan": plan_to_dict(sub.plan) if sub and sub.plan_id else None,
"status": sub.status if sub else UserSubscription.Status.NONE,
"source": sub.source if sub else UserSubscription.Source.NONE,
"needs_checkout": needs_checkout(request.user),
"stripe_subscription_id": (
sub.stripe_subscription_id if sub else ""
),
"revenuecat_original_transaction_id": (
sub.revenuecat_original_transaction_id if sub else ""
),
"cancel_at_period_end": bool(sub.cancel_at_period_end) if sub else False,
"current_period_end": (
sub.current_period_end.isoformat()
if sub and sub.current_period_end
else None
),
"usage": usage.to_dict(),
}
return Response(payload)
class StripeWebhookView(APIView):
"""Verify Stripe signatures and upsert local invoice/payment rows."""
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
def post(self, request):
payload = request.body
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "")
webhook_secret = settings.STRIPE_WEBHOOK_SECRET
if not webhook_secret:
logger.error("STRIPE_WEBHOOK_SECRET is not configured")
return Response(
{"detail": "Webhook secret not configured"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
try:
event = stripe.Webhook.construct_event(
payload=payload,
sig_header=sig_header,
secret=webhook_secret,
)
except ValueError:
return Response(
{"detail": "Invalid payload"},
status=status.HTTP_400_BAD_REQUEST,
)
except stripe.SignatureVerificationError:
return Response(
{"detail": "Invalid signature"},
status=status.HTTP_400_BAD_REQUEST,
)
if hasattr(event, "to_dict"):
event = event.to_dict()
try:
dispatch_stripe_event(event)
except Exception:
logger.exception("Error handling Stripe event %s", event.get("id"))
return Response(
{"detail": "Webhook handler error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"received": True}, status=status.HTTP_200_OK)
class RevenueCatWebhookView(APIView):
"""Verify RevenueCat Authorization and upsert subscription + ledger rows."""
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
def post(self, request):
webhook_secret = settings.REVENUECAT_WEBHOOK_SECRET
if not webhook_secret:
logger.error("REVENUECAT_WEBHOOK_SECRET is not configured")
return Response(
{"detail": "Webhook secret not configured"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
try:
verify_revenuecat_authorization(
authorization_header=request.META.get("HTTP_AUTHORIZATION"),
expected_secret=webhook_secret,
)
except RevenueCatWebhookAuthError as exc:
return Response(
{"detail": str(exc)},
status=status.HTTP_401_UNAUTHORIZED,
)
payload = request.data
if not isinstance(payload, dict):
return Response(
{"detail": "Invalid payload"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
dispatch_revenuecat_event(payload)
except Exception:
event = payload.get("event") if isinstance(payload, dict) else {}
event_id = event.get("id") if isinstance(event, dict) else None
logger.exception("Error handling RevenueCat event %s", event_id)
return Response(
{"detail": "Webhook handler error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"received": True}, status=status.HTTP_200_OK)