diff --git a/hub/services/admin/pricing.py b/hub/services/admin/pricing.py index ac9711d..61f4836 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", "service_level", "amount") + fields = ("currency", "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} ({fee.get_service_level_display()})" for fee in fees]) + "
".join([f"{fee.amount} {fee.currency}" 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", "service_level", "amount") + fields = ("currency", "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} ({fee.get_service_level_display()})" for fee in fees]) + "
".join([f"{fee.amount} {fee.currency}" 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 deleted file mode 100644 index ba0fe26..0000000 --- a/hub/services/migrations/0036_alter_vshnappcataddonbasefee_options_and_more.py +++ /dev/null @@ -1,79 +0,0 @@ -# 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 62ae0ba..0d22ef2 100644 --- a/hub/services/models/pricing.py +++ b/hub/services/models/pricing.py @@ -250,6 +250,29 @@ 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)" @@ -302,6 +325,12 @@ 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( @@ -317,7 +346,7 @@ class VSHNAppCatPrice(models.Model): number_of_units: int, addon_ids=None, ): - base_fee = self.get_base_fee(currency_code, service_level) + base_fee = self.get_base_fee(currency_code) unit_rate = self.get_unit_rate(currency_code, service_level) if base_fee is None or unit_rate is None: @@ -351,7 +380,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, service_level) + addon_price_value = addon.get_price(currency_code) if addon_price_value: addon_price = addon_price_value elif addon.addon_type == VSHNAppCatAddon.AddonType.UNIT_RATE: @@ -379,15 +408,6 @@ 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( @@ -457,16 +477,10 @@ 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. - For base fee addons, service_level is required and used. - For unit rate addons, service_level is required and used. - """ + """Get the price for this addon in the specified currency and service level""" try: if self.addon_type == self.AddonType.BASE_FEE: - 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 + return self.base_fees.get(currency=currency_code).amount elif self.addon_type == self.AddonType.UNIT_RATE: if not service_level: raise ValueError("Service level is required for unit rate addons") @@ -481,9 +495,8 @@ class VSHNAppCatAddon(models.Model): class VSHNAppCatAddonBaseFee(models.Model): - """ - Base fee for an addon (fixed amount regardless of units), specified per currency and service level. - """ + """Base fee for an addon (fixed amount regardless of units)""" + addon = models.ForeignKey( VSHNAppCatAddon, on_delete=models.CASCADE, related_name="base_fees" ) @@ -491,27 +504,19 @@ 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 and service level, excl. VAT", + help_text="Base fee in the specified currency, excl. VAT", ) class Meta: verbose_name = "Addon Base Fee" - unique_together = ("addon", "currency", "service_level") - ordering = ["currency", "service_level"] + unique_together = ("addon", "currency") + ordering = ["currency"] def __str__(self): - 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) + return f"{self.addon.name} Base Fee - {self.amount} {self.currency}" class VSHNAppCatAddonUnitRate(models.Model): @@ -543,40 +548,6 @@ 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/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html index 08cf877..f01109a 100644 --- a/hub/services/templates/services/pricelist.html +++ b/hub/services/templates/services/pricelist.html @@ -228,16 +228,22 @@
diff --git a/hub/services/tests/test_pricing.py b/hub/services/tests/test_pricing.py index 30b2302..150335d 100644 --- a/hub/services/tests/test_pricing.py +++ b/hub/services/tests/test_pricing.py @@ -303,15 +303,14 @@ 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, VSHNAppCatPrice.ServiceLevel.GUARANTEED) + retrieved_fee = self.price_config.get_base_fee(Currency.CHF) self.assertEqual(retrieved_fee, Decimal("50.00")) # Test non-existent currency - non_existent_fee = self.price_config.get_base_fee(Currency.EUR, VSHNAppCatPrice.ServiceLevel.GUARANTEED) + non_existent_fee = self.price_config.get_base_fee(Currency.EUR) self.assertIsNone(non_existent_fee) def test_unit_rate_creation_and_retrieval(self): @@ -340,7 +339,6 @@ 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"), ) @@ -366,7 +364,6 @@ 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"), ) @@ -395,7 +392,6 @@ 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"), ) @@ -417,7 +413,6 @@ 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"), ) @@ -436,7 +431,6 @@ 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"), ) @@ -479,7 +473,6 @@ 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"), ) @@ -502,11 +495,11 @@ class VSHNAppCatAddonTestCase(TestCase): # Create base fee for addon VSHNAppCatAddonBaseFee.objects.create( - addon=addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00") + addon=addon, currency=Currency.CHF, amount=Decimal("25.00") ) # Test get_price method - price = addon.get_price(Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED) + price = addon.get_price(Currency.CHF) self.assertEqual(price, Decimal("25.00")) def test_addon_unit_rate_type(self): @@ -560,7 +553,7 @@ class VSHNAppCatAddonTestCase(TestCase): ) VSHNAppCatAddonBaseFee.objects.create( - addon=mandatory_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00") + addon=mandatory_addon, currency=Currency.CHF, amount=Decimal("25.00") ) # Create mandatory unit rate addon @@ -601,7 +594,7 @@ class VSHNAppCatAddonTestCase(TestCase): ) VSHNAppCatAddonBaseFee.objects.create( - addon=optional_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("15.00") + addon=optional_addon, currency=Currency.CHF, 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 8a59104..87ad40d 100644 --- a/hub/services/tests/test_pricing_edge_cases.py +++ b/hub/services/tests/test_pricing_edge_cases.py @@ -127,7 +127,6 @@ class PricingEdgeCasesTestCase(TestCase): VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=price_config, currency=Currency.CHF, - service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("50.00"), ) @@ -209,7 +208,6 @@ 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 d7f54a0..c9e3002 100644 --- a/hub/services/tests/test_pricing_integration.py +++ b/hub/services/tests/test_pricing_integration.py @@ -204,10 +204,7 @@ class PricingIntegrationTestCase(TestCase): for currency, amount in base_fees: VSHNAppCatBaseFee.objects.create( - 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 + vshn_appcat_price_config=appcat_price, currency=currency, amount=amount ) # Set up unit rates for different service levels and currencies @@ -240,7 +237,7 @@ class PricingIntegrationTestCase(TestCase): ) VSHNAppCatAddonBaseFee.objects.create( - addon=backup_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("15.00") + addon=backup_addon, currency=Currency.CHF, amount=Decimal("15.00") ) # Create optional addon (monitoring) @@ -320,7 +317,6 @@ class PricingIntegrationTestCase(TestCase): VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=appcat_price, currency=Currency.USD, - service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("30.00"), ) @@ -394,7 +390,6 @@ class PricingIntegrationTestCase(TestCase): VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=redis_price, currency=Currency.CHF, - service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("20.00"), ) @@ -459,7 +454,6 @@ 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 ) @@ -480,7 +474,7 @@ class PricingIntegrationTestCase(TestCase): ) VSHNAppCatAddonBaseFee.objects.create( - addon=backup_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00") + addon=backup_addon, currency=Currency.CHF, amount=Decimal("25.00") ) monitoring_addon = VSHNAppCatAddon.objects.create( @@ -507,541 +501,7 @@ class PricingIntegrationTestCase(TestCase): ) 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 - 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") + addon=ssl_addon, currency=Currency.CHF, 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 47d429c..2ce8d31 100644 --- a/hub/services/tests/test_utils.py +++ b/hub/services/tests/test_utils.py @@ -81,7 +81,6 @@ 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 e135aec..fc5c59e 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, service_level) + base_fee = appcat_price.get_base_fee(currency) 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, service_level) + addon_price = addon.get_price(currency) 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 add5b22..cc3996a 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, service_level) + compare_base_fee = appcat_price.get_base_fee(currency) 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, service_level) + base_fee = appcat_price.get_base_fee(currency) 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, service_level) + addon_price = addon.get_price(currency) elif addon.addon_type == "UR": # Unit Rate addon_price_per_unit = addon.get_price( currency, service_level @@ -544,12 +544,6 @@ def pricelist(request): all_compute_plan_groups.append("No Group") # Add option for plans without groups all_service_levels = [choice[1] for choice in VSHNAppCatPrice.ServiceLevel.choices] - # If no filter is specified, select the first available provider/service by default - if not filter_cloud_provider and all_cloud_providers: - filter_cloud_provider = all_cloud_providers[0] - if not filter_service and all_services: - filter_service = all_services[0] - context = { "pricing_data_by_group_and_service_level": final_context_data, "show_discount_details": show_discount_details,