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