diff --git a/hub/services/admin/pricing.py b/hub/services/admin/pricing.py
index 6da4852..61f4836 100644
--- a/hub/services/admin/pricing.py
+++ b/hub/services/admin/pricing.py
@@ -25,6 +25,9 @@ from ..models import (
VSHNAppCatBaseFee,
VSHNAppCatPrice,
VSHNAppCatUnitRate,
+ VSHNAppCatAddon,
+ VSHNAppCatAddonBaseFee,
+ VSHNAppCatAddonUnitRate,
ProgressiveDiscountModel,
DiscountTier,
ExternalPricePlans,
@@ -297,6 +300,15 @@ class VSHNAppCatUnitRateInline(admin.TabularInline):
fields = ("currency", "service_level", "amount")
+class VSHNAppCatAddonInline(admin.TabularInline):
+ """Inline admin for VSHNAppCatAddon model within the VSHNAppCatPrice admin"""
+
+ model = VSHNAppCatAddon
+ extra = 1
+ fields = ("name", "addon_type", "mandatory", "active")
+ show_change_link = True
+
+
class DiscountTierInline(admin.TabularInline):
"""Inline admin for DiscountTier model"""
@@ -330,7 +342,7 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin):
)
list_filter = ("variable_unit", "service", "discount_model")
search_fields = ("service__name",)
- inlines = [VSHNAppCatBaseFeeInline, VSHNAppCatUnitRateInline]
+ inlines = [VSHNAppCatBaseFeeInline, VSHNAppCatUnitRateInline, VSHNAppCatAddonInline]
def admin_display_base_fees(self, obj):
"""Display base fees in admin list view"""
@@ -542,3 +554,84 @@ class ExternalPricePlansAdmin(ImportExportModelAdmin):
return f"{count} plan{'s' if count != 1 else ''}"
display_compare_to_count.short_description = "Compare To"
+
+
+class VSHNAppCatAddonBaseFeeInline(admin.TabularInline):
+ """Inline admin for VSHNAppCatAddonBaseFee model"""
+
+ model = VSHNAppCatAddonBaseFee
+ extra = 1
+ fields = ("currency", "amount")
+
+
+class VSHNAppCatAddonUnitRateInline(admin.TabularInline):
+ """Inline admin for VSHNAppCatAddonUnitRate model"""
+
+ model = VSHNAppCatAddonUnitRate
+ extra = 1
+ fields = ("currency", "service_level", "amount")
+
+
+class VSHNAppCatAddonInline(admin.TabularInline):
+ """Inline admin for VSHNAppCatAddon model within the VSHNAppCatPrice admin"""
+
+ model = VSHNAppCatAddon
+ extra = 1
+ fields = ("name", "addon_type", "mandatory", "active", "order")
+ show_change_link = True
+
+
+@admin.register(VSHNAppCatAddon)
+class VSHNAppCatAddonAdmin(admin.ModelAdmin):
+ """Admin configuration for VSHNAppCatAddon model"""
+
+ list_display = (
+ "name",
+ "vshn_appcat_price_config",
+ "addon_type",
+ "mandatory",
+ "active",
+ "display_pricing",
+ "order",
+ )
+ list_filter = (
+ "addon_type",
+ "mandatory",
+ "active",
+ "vshn_appcat_price_config__service",
+ )
+ search_fields = (
+ "name",
+ "description",
+ "commercial_description",
+ "vshn_appcat_price_config__service__name",
+ )
+ ordering = ("vshn_appcat_price_config__service__name", "order", "name")
+
+ # Different inlines based on addon type
+ inlines = [VSHNAppCatAddonBaseFeeInline, VSHNAppCatAddonUnitRateInline]
+
+ def display_pricing(self, obj):
+ """Display pricing information based on addon type"""
+ if obj.addon_type == "BF": # Base Fee
+ fees = obj.base_fees.all()
+ if not fees:
+ return "No base fees set"
+ return format_html(
+ "
".join([f"{fee.amount} {fee.currency}" for fee in fees])
+ )
+ elif obj.addon_type == "UR": # Unit Rate
+ rates = obj.unit_rates.all()
+ if not rates:
+ return "No unit rates set"
+ return format_html(
+ "
".join(
+ [
+ f"{rate.amount} {rate.currency} ({rate.get_service_level_display()})"
+ for rate in rates
+ ]
+ )
+ )
+ return "Unknown addon type"
+
+ display_pricing.short_description = "Pricing"
diff --git a/hub/services/migrations/0035_alter_article_image_vshnappcataddon_and_more.py b/hub/services/migrations/0035_alter_article_image_vshnappcataddon_and_more.py
new file mode 100644
index 0000000..a020a94
--- /dev/null
+++ b/hub/services/migrations/0035_alter_article_image_vshnappcataddon_and_more.py
@@ -0,0 +1,195 @@
+# Generated by Django 5.2 on 2025-06-19 13:53
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("services", "0034_article"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="article",
+ name="image",
+ field=models.ImageField(
+ help_text="Title picture for the article", upload_to="article_images/"
+ ),
+ ),
+ migrations.CreateModel(
+ name="VSHNAppCatAddon",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "name",
+ models.CharField(help_text="Name of the addon", max_length=100),
+ ),
+ (
+ "description",
+ models.TextField(
+ blank=True, help_text="Technical description of the addon"
+ ),
+ ),
+ (
+ "commercial_description",
+ models.TextField(
+ blank=True,
+ help_text="Commercial description displayed in the frontend",
+ ),
+ ),
+ (
+ "addon_type",
+ models.CharField(
+ choices=[("BF", "Base Fee"), ("UR", "Unit Rate")],
+ help_text="Type of addon pricing (fixed fee or per-unit)",
+ max_length=2,
+ ),
+ ),
+ (
+ "mandatory",
+ models.BooleanField(
+ default=False, help_text="Is this addon mandatory?"
+ ),
+ ),
+ (
+ "active",
+ models.BooleanField(
+ default=True,
+ help_text="Is this addon active and available for selection?",
+ ),
+ ),
+ (
+ "order",
+ models.IntegerField(
+ default=0, help_text="Display order in the frontend"
+ ),
+ ),
+ ("valid_from", models.DateTimeField(blank=True, null=True)),
+ ("valid_to", models.DateTimeField(blank=True, null=True)),
+ (
+ "vshn_appcat_price_config",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="addons",
+ to="services.vshnappcatprice",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Service Addon",
+ "ordering": ["order", "name"],
+ },
+ ),
+ migrations.CreateModel(
+ name="VSHNAppCatAddonBaseFee",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "currency",
+ models.CharField(
+ choices=[
+ ("CHF", "Swiss Franc"),
+ ("EUR", "Euro"),
+ ("USD", "US Dollar"),
+ ],
+ max_length=3,
+ ),
+ ),
+ (
+ "amount",
+ models.DecimalField(
+ decimal_places=2,
+ help_text="Base fee in the specified currency, excl. VAT",
+ max_digits=10,
+ ),
+ ),
+ (
+ "addon",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="base_fees",
+ to="services.vshnappcataddon",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Addon Base Fee",
+ "ordering": ["currency"],
+ "unique_together": {("addon", "currency")},
+ },
+ ),
+ migrations.CreateModel(
+ name="VSHNAppCatAddonUnitRate",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "currency",
+ models.CharField(
+ choices=[
+ ("CHF", "Swiss Franc"),
+ ("EUR", "Euro"),
+ ("USD", "US Dollar"),
+ ],
+ max_length=3,
+ ),
+ ),
+ (
+ "service_level",
+ models.CharField(
+ choices=[
+ ("BE", "Best Effort"),
+ ("GA", "Guaranteed Availability"),
+ ],
+ max_length=2,
+ ),
+ ),
+ (
+ "amount",
+ models.DecimalField(
+ decimal_places=4,
+ help_text="Price per unit in the specified currency and service level, excl. VAT",
+ max_digits=10,
+ ),
+ ),
+ (
+ "addon",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="unit_rates",
+ to="services.vshnappcataddon",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Addon Unit Rate",
+ "ordering": ["currency", "service_level"],
+ "unique_together": {("addon", "currency", "service_level")},
+ },
+ ),
+ ]
diff --git a/hub/services/models/pricing.py b/hub/services/models/pricing.py
index 42b2778..0d22ef2 100644
--- a/hub/services/models/pricing.py
+++ b/hub/services/models/pricing.py
@@ -1,4 +1,5 @@
from django.db import models
+from django.db.models import Q
from .base import Currency, Term, Unit
from .providers import CloudProvider
@@ -339,7 +340,11 @@ class VSHNAppCatPrice(models.Model):
return None
def calculate_final_price(
- self, currency_code: str, service_level: str, number_of_units: int
+ self,
+ currency_code: str,
+ service_level: str,
+ number_of_units: int,
+ addon_ids=None,
):
base_fee = self.get_base_fee(currency_code)
unit_rate = self.get_unit_rate(currency_code, service_level)
@@ -359,7 +364,49 @@ class VSHNAppCatPrice(models.Model):
else:
total_price = base_fee + (unit_rate * number_of_units)
- return total_price
+ # Add prices for mandatory addons and selected addons
+ addon_total = 0
+ addon_breakdown = []
+
+ # Query all active addons related to this price config
+ addons_query = self.addons.filter(active=True)
+
+ # Include mandatory addons and explicitly selected addons
+ if addon_ids:
+ addons = addons_query.filter(Q(mandatory=True) | Q(id__in=addon_ids))
+ else:
+ addons = addons_query.filter(mandatory=True)
+
+ for addon in addons:
+ addon_price = 0
+ if addon.addon_type == VSHNAppCatAddon.AddonType.BASE_FEE:
+ 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:
+ addon_price_value = addon.get_price(currency_code, service_level)
+ if addon_price_value:
+ addon_price = addon_price_value * number_of_units
+
+ addon_total += addon_price
+ addon_breakdown.append(
+ {
+ "id": addon.id,
+ "name": addon.name,
+ "description": addon.description,
+ "commercial_description": addon.commercial_description,
+ "mandatory": addon.mandatory,
+ "price": addon_price,
+ }
+ )
+
+ total_price += addon_total
+
+ return {
+ "total_price": total_price,
+ "addon_total": addon_total,
+ "addon_breakdown": addon_breakdown,
+ }
class VSHNAppCatUnitRate(models.Model):
@@ -389,6 +436,118 @@ class VSHNAppCatUnitRate(models.Model):
return f"{self.vshn_appcat_price_config.service.name} - {self.get_service_level_display()} Unit Rate - {self.amount} {self.currency}"
+class VSHNAppCatAddon(models.Model):
+ """
+ Addon pricing model for VSHNAppCatPrice. Can be added to a service price configuration
+ to provide additional features or resources with their own pricing.
+ """
+
+ class AddonType(models.TextChoices):
+ BASE_FEE = "BF", "Base Fee" # Fixed amount regardless of units
+ UNIT_RATE = "UR", "Unit Rate" # Price per unit
+
+ vshn_appcat_price_config = models.ForeignKey(
+ VSHNAppCatPrice, on_delete=models.CASCADE, related_name="addons"
+ )
+ name = models.CharField(max_length=100, help_text="Name of the addon")
+ description = models.TextField(
+ blank=True, help_text="Technical description of the addon"
+ )
+ commercial_description = models.TextField(
+ blank=True, help_text="Commercial description displayed in the frontend"
+ )
+ addon_type = models.CharField(
+ max_length=2,
+ choices=AddonType.choices,
+ help_text="Type of addon pricing (fixed fee or per-unit)",
+ )
+ mandatory = models.BooleanField(default=False, help_text="Is this addon mandatory?")
+ active = models.BooleanField(
+ default=True, help_text="Is this addon active and available for selection?"
+ )
+ order = models.IntegerField(default=0, help_text="Display order in the frontend")
+ valid_from = models.DateTimeField(blank=True, null=True)
+ valid_to = models.DateTimeField(blank=True, null=True)
+
+ class Meta:
+ verbose_name = "Service Addon"
+ ordering = ["order", "name"]
+
+ def __str__(self):
+ 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"""
+ try:
+ if self.addon_type == self.AddonType.BASE_FEE:
+ 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")
+ return self.unit_rates.get(
+ currency=currency_code, service_level=service_level
+ ).amount
+ except (
+ VSHNAppCatAddonBaseFee.DoesNotExist,
+ VSHNAppCatAddonUnitRate.DoesNotExist,
+ ):
+ return None
+
+
+class VSHNAppCatAddonBaseFee(models.Model):
+ """Base fee for an addon (fixed amount regardless of units)"""
+
+ addon = models.ForeignKey(
+ VSHNAppCatAddon, 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 = "Addon Base Fee"
+ unique_together = ("addon", "currency")
+ ordering = ["currency"]
+
+ def __str__(self):
+ return f"{self.addon.name} Base Fee - {self.amount} {self.currency}"
+
+
+class VSHNAppCatAddonUnitRate(models.Model):
+ """Unit rate for an addon (price per unit)"""
+
+ addon = models.ForeignKey(
+ VSHNAppCatAddon, on_delete=models.CASCADE, related_name="unit_rates"
+ )
+ currency = models.CharField(
+ max_length=3,
+ choices=Currency.choices,
+ )
+ service_level = models.CharField(
+ max_length=2,
+ choices=VSHNAppCatPrice.ServiceLevel.choices,
+ )
+ amount = models.DecimalField(
+ max_digits=10,
+ decimal_places=4,
+ help_text="Price per unit in the specified currency and service level, excl. VAT",
+ )
+
+ class Meta:
+ verbose_name = "Addon Unit Rate"
+ unique_together = ("addon", "currency", "service_level")
+ ordering = ["currency", "service_level"]
+
+ def __str__(self):
+ return f"{self.addon.name} - {self.get_service_level_display()} Unit Rate - {self.amount} {self.currency}"
+
+
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/offering_detail.html b/hub/services/templates/services/offering_detail.html
index f44dddc..1d5e5af 100644
--- a/hub/services/templates/services/offering_detail.html
+++ b/hub/services/templates/services/offering_detail.html
@@ -275,6 +275,14 @@
+
+
+