feat: add billing core models

This commit is contained in:
QiuSW
2026-07-02 15:06:00 +08:00
parent 2f93374193
commit cbc56ee15d
16 changed files with 660 additions and 24 deletions
+58 -1
View File
@@ -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")
+105
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+53 -1
View File
@@ -1,7 +1,7 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from .models import User
from .models import ApiKey, User, UserWallet
@admin.register(User)
@@ -20,3 +20,55 @@ class UserAdmin(DjangoUserAdmin):
)
list_filter = DjangoUserAdmin.list_filter + ("status",)
search_fields = ("username", "email", "payment_user_id")
@admin.register(UserWallet)
class UserWalletAdmin(admin.ModelAdmin):
list_display = ("user", "points_balance", "updated_at")
search_fields = ("user__username", "user__email")
readonly_fields = ("user", "points_balance", "created_at", "updated_at")
ordering = ("user__username",)
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(ApiKey)
class ApiKeyAdmin(admin.ModelAdmin):
list_display = ("key_prefix", "user", "name", "status", "last_used_at", "created_at")
list_filter = ("status", "created_at")
search_fields = ("key_prefix", "user__username", "user__email", "name")
readonly_fields = (
"user",
"key_prefix",
"key_hash_masked",
"last_used_at",
"created_at",
"updated_at",
)
fields = (
"user",
"name",
"status",
"key_prefix",
"key_hash_masked",
"last_used_at",
"created_at",
"updated_at",
)
ordering = ("-created_at", "-id")
@admin.display(description="key hash")
def key_hash_masked(self, obj):
if not obj.key_hash:
return ""
return f"{obj.key_hash[:8]}...{obj.key_hash[-6:]}"
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@@ -0,0 +1,49 @@
# 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):
dependencies = [
('users', '0002_alter_user_email'),
]
operations = [
migrations.CreateModel(
name='ApiKey',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, max_length=80)),
('key_hash', models.CharField(editable=False, max_length=64, unique=True)),
('key_prefix', models.CharField(editable=False, max_length=32)),
('status', models.CharField(choices=[('active', 'Active'), ('revoked', 'Revoked')], default='active', max_length=20)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'api_key',
'ordering': ('-created_at', '-id'),
'indexes': [models.Index(fields=['user', 'status'], name='api_key_user_id_b1527f_idx'), models.Index(fields=['key_prefix'], name='api_key_key_pre_e8448b_idx')],
},
),
migrations.CreateModel(
name='UserWallet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('points_balance', models.BigIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='wallet', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'user_wallet',
'ordering': ('user_id',),
'constraints': [models.CheckConstraint(condition=models.Q(('points_balance__gte', 0)), name='user_wallet_points_balance_non_negative')],
},
),
]
+99
View File
@@ -1,5 +1,13 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models import Q
class User(AbstractUser):
@@ -26,3 +34,94 @@ class User(AbstractUser):
class Meta:
db_table = "user"
class UserWallet(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="wallet",
)
points_balance = models.BigIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "user_wallet"
ordering = ("user_id",)
constraints = [
models.CheckConstraint(
condition=Q(points_balance__gte=0),
name="user_wallet_points_balance_non_negative",
),
]
def __str__(self) -> str:
return f"{self.user} wallet: {self.points_balance}"
class ApiKey(models.Model):
KEY_PREFIX_LENGTH = 16
class Status(models.TextChoices):
ACTIVE = "active", "Active"
REVOKED = "revoked", "Revoked"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="api_keys",
)
name = models.CharField(max_length=80, blank=True)
key_hash = models.CharField(max_length=64, unique=True, editable=False)
key_prefix = models.CharField(max_length=32, editable=False)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.ACTIVE,
)
last_used_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "api_key"
ordering = ("-created_at", "-id")
indexes = [
models.Index(fields=("user", "status")),
models.Index(fields=("key_prefix",)),
]
def __str__(self) -> str:
return f"{self.user} {self.key_prefix}"
@classmethod
def generate_plaintext_key(cls) -> str:
return f"sk_cmhub_{secrets.token_urlsafe(32)}"
@classmethod
def hash_key(cls, raw_key: str) -> str:
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
@classmethod
def build_prefix(cls, raw_key: str) -> str:
return raw_key[: cls.KEY_PREFIX_LENGTH]
@classmethod
def create_for_user(cls, user, *, name: str = "") -> tuple["ApiKey", str]:
raw_key = cls.generate_plaintext_key()
api_key = cls(user=user, name=name)
api_key.set_key(raw_key)
api_key.save()
return api_key, raw_key
def set_key(self, raw_key: str) -> None:
self.key_hash = self.hash_key(raw_key)
self.key_prefix = self.build_prefix(raw_key)
def matches_key(self, raw_key: str) -> bool:
return hmac.compare_digest(self.key_hash, self.hash_key(raw_key))
@property
def is_active_key(self) -> bool:
return self.status == self.Status.ACTIVE