Monetization app + RevenueCat webhooks (store IAP ledger) (#69)
## 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:
@@ -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"
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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"])
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user