feat: add billing core models
This commit is contained in:
+58
-1
@@ -1,3 +1,60 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
from .models import CallRecord, PointsLedger
|
||||
|
||||
|
||||
class ReadOnlyLedgerAdmin(admin.ModelAdmin):
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
return tuple(field.name for field in self.model._meta.fields)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(PointsLedger)
|
||||
class PointsLedgerAdmin(ReadOnlyLedgerAdmin):
|
||||
list_display = (
|
||||
"created_at",
|
||||
"user",
|
||||
"change_type",
|
||||
"points_delta",
|
||||
"balance_after",
|
||||
"ref_order_id",
|
||||
"ref_call",
|
||||
)
|
||||
list_filter = ("change_type", "created_at")
|
||||
search_fields = ("user__username", "user__email", "reason")
|
||||
ordering = ("-created_at", "-id")
|
||||
|
||||
|
||||
@admin.register(CallRecord)
|
||||
class CallRecordAdmin(ReadOnlyLedgerAdmin):
|
||||
list_display = (
|
||||
"created_at",
|
||||
"user",
|
||||
"api_key",
|
||||
"operation_type",
|
||||
"alias",
|
||||
"model_used",
|
||||
"points_cost",
|
||||
"status",
|
||||
"upstream_latency_ms",
|
||||
)
|
||||
list_filter = ("operation_type", "status", "created_at")
|
||||
search_fields = (
|
||||
"user__username",
|
||||
"user__email",
|
||||
"api_key__key_prefix",
|
||||
"alias",
|
||||
"model_used",
|
||||
"error_message",
|
||||
"result_ref",
|
||||
"result_summary",
|
||||
)
|
||||
ordering = ("-created_at", "-id")
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-02 06:52
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('users', '0003_apikey_userwallet'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CallRecord',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('operation_type', models.CharField(choices=[('title', 'Generate title'), ('image', 'Generate image')], max_length=32)),
|
||||
('alias', models.CharField(blank=True, max_length=64)),
|
||||
('model_used', models.CharField(blank=True, max_length=128)),
|
||||
('resolution', models.CharField(blank=True, max_length=32)),
|
||||
('prompt', models.TextField(blank=True)),
|
||||
('points_cost', models.BigIntegerField(default=0)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('success', 'Success'), ('failed', 'Failed')], default='pending', max_length=20)),
|
||||
('upstream_latency_ms', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('error_message', models.TextField(blank=True)),
|
||||
('result_ref', models.TextField(blank=True)),
|
||||
('result_summary', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('api_key', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='call_records', to='users.apikey')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='call_records', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'call_record',
|
||||
'ordering': ('-created_at', '-id'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PointsLedger',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('change_type', models.CharField(choices=[('recharge', 'Recharge'), ('consume', 'Consume'), ('adjust', 'Adjust'), ('refund', 'Refund')], max_length=20)),
|
||||
('points_delta', models.BigIntegerField()),
|
||||
('balance_after', models.BigIntegerField()),
|
||||
('ref_order_id', models.PositiveBigIntegerField(blank=True, null=True)),
|
||||
('reason', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('ref_call', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ledger_entries', to='billing.callrecord')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='points_ledger_entries', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'points_ledger',
|
||||
'ordering': ('-created_at', '-id'),
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='callrecord',
|
||||
index=models.Index(fields=['user', 'created_at'], name='call_record_user_id_c4cb1c_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='callrecord',
|
||||
index=models.Index(fields=['api_key', 'created_at'], name='call_record_api_key_0fe521_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='callrecord',
|
||||
index=models.Index(fields=['operation_type', 'status'], name='call_record_operati_c3ca15_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='callrecord',
|
||||
index=models.Index(fields=['alias'], name='call_record_alias_8f3c9b_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='callrecord',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('points_cost__gte', 0)), name='call_record_points_cost_non_negative'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='pointsledger',
|
||||
index=models.Index(fields=['user', 'created_at'], name='points_ledg_user_id_ed8d7f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='pointsledger',
|
||||
index=models.Index(fields=['change_type', 'created_at'], name='points_ledg_change__431a16_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='pointsledger',
|
||||
index=models.Index(fields=['ref_order_id'], name='points_ledg_ref_ord_da5b21_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='pointsledger',
|
||||
index=models.Index(fields=['ref_call'], name='points_ledg_ref_cal_76574c_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='pointsledger',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('balance_after__gte', 0)), name='points_ledger_balance_after_non_negative'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='pointsledger',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('points_delta', 0), _negated=True), name='points_ledger_points_delta_non_zero'),
|
||||
),
|
||||
]
|
||||
+119
-1
@@ -1,3 +1,121 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
# Create your models here.
|
||||
from apps.users.models import ApiKey
|
||||
|
||||
|
||||
class CallRecord(models.Model):
|
||||
class OperationType(models.TextChoices):
|
||||
TITLE = "title", "Generate title"
|
||||
IMAGE = "image", "Generate image"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
SUCCESS = "success", "Success"
|
||||
FAILED = "failed", "Failed"
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="call_records",
|
||||
)
|
||||
api_key = models.ForeignKey(
|
||||
ApiKey,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="call_records",
|
||||
)
|
||||
operation_type = models.CharField(max_length=32, choices=OperationType.choices)
|
||||
alias = models.CharField(max_length=64, blank=True)
|
||||
model_used = models.CharField(max_length=128, blank=True)
|
||||
resolution = models.CharField(max_length=32, blank=True)
|
||||
prompt = models.TextField(blank=True)
|
||||
points_cost = models.BigIntegerField(default=0)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.PENDING,
|
||||
)
|
||||
upstream_latency_ms = models.PositiveIntegerField(null=True, blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
result_ref = models.TextField(blank=True)
|
||||
result_summary = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "call_record"
|
||||
ordering = ("-created_at", "-id")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(points_cost__gte=0),
|
||||
name="call_record_points_cost_non_negative",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=("user", "created_at")),
|
||||
models.Index(fields=("api_key", "created_at")),
|
||||
models.Index(fields=("operation_type", "status")),
|
||||
models.Index(fields=("alias",)),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.operation_type}:{self.alias or '<default>'} {self.status}"
|
||||
|
||||
|
||||
class PointsLedger(models.Model):
|
||||
class ChangeType(models.TextChoices):
|
||||
RECHARGE = "recharge", "Recharge"
|
||||
CONSUME = "consume", "Consume"
|
||||
ADJUST = "adjust", "Adjust"
|
||||
REFUND = "refund", "Refund"
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="points_ledger_entries",
|
||||
)
|
||||
change_type = models.CharField(max_length=20, choices=ChangeType.choices)
|
||||
points_delta = models.BigIntegerField()
|
||||
balance_after = models.BigIntegerField()
|
||||
ref_order_id = models.PositiveBigIntegerField(null=True, blank=True)
|
||||
ref_call = models.ForeignKey(
|
||||
CallRecord,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="ledger_entries",
|
||||
)
|
||||
reason = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "points_ledger"
|
||||
ordering = ("-created_at", "-id")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(balance_after__gte=0),
|
||||
name="points_ledger_balance_after_non_negative",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=~Q(points_delta=0),
|
||||
name="points_ledger_points_delta_non_zero",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=("user", "created_at")),
|
||||
models.Index(fields=("change_type", "created_at")),
|
||||
models.Index(fields=("ref_order_id",)),
|
||||
models.Index(fields=("ref_call",)),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} {self.change_type} {self.points_delta}"
|
||||
|
||||
def clean(self) -> None:
|
||||
super().clean()
|
||||
if self.change_type == self.ChangeType.ADJUST and not (self.reason or "").strip():
|
||||
raise ValidationError({"reason": "Adjust ledger entries require a reason."})
|
||||
|
||||
+122
-1
@@ -1,3 +1,124 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
from apps.billing.models import CallRecord, PointsLedger
|
||||
from apps.users.models import ApiKey, UserWallet
|
||||
|
||||
|
||||
class BillingCoreModelTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="client",
|
||||
email="client@example.com",
|
||||
password="password",
|
||||
)
|
||||
|
||||
def test_user_wallet_defaults_to_zero_and_rejects_negative_balance(self):
|
||||
wallet = UserWallet.objects.create(user=self.user)
|
||||
|
||||
self.assertEqual(wallet.points_balance, 0)
|
||||
|
||||
other_user = get_user_model().objects.create_user(
|
||||
username="negative",
|
||||
email="negative@example.com",
|
||||
password="password",
|
||||
)
|
||||
with self.assertRaises(IntegrityError):
|
||||
with transaction.atomic():
|
||||
UserWallet.objects.create(user=other_user, points_balance=-1)
|
||||
|
||||
def test_api_key_hashes_plaintext_and_matches_only_raw_key(self):
|
||||
api_key, raw_key = ApiKey.create_for_user(self.user, name="desktop")
|
||||
|
||||
self.assertTrue(raw_key.startswith("sk_cmhub_"))
|
||||
self.assertEqual(api_key.key_prefix, raw_key[: ApiKey.KEY_PREFIX_LENGTH])
|
||||
self.assertEqual(len(api_key.key_hash), 64)
|
||||
self.assertNotEqual(api_key.key_hash, raw_key)
|
||||
self.assertNotIn(raw_key, str(api_key.__dict__))
|
||||
self.assertTrue(api_key.matches_key(raw_key))
|
||||
self.assertFalse(api_key.matches_key(raw_key + "-wrong"))
|
||||
self.assertEqual(api_key.status, ApiKey.Status.ACTIVE)
|
||||
|
||||
def test_call_record_stores_summary_reference_but_no_provider_raw_field(self):
|
||||
api_key, _raw_key = ApiKey.create_for_user(self.user)
|
||||
call = CallRecord.objects.create(
|
||||
user=self.user,
|
||||
api_key=api_key,
|
||||
operation_type=CallRecord.OperationType.IMAGE,
|
||||
alias="image-hd",
|
||||
model_used="gpt-image-2",
|
||||
resolution="1K",
|
||||
prompt="Generate an image",
|
||||
points_cost=10,
|
||||
status=CallRecord.Status.SUCCESS,
|
||||
upstream_latency_ms=1234,
|
||||
result_ref="https://cdn.example.test/result.png",
|
||||
result_summary="stored image result",
|
||||
)
|
||||
|
||||
field_names = {field.name for field in CallRecord._meta.fields}
|
||||
self.assertNotIn("raw", field_names)
|
||||
self.assertNotIn("provider_raw", field_names)
|
||||
self.assertEqual(call.user, self.user)
|
||||
self.assertEqual(call.api_key, api_key)
|
||||
self.assertEqual(call.alias, "image-hd")
|
||||
self.assertEqual(call.model_used, "gpt-image-2")
|
||||
self.assertEqual(call.result_ref, "https://cdn.example.test/result.png")
|
||||
self.assertEqual(call.result_summary, "stored image result")
|
||||
|
||||
def test_points_ledger_records_balance_and_requires_adjust_reason(self):
|
||||
call = CallRecord.objects.create(
|
||||
user=self.user,
|
||||
operation_type=CallRecord.OperationType.TITLE,
|
||||
alias="title-standard",
|
||||
model_used="gpt-5.5",
|
||||
points_cost=2,
|
||||
status=CallRecord.Status.SUCCESS,
|
||||
)
|
||||
ledger = PointsLedger.objects.create(
|
||||
user=self.user,
|
||||
change_type=PointsLedger.ChangeType.CONSUME,
|
||||
points_delta=-2,
|
||||
balance_after=98,
|
||||
ref_call=call,
|
||||
)
|
||||
|
||||
self.assertEqual(ledger.ref_call, call)
|
||||
self.assertEqual(ledger.balance_after, 98)
|
||||
|
||||
adjustment = PointsLedger(
|
||||
user=self.user,
|
||||
change_type=PointsLedger.ChangeType.ADJUST,
|
||||
points_delta=10,
|
||||
balance_after=108,
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
adjustment.full_clean()
|
||||
|
||||
def test_points_ledger_rejects_zero_delta_and_negative_balance_after(self):
|
||||
with self.assertRaises(IntegrityError):
|
||||
with transaction.atomic():
|
||||
PointsLedger.objects.create(
|
||||
user=self.user,
|
||||
change_type=PointsLedger.ChangeType.RECHARGE,
|
||||
points_delta=0,
|
||||
balance_after=100,
|
||||
)
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
with transaction.atomic():
|
||||
PointsLedger.objects.create(
|
||||
user=self.user,
|
||||
change_type=PointsLedger.ChangeType.CONSUME,
|
||||
points_delta=-1,
|
||||
balance_after=-1,
|
||||
)
|
||||
|
||||
def test_billing_models_are_registered_in_admin(self):
|
||||
self.assertIn(UserWallet, admin.site._registry)
|
||||
self.assertIn(ApiKey, admin.site._registry)
|
||||
self.assertIn(PointsLedger, admin.site._registry)
|
||||
self.assertIn(CallRecord, admin.site._registry)
|
||||
|
||||
Reference in New Issue
Block a user