feat: add billing pricing rules
This commit is contained in:
+36
-1
@@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import CallRecord, PointsLedger
|
||||
from .models import CallRecord, ExchangeRate, PointsLedger, PricingRule
|
||||
|
||||
|
||||
class ReadOnlyLedgerAdmin(admin.ModelAdmin):
|
||||
@@ -33,6 +33,41 @@ class PointsLedgerAdmin(ReadOnlyLedgerAdmin):
|
||||
ordering = ("-created_at", "-id")
|
||||
|
||||
|
||||
@admin.register(PricingRule)
|
||||
class PricingRuleAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"operation_type",
|
||||
"alias",
|
||||
"resolution_display",
|
||||
"points_cost",
|
||||
"is_active",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("operation_type", "is_active")
|
||||
search_fields = ("alias", "resolution")
|
||||
ordering = ("operation_type", "alias", "resolution")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
@admin.display(description="resolution")
|
||||
def resolution_display(self, obj):
|
||||
return obj.resolution or "*"
|
||||
|
||||
|
||||
@admin.register(ExchangeRate)
|
||||
class ExchangeRateAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"currency",
|
||||
"points_per_unit",
|
||||
"is_active",
|
||||
"effective_from",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("currency", "is_active")
|
||||
search_fields = ("currency", "note")
|
||||
ordering = ("-effective_from", "-id")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(CallRecord)
|
||||
class CallRecordAdmin(ReadOnlyLedgerAdmin):
|
||||
list_display = (
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-02 07:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ExchangeRate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('currency', models.CharField(default='CNY', max_length=10)),
|
||||
('points_per_unit', models.DecimalField(decimal_places=4, max_digits=12)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('effective_from', models.DateTimeField()),
|
||||
('note', models.CharField(blank=True, max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'exchange_rate',
|
||||
'ordering': ('-effective_from', '-id'),
|
||||
'indexes': [models.Index(fields=['currency', 'is_active', 'effective_from'], name='exchange_ra_currenc_d69abe_idx')],
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(('points_per_unit__gt', 0)), name='exchange_rate_points_per_unit_positive')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PricingRule',
|
||||
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(max_length=64)),
|
||||
('resolution', models.CharField(blank=True, default='', help_text='Leave blank to apply to every resolution for this alias.', max_length=32)),
|
||||
('points_cost', models.BigIntegerField()),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'pricing_rule',
|
||||
'ordering': ('operation_type', 'alias', 'resolution'),
|
||||
'indexes': [models.Index(fields=['operation_type', 'alias', 'is_active'], name='pricing_rul_operati_580547_idx'), models.Index(fields=['operation_type', 'alias', 'resolution', 'is_active'], name='pricing_rul_operati_52be66_idx')],
|
||||
'constraints': [models.UniqueConstraint(fields=('operation_type', 'alias', 'resolution'), name='unique_pricing_rule_per_alias_resolution'), models.CheckConstraint(condition=models.Q(('points_cost__gt', 0)), name='pricing_rule_points_cost_positive')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -6,6 +6,10 @@ from django.db.models import Q
|
||||
from apps.users.models import ApiKey
|
||||
|
||||
|
||||
def normalize_resolution(value: str | None) -> str:
|
||||
return "" if value is None else str(value).strip().upper()
|
||||
|
||||
|
||||
class CallRecord(models.Model):
|
||||
class OperationType(models.TextChoices):
|
||||
TITLE = "title", "Generate title"
|
||||
@@ -66,6 +70,90 @@ class CallRecord(models.Model):
|
||||
return f"{self.operation_type}:{self.alias or '<default>'} {self.status}"
|
||||
|
||||
|
||||
class PricingRule(models.Model):
|
||||
operation_type = models.CharField(max_length=32, choices=CallRecord.OperationType.choices)
|
||||
alias = models.CharField(max_length=64)
|
||||
resolution = models.CharField(
|
||||
max_length=32,
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Leave blank to apply to every resolution for this alias.",
|
||||
)
|
||||
points_cost = models.BigIntegerField()
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "pricing_rule"
|
||||
ordering = ("operation_type", "alias", "resolution")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("operation_type", "alias", "resolution"),
|
||||
name="unique_pricing_rule_per_alias_resolution",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=Q(points_cost__gt=0),
|
||||
name="pricing_rule_points_cost_positive",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=("operation_type", "alias", "is_active")),
|
||||
models.Index(fields=("operation_type", "alias", "resolution", "is_active")),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
resolution = self.resolution or "*"
|
||||
return f"{self.operation_type}:{self.alias}:{resolution} = {self.points_cost}"
|
||||
|
||||
def clean(self) -> None:
|
||||
super().clean()
|
||||
self.alias = str(self.alias or "").strip()
|
||||
self.resolution = normalize_resolution(self.resolution)
|
||||
if not self.alias:
|
||||
raise ValidationError({"alias": "Alias is required."})
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ExchangeRate(models.Model):
|
||||
currency = models.CharField(max_length=10, default="CNY")
|
||||
points_per_unit = models.DecimalField(max_digits=12, decimal_places=4)
|
||||
is_active = models.BooleanField(default=True)
|
||||
effective_from = models.DateTimeField()
|
||||
note = models.CharField(max_length=255, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "exchange_rate"
|
||||
ordering = ("-effective_from", "-id")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(points_per_unit__gt=0),
|
||||
name="exchange_rate_points_per_unit_positive",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=("currency", "is_active", "effective_from")),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.currency} 1 = {self.points_per_unit} points"
|
||||
|
||||
def clean(self) -> None:
|
||||
super().clean()
|
||||
self.currency = str(self.currency or "").strip().upper()
|
||||
if not self.currency:
|
||||
raise ValidationError({"currency": "Currency is required."})
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class PointsLedger(models.Model):
|
||||
class ChangeType(models.TextChoices):
|
||||
RECHARGE = "recharge", "Recharge"
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, ROUND_FLOOR
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import ExchangeRate, PricingRule, normalize_resolution
|
||||
|
||||
|
||||
class BillingConfigError(RuntimeError):
|
||||
code = "billing_config_error"
|
||||
|
||||
|
||||
class NoPricingRuleError(BillingConfigError):
|
||||
code = "no_pricing_rule"
|
||||
|
||||
def __init__(self, operation_type: str, alias: str, resolution: str | None = None):
|
||||
normalized_resolution = normalize_resolution(resolution) or "*"
|
||||
super().__init__(
|
||||
"No pricing rule configured for "
|
||||
f"operation={operation_type}, alias={alias}, resolution={normalized_resolution}."
|
||||
)
|
||||
|
||||
|
||||
class NoExchangeRateError(BillingConfigError):
|
||||
code = "no_exchange_rate"
|
||||
|
||||
def __init__(self, currency: str):
|
||||
super().__init__(f"No active exchange rate configured for currency={currency}.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RechargeQuote:
|
||||
amount: Decimal
|
||||
currency: str
|
||||
points_per_unit: Decimal
|
||||
points_granted: int
|
||||
exchange_rate: ExchangeRate
|
||||
|
||||
|
||||
def get_pricing_rule(
|
||||
operation_type: str,
|
||||
alias: str,
|
||||
resolution: str | None = None,
|
||||
) -> PricingRule:
|
||||
normalized_alias = str(alias or "").strip()
|
||||
normalized_resolution = normalize_resolution(resolution)
|
||||
rules = PricingRule.objects.filter(
|
||||
operation_type=operation_type,
|
||||
alias=normalized_alias,
|
||||
is_active=True,
|
||||
)
|
||||
if normalized_resolution:
|
||||
exact_rule = rules.filter(resolution=normalized_resolution).first()
|
||||
if exact_rule is not None:
|
||||
return exact_rule
|
||||
|
||||
default_rule = rules.filter(resolution="").first()
|
||||
if default_rule is not None:
|
||||
return default_rule
|
||||
|
||||
raise NoPricingRuleError(operation_type, normalized_alias, normalized_resolution)
|
||||
|
||||
|
||||
def calculate_points_cost(
|
||||
operation_type: str,
|
||||
alias: str,
|
||||
resolution: str | None = None,
|
||||
) -> int:
|
||||
return get_pricing_rule(operation_type, alias, resolution).points_cost
|
||||
|
||||
|
||||
def get_current_exchange_rate(
|
||||
*,
|
||||
currency: str = "CNY",
|
||||
at=None,
|
||||
) -> ExchangeRate:
|
||||
normalized_currency = str(currency or "").strip().upper()
|
||||
effective_at = at or timezone.now()
|
||||
exchange_rate = (
|
||||
ExchangeRate.objects.filter(
|
||||
currency=normalized_currency,
|
||||
is_active=True,
|
||||
effective_from__lte=effective_at,
|
||||
)
|
||||
.order_by("-effective_from", "-id")
|
||||
.first()
|
||||
)
|
||||
if exchange_rate is None:
|
||||
raise NoExchangeRateError(normalized_currency)
|
||||
return exchange_rate
|
||||
|
||||
|
||||
def quote_recharge_points(
|
||||
amount,
|
||||
*,
|
||||
currency: str = "CNY",
|
||||
at=None,
|
||||
) -> RechargeQuote:
|
||||
decimal_amount = Decimal(str(amount))
|
||||
if decimal_amount <= 0:
|
||||
raise ValueError("amount must be greater than 0")
|
||||
exchange_rate = get_current_exchange_rate(currency=currency, at=at)
|
||||
points_decimal = decimal_amount * exchange_rate.points_per_unit
|
||||
points_granted = int(points_decimal.to_integral_value(rounding=ROUND_FLOOR))
|
||||
return RechargeQuote(
|
||||
amount=decimal_amount,
|
||||
currency=exchange_rate.currency,
|
||||
points_per_unit=exchange_rate.points_per_unit,
|
||||
points_granted=points_granted,
|
||||
exchange_rate=exchange_rate,
|
||||
)
|
||||
|
||||
|
||||
def calculate_points_granted(
|
||||
amount,
|
||||
*,
|
||||
currency: str = "CNY",
|
||||
at=None,
|
||||
) -> int:
|
||||
return quote_recharge_points(amount, currency=currency, at=at).points_granted
|
||||
+157
-2
@@ -1,12 +1,27 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
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
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.billing.models import CallRecord, PointsLedger
|
||||
from apps.ai.models import AiModel, ModelAlias
|
||||
from apps.billing.models import CallRecord, ExchangeRate, PointsLedger, PricingRule
|
||||
from apps.billing.pricing import (
|
||||
NoPricingRuleError,
|
||||
calculate_points_cost,
|
||||
calculate_points_granted,
|
||||
get_pricing_rule,
|
||||
quote_recharge_points,
|
||||
)
|
||||
from apps.users.models import ApiKey, UserWallet
|
||||
|
||||
TEST_ENCRYPTION_KEY = Fernet.generate_key().decode("ascii")
|
||||
|
||||
|
||||
class BillingCoreModelTests(TestCase):
|
||||
def setUp(self):
|
||||
@@ -122,3 +137,143 @@ class BillingCoreModelTests(TestCase):
|
||||
self.assertIn(ApiKey, admin.site._registry)
|
||||
self.assertIn(PointsLedger, admin.site._registry)
|
||||
self.assertIn(CallRecord, admin.site._registry)
|
||||
|
||||
|
||||
@override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY)
|
||||
class PricingCalculationTests(TestCase):
|
||||
def setUp(self):
|
||||
self.text_model = self.create_ai_model(
|
||||
name="Text model A",
|
||||
model="gpt-5.5-a",
|
||||
capabilities=["text"],
|
||||
)
|
||||
self.replacement_text_model = self.create_ai_model(
|
||||
name="Text model B",
|
||||
model="gpt-5.5-b",
|
||||
capabilities=["text"],
|
||||
)
|
||||
self.alias = ModelAlias.objects.create(
|
||||
operation_type=ModelAlias.OperationType.TITLE,
|
||||
alias="title-standard",
|
||||
ai_model=self.text_model,
|
||||
)
|
||||
|
||||
def create_ai_model(self, *, name, model, capabilities):
|
||||
ai_model = AiModel(
|
||||
name=name,
|
||||
url="https://api.vectorengine.ai/v1",
|
||||
model=model,
|
||||
api_type=AiModel.ApiType.CHAT,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
ai_model.set_api_key("sk-test-secret")
|
||||
ai_model.save()
|
||||
return ai_model
|
||||
|
||||
def test_exact_resolution_rule_overrides_alias_default(self):
|
||||
PricingRule.objects.create(
|
||||
operation_type=CallRecord.OperationType.IMAGE,
|
||||
alias="image-hd",
|
||||
resolution="",
|
||||
points_cost=10,
|
||||
)
|
||||
PricingRule.objects.create(
|
||||
operation_type=CallRecord.OperationType.IMAGE,
|
||||
alias="image-hd",
|
||||
resolution="1k",
|
||||
points_cost=15,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
calculate_points_cost(
|
||||
CallRecord.OperationType.IMAGE,
|
||||
"image-hd",
|
||||
"1K",
|
||||
),
|
||||
15,
|
||||
)
|
||||
self.assertEqual(
|
||||
calculate_points_cost(
|
||||
CallRecord.OperationType.IMAGE,
|
||||
"image-hd",
|
||||
"2K",
|
||||
),
|
||||
10,
|
||||
)
|
||||
|
||||
exact_rule = get_pricing_rule(
|
||||
CallRecord.OperationType.IMAGE,
|
||||
"image-hd",
|
||||
"1k",
|
||||
)
|
||||
self.assertEqual(exact_rule.resolution, "1K")
|
||||
|
||||
def test_pricing_is_bound_to_alias_not_underlying_model(self):
|
||||
PricingRule.objects.create(
|
||||
operation_type=CallRecord.OperationType.TITLE,
|
||||
alias="title-standard",
|
||||
points_cost=2,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
calculate_points_cost(
|
||||
CallRecord.OperationType.TITLE,
|
||||
"title-standard",
|
||||
"1K",
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
self.alias.ai_model = self.replacement_text_model
|
||||
self.alias.save()
|
||||
|
||||
self.assertEqual(
|
||||
calculate_points_cost(
|
||||
CallRecord.OperationType.TITLE,
|
||||
"title-standard",
|
||||
"1K",
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
def test_missing_pricing_rule_raises_no_pricing_rule_code(self):
|
||||
with self.assertRaises(NoPricingRuleError) as context:
|
||||
calculate_points_cost(
|
||||
CallRecord.OperationType.TITLE,
|
||||
"missing-alias",
|
||||
"1K",
|
||||
)
|
||||
|
||||
self.assertEqual(context.exception.code, "no_pricing_rule")
|
||||
|
||||
def test_exchange_rate_uses_latest_active_effective_rate_and_floors_points(self):
|
||||
now = timezone.now()
|
||||
ExchangeRate.objects.create(
|
||||
currency="cny",
|
||||
points_per_unit=Decimal("10.0000"),
|
||||
effective_from=now - timedelta(days=2),
|
||||
)
|
||||
current = ExchangeRate.objects.create(
|
||||
currency="CNY",
|
||||
points_per_unit=Decimal("12.5000"),
|
||||
effective_from=now - timedelta(days=1),
|
||||
)
|
||||
ExchangeRate.objects.create(
|
||||
currency="CNY",
|
||||
points_per_unit=Decimal("20.0000"),
|
||||
effective_from=now,
|
||||
is_active=False,
|
||||
)
|
||||
ExchangeRate.objects.create(
|
||||
currency="CNY",
|
||||
points_per_unit=Decimal("99.0000"),
|
||||
effective_from=now + timedelta(days=1),
|
||||
)
|
||||
|
||||
quote = quote_recharge_points(Decimal("8.88"), currency="cny", at=now)
|
||||
|
||||
self.assertEqual(quote.exchange_rate, current)
|
||||
self.assertEqual(quote.currency, "CNY")
|
||||
self.assertEqual(quote.points_per_unit, Decimal("12.5000"))
|
||||
self.assertEqual(quote.points_granted, 111)
|
||||
self.assertEqual(calculate_points_granted("8.88", currency="CNY", at=now), 111)
|
||||
|
||||
Reference in New Issue
Block a user