diff --git a/hub/services/admin/pricing.py b/hub/services/admin/pricing.py
index 61f4836..ac9711d 100644
--- a/hub/services/admin/pricing.py
+++ b/hub/services/admin/pricing.py
@@ -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(
- "
".join([f"{fee.amount} {fee.currency}" for fee in fees])
+ "
".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(
- "
".join([f"{fee.amount} {fee.currency}" for fee in fees])
+ "
".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()
diff --git a/hub/services/migrations/0036_alter_vshnappcataddonbasefee_options_and_more.py b/hub/services/migrations/0036_alter_vshnappcataddonbasefee_options_and_more.py
new file mode 100644
index 0000000..ba0fe26
--- /dev/null
+++ b/hub/services/migrations/0036_alter_vshnappcataddonbasefee_options_and_more.py
@@ -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")},
+ ),
+ ]
diff --git a/hub/services/models/pricing.py b/hub/services/models/pricing.py
index 0d22ef2..62ae0ba 100644
--- a/hub/services/models/pricing.py
+++ b/hub/services/models/pricing.py
@@ -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)
diff --git a/hub/services/tests/test_pricing.py b/hub/services/tests/test_pricing.py
index 150335d..30b2302 100644
--- a/hub/services/tests/test_pricing.py
+++ b/hub/services/tests/test_pricing.py
@@ -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
diff --git a/hub/services/tests/test_pricing_edge_cases.py b/hub/services/tests/test_pricing_edge_cases.py
index 87ad40d..8a59104 100644
--- a/hub/services/tests/test_pricing_edge_cases.py
+++ b/hub/services/tests/test_pricing_edge_cases.py
@@ -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
)
diff --git a/hub/services/tests/test_pricing_integration.py b/hub/services/tests/test_pricing_integration.py
index c9e3002..d7f54a0 100644
--- a/hub/services/tests/test_pricing_integration.py
+++ b/hub/services/tests/test_pricing_integration.py
@@ -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
diff --git a/hub/services/tests/test_utils.py b/hub/services/tests/test_utils.py
index 2ce8d31..47d429c 100644
--- a/hub/services/tests/test_utils.py
+++ b/hub/services/tests/test_utils.py
@@ -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,
)
diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py
index fc5c59e..e135aec 100644
--- a/hub/services/views/offerings.py
+++ b/hub/services/views/offerings.py
@@ -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:
diff --git a/hub/services/views/pricelist.py b/hub/services/views/pricelist.py
index cc3996a..d2586a1 100644
--- a/hub/services/views/pricelist.py
+++ b/hub/services/views/pricelist.py
@@ -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