service level specific base fees

This commit is contained in:
Tobias Brunner 2025-06-20 15:39:26 +02:00
parent 150250bfb1
commit 033eea92cd
No known key found for this signature in database
9 changed files with 716 additions and 58 deletions

View file

@ -289,7 +289,7 @@ class VSHNAppCatBaseFeeInline(admin.TabularInline):
model = VSHNAppCatBaseFee
extra = 1
fields = ("currency", "amount")
fields = ("currency", "service_level", "amount")
class VSHNAppCatUnitRateInline(admin.TabularInline):
@ -350,7 +350,7 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin):
if not fees:
return "No base fees"
return format_html(
"<br>".join([f"{fee.amount} {fee.currency}" for fee in fees])
"<br>".join([f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})" for fee in fees])
)
admin_display_base_fees.short_description = "Base Fees"
@ -561,7 +561,7 @@ class VSHNAppCatAddonBaseFeeInline(admin.TabularInline):
model = VSHNAppCatAddonBaseFee
extra = 1
fields = ("currency", "amount")
fields = ("currency", "service_level", "amount")
class VSHNAppCatAddonUnitRateInline(admin.TabularInline):
@ -618,7 +618,7 @@ class VSHNAppCatAddonAdmin(admin.ModelAdmin):
if not fees:
return "No base fees set"
return format_html(
"<br>".join([f"{fee.amount} {fee.currency}" for fee in fees])
"<br>".join([f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})" for fee in fees])
)
elif obj.addon_type == "UR": # Unit Rate
rates = obj.unit_rates.all()

View file

@ -0,0 +1,79 @@
# Generated by Django 5.2 on 2025-06-20 13:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0035_alter_article_image_vshnappcataddon_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="vshnappcataddonbasefee",
options={
"ordering": ["currency", "service_level"],
"verbose_name": "Addon Base Fee",
},
),
migrations.AlterModelOptions(
name="vshnappcatbasefee",
options={
"ordering": ["currency", "service_level"],
"verbose_name": "Service Base Fee",
},
),
migrations.AlterUniqueTogether(
name="vshnappcataddonbasefee",
unique_together=set(),
),
migrations.AlterUniqueTogether(
name="vshnappcatbasefee",
unique_together=set(),
),
migrations.AddField(
model_name="vshnappcataddonbasefee",
name="service_level",
field=models.CharField(
choices=[("BE", "Best Effort"), ("GA", "Guaranteed Availability")],
default="BE",
max_length=2,
),
),
migrations.AddField(
model_name="vshnappcatbasefee",
name="service_level",
field=models.CharField(
choices=[("BE", "Best Effort"), ("GA", "Guaranteed Availability")],
default="BE",
max_length=2,
),
),
migrations.AlterField(
model_name="vshnappcataddonbasefee",
name="amount",
field=models.DecimalField(
decimal_places=2,
help_text="Base fee in the specified currency and service level, excl. VAT",
max_digits=10,
),
),
migrations.AlterField(
model_name="vshnappcatbasefee",
name="amount",
field=models.DecimalField(
decimal_places=2,
help_text="Base fee in the specified currency and service level, excl. VAT",
max_digits=10,
),
),
migrations.AlterUniqueTogether(
name="vshnappcataddonbasefee",
unique_together={("addon", "currency", "service_level")},
),
migrations.AlterUniqueTogether(
name="vshnappcatbasefee",
unique_together={("vshn_appcat_price_config", "currency", "service_level")},
),
]

View file

@ -250,29 +250,6 @@ class DiscountTier(models.Model):
return f"{self.discount_model.name}: {self.min_units}+ units → {self.discount_percent}% discount"
class VSHNAppCatBaseFee(models.Model):
vshn_appcat_price_config = models.ForeignKey(
"VSHNAppCatPrice", on_delete=models.CASCADE, related_name="base_fees"
)
currency = models.CharField(
max_length=3,
choices=Currency.choices,
)
amount = models.DecimalField(
max_digits=10,
decimal_places=2,
help_text="Base fee in the specified currency, excl. VAT",
)
class Meta:
verbose_name = "Service Base Fee"
unique_together = ("vshn_appcat_price_config", "currency")
ordering = ["currency"]
def __str__(self):
return f"{self.vshn_appcat_price_config.service.name} Base Fee - {self.amount} {self.currency}"
class VSHNAppCatPrice(models.Model):
class VariableUnit(models.TextChoices):
RAM = "RAM", "Memory (RAM)"
@ -325,12 +302,6 @@ class VSHNAppCatPrice(models.Model):
def __str__(self):
return f"{self.service.name} - {self.get_variable_unit_display()} based pricing"
def get_base_fee(self, currency_code: str):
try:
return self.base_fees.get(currency=currency_code).amount
except VSHNAppCatBaseFee.DoesNotExist:
return None
def get_unit_rate(self, currency_code: str, service_level: str):
try:
return self.unit_rates.get(
@ -346,7 +317,7 @@ class VSHNAppCatPrice(models.Model):
number_of_units: int,
addon_ids=None,
):
base_fee = self.get_base_fee(currency_code)
base_fee = self.get_base_fee(currency_code, service_level)
unit_rate = self.get_unit_rate(currency_code, service_level)
if base_fee is None or unit_rate is None:
@ -380,7 +351,7 @@ class VSHNAppCatPrice(models.Model):
for addon in addons:
addon_price = 0
if addon.addon_type == VSHNAppCatAddon.AddonType.BASE_FEE:
addon_price_value = addon.get_price(currency_code)
addon_price_value = addon.get_price(currency_code, service_level)
if addon_price_value:
addon_price = addon_price_value
elif addon.addon_type == VSHNAppCatAddon.AddonType.UNIT_RATE:
@ -408,6 +379,15 @@ class VSHNAppCatPrice(models.Model):
"addon_breakdown": addon_breakdown,
}
def get_base_fee(self, currency_code: str, service_level: str):
"""
Get the base fee for the given currency and service level.
"""
try:
return self.base_fees.get(currency=currency_code, service_level=service_level).amount
except VSHNAppCatBaseFee.DoesNotExist:
return None
class VSHNAppCatUnitRate(models.Model):
vshn_appcat_price_config = models.ForeignKey(
@ -477,10 +457,16 @@ class VSHNAppCatAddon(models.Model):
return f"{self.vshn_appcat_price_config.service.name} - {self.name}"
def get_price(self, currency_code: str, service_level: str = None):
"""Get the price for this addon in the specified currency and service level"""
"""
Get the price for this addon in the specified currency and service level.
For base fee addons, service_level is required and used.
For unit rate addons, service_level is required and used.
"""
try:
if self.addon_type == self.AddonType.BASE_FEE:
return self.base_fees.get(currency=currency_code).amount
if not service_level:
raise ValueError("Service level is required for base fee addons")
return self.base_fees.get(currency=currency_code, service_level=service_level).amount
elif self.addon_type == self.AddonType.UNIT_RATE:
if not service_level:
raise ValueError("Service level is required for unit rate addons")
@ -495,8 +481,9 @@ class VSHNAppCatAddon(models.Model):
class VSHNAppCatAddonBaseFee(models.Model):
"""Base fee for an addon (fixed amount regardless of units)"""
"""
Base fee for an addon (fixed amount regardless of units), specified per currency and service level.
"""
addon = models.ForeignKey(
VSHNAppCatAddon, on_delete=models.CASCADE, related_name="base_fees"
)
@ -504,19 +491,27 @@ class VSHNAppCatAddonBaseFee(models.Model):
max_length=3,
choices=Currency.choices,
)
service_level = models.CharField(
max_length=2,
choices=VSHNAppCatPrice.ServiceLevel.choices,
default=VSHNAppCatPrice.ServiceLevel.BEST_EFFORT,
)
amount = models.DecimalField(
max_digits=10,
decimal_places=2,
help_text="Base fee in the specified currency, excl. VAT",
help_text="Base fee in the specified currency and service level, excl. VAT",
)
class Meta:
verbose_name = "Addon Base Fee"
unique_together = ("addon", "currency")
ordering = ["currency"]
unique_together = ("addon", "currency", "service_level")
ordering = ["currency", "service_level"]
def __str__(self):
return f"{self.addon.name} Base Fee - {self.amount} {self.currency}"
return f"{self.addon.name} Base Fee - {self.amount} {self.currency} ({self.get_service_level_display()})"
def get_service_level_display(self):
return dict(VSHNAppCatPrice.ServiceLevel.choices).get(self.service_level, self.service_level)
class VSHNAppCatAddonUnitRate(models.Model):
@ -548,6 +543,40 @@ class VSHNAppCatAddonUnitRate(models.Model):
return f"{self.addon.name} - {self.get_service_level_display()} Unit Rate - {self.amount} {self.currency}"
class VSHNAppCatBaseFee(models.Model):
"""
Base fee for a service, specified per currency and service level.
"""
vshn_appcat_price_config = models.ForeignKey(
"VSHNAppCatPrice", on_delete=models.CASCADE, related_name="base_fees"
)
currency = models.CharField(
max_length=3,
choices=Currency.choices,
)
service_level = models.CharField(
max_length=2,
choices=VSHNAppCatPrice.ServiceLevel.choices,
default=VSHNAppCatPrice.ServiceLevel.BEST_EFFORT,
)
amount = models.DecimalField(
max_digits=10,
decimal_places=2,
help_text="Base fee in the specified currency and service level, excl. VAT",
)
class Meta:
verbose_name = "Service Base Fee"
unique_together = ("vshn_appcat_price_config", "currency", "service_level")
ordering = ["currency", "service_level"]
def __str__(self):
return f"{self.vshn_appcat_price_config.service.name} Base Fee - {self.amount} {self.currency} ({self.get_service_level_display()})"
def get_service_level_display(self):
return dict(VSHNAppCatPrice.ServiceLevel.choices).get(self.service_level, self.service_level)
class ExternalPricePlans(models.Model):
plan_name = models.CharField()
description = models.CharField(max_length=200, blank=True, null=True)

View file

@ -303,14 +303,15 @@ class VSHNAppCatPriceTestCase(TestCase):
base_fee = VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=self.price_config,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("50.00"),
)
retrieved_fee = self.price_config.get_base_fee(Currency.CHF)
retrieved_fee = self.price_config.get_base_fee(Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED)
self.assertEqual(retrieved_fee, Decimal("50.00"))
# Test non-existent currency
non_existent_fee = self.price_config.get_base_fee(Currency.EUR)
non_existent_fee = self.price_config.get_base_fee(Currency.EUR, VSHNAppCatPrice.ServiceLevel.GUARANTEED)
self.assertIsNone(non_existent_fee)
def test_unit_rate_creation_and_retrieval(self):
@ -339,6 +340,7 @@ class VSHNAppCatPriceTestCase(TestCase):
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=self.price_config,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("50.00"),
)
@ -364,6 +366,7 @@ class VSHNAppCatPriceTestCase(TestCase):
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=self.price_config,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("50.00"),
)
@ -392,6 +395,7 @@ class VSHNAppCatPriceTestCase(TestCase):
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=self.price_config,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("50.00"),
)
@ -413,6 +417,7 @@ class VSHNAppCatPriceTestCase(TestCase):
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=self.price_config,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("50.00"),
)
@ -431,6 +436,7 @@ class VSHNAppCatPriceTestCase(TestCase):
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=self.price_config,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("50.00"),
)
@ -473,6 +479,7 @@ class VSHNAppCatAddonTestCase(TestCase):
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=self.price_config,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("50.00"),
)
@ -495,11 +502,11 @@ class VSHNAppCatAddonTestCase(TestCase):
# Create base fee for addon
VSHNAppCatAddonBaseFee.objects.create(
addon=addon, currency=Currency.CHF, amount=Decimal("25.00")
addon=addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00")
)
# Test get_price method
price = addon.get_price(Currency.CHF)
price = addon.get_price(Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED)
self.assertEqual(price, Decimal("25.00"))
def test_addon_unit_rate_type(self):
@ -553,7 +560,7 @@ class VSHNAppCatAddonTestCase(TestCase):
)
VSHNAppCatAddonBaseFee.objects.create(
addon=mandatory_addon, currency=Currency.CHF, amount=Decimal("25.00")
addon=mandatory_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00")
)
# Create mandatory unit rate addon
@ -594,7 +601,7 @@ class VSHNAppCatAddonTestCase(TestCase):
)
VSHNAppCatAddonBaseFee.objects.create(
addon=optional_addon, currency=Currency.CHF, amount=Decimal("15.00")
addon=optional_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("15.00")
)
# Calculate price with selected addon

View file

@ -127,6 +127,7 @@ class PricingEdgeCasesTestCase(TestCase):
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=price_config,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("50.00"),
)
@ -208,6 +209,7 @@ class PricingEdgeCasesTestCase(TestCase):
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=price_config,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("0.01"), # Very small base fee
)

View file

@ -204,7 +204,10 @@ class PricingIntegrationTestCase(TestCase):
for currency, amount in base_fees:
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=appcat_price, currency=currency, amount=amount
vshn_appcat_price_config=appcat_price, currency=currency, service_level=VSHNAppCatPrice.ServiceLevel.BEST_EFFORT, amount=amount
)
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=appcat_price, currency=currency, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=amount
)
# Set up unit rates for different service levels and currencies
@ -237,7 +240,7 @@ class PricingIntegrationTestCase(TestCase):
)
VSHNAppCatAddonBaseFee.objects.create(
addon=backup_addon, currency=Currency.CHF, amount=Decimal("15.00")
addon=backup_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("15.00")
)
# Create optional addon (monitoring)
@ -317,6 +320,7 @@ class PricingIntegrationTestCase(TestCase):
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=appcat_price,
currency=Currency.USD,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("30.00"),
)
@ -390,6 +394,7 @@ class PricingIntegrationTestCase(TestCase):
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=redis_price,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("20.00"),
)
@ -454,6 +459,7 @@ class PricingIntegrationTestCase(TestCase):
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=appcat_price,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("40.00"), # Base fee for managed service
)
@ -474,7 +480,7 @@ class PricingIntegrationTestCase(TestCase):
)
VSHNAppCatAddonBaseFee.objects.create(
addon=backup_addon, currency=Currency.CHF, amount=Decimal("25.00")
addon=backup_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00")
)
monitoring_addon = VSHNAppCatAddon.objects.create(
@ -501,7 +507,541 @@ class PricingIntegrationTestCase(TestCase):
)
VSHNAppCatAddonBaseFee.objects.create(
addon=ssl_addon, currency=Currency.CHF, amount=Decimal("18.00")
addon=ssl_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("18.00")
)
# Calculate final price with all selected addons
result = appcat_price.calculate_final_price(
Currency.CHF,
VSHNAppCatPrice.ServiceLevel.GUARANTEED,
16, # 16 GiB RAM
addon_ids=[monitoring_addon.id, ssl_addon.id],
)
# Expected calculation:
# Base fee: 40.00
# RAM cost: First 8 at 6.00 = 48.00, Next 8 at 5.40 (10% discount) = 43.20
# RAM total: 91.20
# Mandatory backup: 25.00
# Optional monitoring: 0.75 * 16 = 12.00
# Optional SSL: 18.00
# Total: 40.00 + 91.20 + 25.00 + 12.00 + 18.00 = 186.20
self.assertEqual(result["total_price"], Decimal("186.20"))
self.assertEqual(result["addon_total"], Decimal("55.00"))
self.assertEqual(len(result["addon_breakdown"]), 3)
# Verify addon breakdown details
addon_names = [addon["name"] for addon in result["addon_breakdown"]]
self.assertIn("Enterprise Backup", addon_names)
self.assertIn("Advanced Monitoring", addon_names)
from decimal import Decimal
from django.test import TestCase
from django.utils import timezone
from ..models.base import Currency, Term, Unit
from ..models.providers import CloudProvider
from ..models.services import Service, Category
from ..models.pricing import (
ComputePlan,
ComputePlanPrice,
ComputePlanGroup,
StoragePlan,
StoragePlanPrice,
ProgressiveDiscountModel,
DiscountTier,
VSHNAppCatPrice,
VSHNAppCatBaseFee,
VSHNAppCatUnitRate,
VSHNAppCatAddon,
VSHNAppCatAddonBaseFee,
VSHNAppCatAddonUnitRate,
ExternalPricePlans,
)
class PricingIntegrationTestCase(TestCase):
"""Integration tests for pricing models working together"""
def setUp(self):
"""Set up test data for integration tests"""
# Create cloud provider
self.cloud_provider = CloudProvider.objects.create(
name="VSHN Cloud",
slug="vshn-cloud",
description="Swiss cloud provider",
website="https://vshn.ch",
is_featured=True,
)
# Create service category
self.database_category = Category.objects.create(
name="Databases", slug="databases", description="Database services"
)
# Create database service
self.postgresql_service = Service.objects.create(
name="PostgreSQL",
slug="postgresql",
description="Managed PostgreSQL database service",
tagline="Reliable, scalable PostgreSQL",
features="High availability, automated backups, monitoring",
is_featured=True,
)
self.postgresql_service.categories.add(self.database_category)
# Create compute plan group
self.standard_group = ComputePlanGroup.objects.create(
name="Standard",
description="Standard compute plans",
node_label="standard",
order=1,
)
# Create multiple compute plans
self.small_plan = ComputePlan.objects.create(
name="Small",
vcpus=1.0,
ram=2.0,
cpu_mem_ratio=0.5,
cloud_provider=self.cloud_provider,
group=self.standard_group,
term=Term.MTH,
active=True,
)
self.medium_plan = ComputePlan.objects.create(
name="Medium",
vcpus=2.0,
ram=4.0,
cpu_mem_ratio=0.5,
cloud_provider=self.cloud_provider,
group=self.standard_group,
term=Term.MTH,
active=True,
)
self.large_plan = ComputePlan.objects.create(
name="Large",
vcpus=4.0,
ram=8.0,
cpu_mem_ratio=0.5,
cloud_provider=self.cloud_provider,
group=self.standard_group,
term=Term.MTH,
active=True,
)
# Create storage plan
self.ssd_storage = StoragePlan.objects.create(
name="SSD Storage",
cloud_provider=self.cloud_provider,
term=Term.MTH,
unit=Unit.GIB,
)
# Create progressive discount model for AppCat
self.ram_discount_model = ProgressiveDiscountModel.objects.create(
name="RAM Volume Discount",
description="Progressive discount for RAM usage",
active=True,
)
# Create discount tiers
DiscountTier.objects.create(
discount_model=self.ram_discount_model,
min_units=0,
max_units=8,
discount_percent=Decimal("0.00"), # 0-7 GiB: no discount
)
DiscountTier.objects.create(
discount_model=self.ram_discount_model,
min_units=8,
max_units=32,
discount_percent=Decimal("10.00"), # 8-31 GiB: 10% discount
)
DiscountTier.objects.create(
discount_model=self.ram_discount_model,
min_units=32,
max_units=None,
discount_percent=Decimal("20.00"), # 32+ GiB: 20% discount
)
def test_complete_pricing_setup(self):
"""Test complete pricing setup for all models"""
# Set up compute plan prices
ComputePlanPrice.objects.create(
compute_plan=self.small_plan, currency=Currency.CHF, amount=Decimal("50.00")
)
ComputePlanPrice.objects.create(
compute_plan=self.medium_plan,
currency=Currency.CHF,
amount=Decimal("100.00"),
)
ComputePlanPrice.objects.create(
compute_plan=self.large_plan,
currency=Currency.CHF,
amount=Decimal("200.00"),
)
# Set up storage pricing
StoragePlanPrice.objects.create(
storage_plan=self.ssd_storage,
currency=Currency.CHF,
amount=Decimal("0.20"), # 0.20 CHF per GiB
)
# Verify all prices are retrievable
self.assertEqual(self.small_plan.get_price(Currency.CHF), Decimal("50.00"))
self.assertEqual(self.medium_plan.get_price(Currency.CHF), Decimal("100.00"))
self.assertEqual(self.large_plan.get_price(Currency.CHF), Decimal("200.00"))
self.assertEqual(self.ssd_storage.get_price(Currency.CHF), Decimal("0.20"))
def test_multi_currency_pricing(self):
"""Test pricing in multiple currencies"""
# Set up prices in CHF, EUR, and USD
currencies_and_rates = [
(Currency.CHF, Decimal("100.00")),
(Currency.EUR, Decimal("95.00")),
(Currency.USD, Decimal("110.00")),
]
for currency, amount in currencies_and_rates:
ComputePlanPrice.objects.create(
compute_plan=self.medium_plan, currency=currency, amount=amount
)
# Verify all currencies are available
for currency, expected_amount in currencies_and_rates:
self.assertEqual(self.medium_plan.get_price(currency), expected_amount)
def test_appcat_service_with_complete_pricing(self):
"""Test complete AppCat service pricing with all features"""
# Create AppCat price configuration
appcat_price = VSHNAppCatPrice.objects.create(
service=self.postgresql_service,
variable_unit=VSHNAppCatPrice.VariableUnit.RAM,
term=Term.MTH,
discount_model=self.ram_discount_model,
ha_replica_min=1,
ha_replica_max=3,
public_display_enabled=True,
)
# Set up base fees for different currencies
base_fees = [
(Currency.CHF, Decimal("25.00")),
(Currency.EUR, Decimal("22.50")),
(Currency.USD, Decimal("27.50")),
]
for currency, amount in base_fees:
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=appcat_price, currency=currency, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=amount
)
# Set up unit rates for different service levels and currencies
unit_rates = [
(Currency.CHF, VSHNAppCatPrice.ServiceLevel.BEST_EFFORT, Decimal("3.5000")),
(Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED, Decimal("5.0000")),
(Currency.EUR, VSHNAppCatPrice.ServiceLevel.BEST_EFFORT, Decimal("3.2000")),
(Currency.EUR, VSHNAppCatPrice.ServiceLevel.GUARANTEED, Decimal("4.5000")),
(Currency.USD, VSHNAppCatPrice.ServiceLevel.BEST_EFFORT, Decimal("3.8000")),
(Currency.USD, VSHNAppCatPrice.ServiceLevel.GUARANTEED, Decimal("5.5000")),
]
for currency, service_level, amount in unit_rates:
VSHNAppCatUnitRate.objects.create(
vshn_appcat_price_config=appcat_price,
currency=currency,
service_level=service_level,
amount=amount,
)
# Create mandatory addon (backup)
backup_addon = VSHNAppCatAddon.objects.create(
vshn_appcat_price_config=appcat_price,
name="Automated Backup",
description="Daily automated backups with 30-day retention",
commercial_description="Never lose your data with automated daily backups",
addon_type=VSHNAppCatAddon.AddonType.BASE_FEE,
mandatory=True,
order=1,
)
VSHNAppCatAddonBaseFee.objects.create(
addon=backup_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("15.00")
)
# Create optional addon (monitoring)
monitoring_addon = VSHNAppCatAddon.objects.create(
vshn_appcat_price_config=appcat_price,
name="Advanced Monitoring",
description="Detailed monitoring with custom alerts",
commercial_description="Get insights into your database performance",
addon_type=VSHNAppCatAddon.AddonType.UNIT_RATE,
mandatory=False,
order=2,
)
VSHNAppCatAddonUnitRate.objects.create(
addon=monitoring_addon,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("0.5000"),
)
# Test price calculation scenarios
# Scenario 1: Small setup (4 GiB RAM, no discount)
result_small = appcat_price.calculate_final_price(
Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED, 4
)
# Base: 25 + (5 * 4) = 45
# Mandatory backup: 15
# Total: 60
self.assertEqual(result_small["total_price"], Decimal("60.00"))
self.assertEqual(result_small["addon_total"], Decimal("15.00"))
self.assertEqual(len(result_small["addon_breakdown"]), 1)
# Scenario 2: Medium setup (16 GiB RAM, partial discount)
result_medium = appcat_price.calculate_final_price(
Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED, 16
)
# First 8 GiB at full rate: 5 * 8 = 40
# Next 8 GiB at 90% (10% discount): 5 * 0.9 * 8 = 36
# Unit cost: 76
# Base: 25 + 76 = 101
# Mandatory backup: 15
# Total: 116
self.assertEqual(result_medium["total_price"], Decimal("116.00"))
# Scenario 3: Large setup with optional addon (40 GiB RAM, full discount tiers)
result_large = appcat_price.calculate_final_price(
Currency.CHF,
VSHNAppCatPrice.ServiceLevel.GUARANTEED,
40,
addon_ids=[monitoring_addon.id],
)
# First 8 GiB at full rate: 5 * 8 = 40
# Next 24 GiB at 90% (10% discount): 5 * 0.9 * 24 = 108
# Next 8 GiB at 80% (20% discount): 5 * 0.8 * 8 = 32
# Unit cost: 180
# Base: 25 + 180 = 205
# Mandatory backup: 15
# Optional monitoring: 0.5 * 40 = 20
# Total: 240
self.assertEqual(result_large["total_price"], Decimal("240.00"))
self.assertEqual(result_large["addon_total"], Decimal("35.00"))
self.assertEqual(len(result_large["addon_breakdown"]), 2)
def test_external_price_comparison_integration(self):
"""Test external price comparison with internal pricing"""
# Set up internal pricing
appcat_price = VSHNAppCatPrice.objects.create(
service=self.postgresql_service,
variable_unit=VSHNAppCatPrice.VariableUnit.RAM,
term=Term.MTH,
)
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=appcat_price,
currency=Currency.USD,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("30.00"),
)
VSHNAppCatUnitRate.objects.create(
vshn_appcat_price_config=appcat_price,
currency=Currency.USD,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("4.0000"),
)
# Create external competitor pricing
aws_provider = CloudProvider.objects.create(
name="AWS",
slug="aws",
description="Amazon Web Services",
website="https://aws.amazon.com",
)
external_price = ExternalPricePlans.objects.create(
plan_name="RDS PostgreSQL db.t3.medium",
description="AWS RDS PostgreSQL instance",
source="https://aws.amazon.com/rds/postgresql/pricing/",
cloud_provider=aws_provider,
service=self.postgresql_service,
vshn_appcat_price=appcat_price,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
currency=Currency.USD,
term=Term.MTH,
amount=Decimal("62.56"), # Monthly cost for db.t3.medium
vcpus=2.0,
ram=4.0, # 4 GiB RAM
storage=20.0, # 20 GiB storage included
competitor_sla="99.95%",
replicas=1,
)
# Compare internal vs external pricing for equivalent setup
internal_result = appcat_price.calculate_final_price(
Currency.USD,
VSHNAppCatPrice.ServiceLevel.GUARANTEED,
4, # 4 GiB RAM to match external offering
)
# Internal: 30 + (4 * 4) = 46 USD
internal_price = internal_result["total_price"]
external_price_amount = external_price.amount
self.assertEqual(internal_price, Decimal("46.00"))
self.assertEqual(external_price_amount, Decimal("62.56"))
# Verify our pricing is competitive
self.assertLess(internal_price, external_price_amount)
def test_service_availability_with_pricing(self):
"""Test service availability based on pricing configuration"""
# Create service with pricing but not enabled for public display
redis_service = Service.objects.create(
name="Redis",
slug="redis",
description="In-memory data store",
features="High performance caching",
)
redis_price = VSHNAppCatPrice.objects.create(
service=redis_service,
variable_unit=VSHNAppCatPrice.VariableUnit.RAM,
term=Term.MTH,
public_display_enabled=False, # Private pricing
)
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=redis_price,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("20.00"),
)
# Service should exist but not be publicly available for pricing
self.assertFalse(redis_price.public_display_enabled)
# Enable public display
redis_price.public_display_enabled = True
redis_price.save()
self.assertTrue(redis_price.public_display_enabled)
def test_pricing_model_relationships(self):
"""Test all pricing model relationships work correctly"""
# Verify cloud provider relationships
self.assertEqual(self.cloud_provider.compute_plans.count(), 3)
self.assertEqual(self.cloud_provider.storage_plans.count(), 1)
# Verify service relationships
self.assertTrue(hasattr(self.postgresql_service, "vshn_appcat_price"))
# Verify compute plan group relationships
self.assertEqual(self.standard_group.compute_plans.count(), 3)
# Create and verify discount model relationships
appcat_price = VSHNAppCatPrice.objects.create(
service=self.postgresql_service,
variable_unit=VSHNAppCatPrice.VariableUnit.RAM,
term=Term.MTH,
discount_model=self.ram_discount_model,
)
self.assertEqual(self.ram_discount_model.price_configs.count(), 1)
self.assertEqual(self.ram_discount_model.tiers.count(), 3)
# Test cascade deletions work properly
service_id = self.postgresql_service.id
appcat_price_id = appcat_price.id
# Delete service should cascade to appcat price
self.postgresql_service.delete()
with self.assertRaises(VSHNAppCatPrice.DoesNotExist):
VSHNAppCatPrice.objects.get(id=appcat_price_id)
def test_comprehensive_pricing_scenario(self):
"""Test a comprehensive real-world pricing scenario"""
# Company needs PostgreSQL with high availability
# Requirements: 16 GiB RAM, automated backups, monitoring, SSL
appcat_price = VSHNAppCatPrice.objects.create(
service=self.postgresql_service,
variable_unit=VSHNAppCatPrice.VariableUnit.RAM,
term=Term.MTH,
discount_model=self.ram_discount_model,
ha_replica_min=2,
ha_replica_max=3,
public_display_enabled=True,
)
# Set up pricing
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=appcat_price,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("40.00"), # Base fee for managed service
)
VSHNAppCatUnitRate.objects.create(
vshn_appcat_price_config=appcat_price,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("6.0000"), # CHF per GiB RAM
)
# Create all required addons
backup_addon = VSHNAppCatAddon.objects.create(
vshn_appcat_price_config=appcat_price,
name="Enterprise Backup",
addon_type=VSHNAppCatAddon.AddonType.BASE_FEE,
mandatory=True,
order=1,
)
VSHNAppCatAddonBaseFee.objects.create(
addon=backup_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00")
)
monitoring_addon = VSHNAppCatAddon.objects.create(
vshn_appcat_price_config=appcat_price,
name="Advanced Monitoring",
addon_type=VSHNAppCatAddon.AddonType.UNIT_RATE,
mandatory=False,
order=2,
)
VSHNAppCatAddonUnitRate.objects.create(
addon=monitoring_addon,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=Decimal("0.7500"),
)
ssl_addon = VSHNAppCatAddon.objects.create(
vshn_appcat_price_config=appcat_price,
name="SSL Certificate",
addon_type=VSHNAppCatAddon.AddonType.BASE_FEE,
mandatory=False,
order=3,
)
VSHNAppCatAddonBaseFee.objects.create(
addon=ssl_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("18.00")
)
# Calculate final price with all selected addons

View file

@ -81,6 +81,7 @@ class PricingTestMixin:
VSHNAppCatBaseFee.objects.create(
vshn_appcat_price_config=appcat_price,
currency=Currency.CHF,
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
amount=base_fee,
)

View file

@ -340,7 +340,7 @@ def generate_pricing_data(offering):
# Get pricing components
compute_plan_price = plan.get_price(currency)
base_fee = appcat_price.get_base_fee(currency)
base_fee = appcat_price.get_base_fee(currency, service_level)
unit_rate = appcat_price.get_unit_rate(currency, service_level)
# Skip if any pricing component is missing
@ -380,7 +380,7 @@ def generate_pricing_data(offering):
addon_price_per_unit = None
if addon.addon_type == "BF": # Base Fee
addon_price = addon.get_price(currency)
addon_price = addon.get_price(currency, service_level)
elif addon.addon_type == "UR": # Unit Rate
addon_price_per_unit = addon.get_price(currency, service_level)
if addon_price_per_unit:

View file

@ -60,7 +60,7 @@ def get_internal_cloud_provider_comparisons(
for similar_plan in similar_plans:
# Get pricing components for comparison plan
compare_plan_price = similar_plan.get_price(currency)
compare_base_fee = appcat_price.get_base_fee(currency)
compare_base_fee = appcat_price.get_base_fee(currency, service_level)
compare_unit_rate = appcat_price.get_unit_rate(currency, service_level)
# Skip if any pricing component is missing
@ -239,7 +239,7 @@ def pricelist(request):
# Get pricing components
compute_plan_price = plan.get_price(currency)
base_fee = appcat_price.get_base_fee(currency)
base_fee = appcat_price.get_base_fee(currency, service_level)
unit_rate = appcat_price.get_unit_rate(currency, service_level)
# Skip if any pricing component is missing
@ -340,7 +340,7 @@ def pricelist(request):
addon_price = None
if addon.addon_type == "BF": # Base Fee
addon_price = addon.get_price(currency)
addon_price = addon.get_price(currency, service_level)
elif addon.addon_type == "UR": # Unit Rate
addon_price_per_unit = addon.get_price(
currency, service_level