feat: add billing pricing rules
This commit is contained in:
+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