diff --git a/hub/services/admin/content.py b/hub/services/admin/content.py
index 9d601ae..2b655d1 100644
--- a/hub/services/admin/content.py
+++ b/hub/services/admin/content.py
@@ -13,7 +13,7 @@ class PlanInline(admin.StackedInline):
model = Plan
extra = 1
fieldsets = (
- (None, {"fields": ("name", "description", "pricing", "plan_description")}),
+ (None, {"fields": ("name", "description", "plan_description")}),
)
diff --git a/hub/services/admin/services.py b/hub/services/admin/services.py
index 44f848b..f679c09 100644
--- a/hub/services/admin/services.py
+++ b/hub/services/admin/services.py
@@ -5,7 +5,7 @@ Admin classes for services and service offerings
from django.contrib import admin
from django.utils.html import format_html
-from ..models import Service, ServiceOffering, ExternalLink, ExternalLinkOffering, Plan
+from ..models import Service, ServiceOffering, ExternalLink, ExternalLinkOffering, Plan, PlanPrice
class ExternalLinkInline(admin.TabularInline):
@@ -32,7 +32,7 @@ class PlanInline(admin.StackedInline):
model = Plan
extra = 1
fieldsets = (
- (None, {"fields": ("name", "description", "pricing", "plan_description")}),
+ (None, {"fields": ("name", "description", "plan_description")}),
)
@@ -57,6 +57,18 @@ class OfferingInline(admin.StackedInline):
show_change_link = True
+class PlanPriceInline(admin.TabularInline):
+ model = PlanPrice
+ extra = 1
+
+
+class PlanAdmin(admin.ModelAdmin):
+ inlines = [PlanPriceInline]
+ list_display = ("name", "offering")
+ search_fields = ("name",)
+ list_filter = ("offering",)
+
+
@admin.register(Service)
class ServiceAdmin(admin.ModelAdmin):
"""Admin configuration for Service model"""
@@ -106,3 +118,7 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
list_filter = ("service", "cloud_provider")
search_fields = ("service__name", "cloud_provider__name", "description")
inlines = [ExternalLinkOfferingInline, PlanInline]
+
+
+admin.site.register(Plan, PlanAdmin)
+admin.site.register(PlanPrice)
diff --git a/hub/services/migrations/0037_remove_plan_pricing_planprice.py b/hub/services/migrations/0037_remove_plan_pricing_planprice.py
new file mode 100644
index 0000000..e5476f1
--- /dev/null
+++ b/hub/services/migrations/0037_remove_plan_pricing_planprice.py
@@ -0,0 +1,63 @@
+# Generated by Django 5.2 on 2025-06-20 15:28
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("services", "0036_alter_vshnappcataddonbasefee_options_and_more"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="plan",
+ name="pricing",
+ ),
+ migrations.CreateModel(
+ name="PlanPrice",
+ 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="Price in the specified currency, excl. VAT",
+ max_digits=10,
+ ),
+ ),
+ (
+ "plan",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="plan_prices",
+ to="services.plan",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["currency"],
+ "unique_together": {("plan", "currency")},
+ },
+ ),
+ ]
diff --git a/hub/services/models/services.py b/hub/services/models/services.py
index 5c155b8..aa2e1ba 100644
--- a/hub/services/models/services.py
+++ b/hub/services/models/services.py
@@ -4,10 +4,14 @@ from django.core.validators import URLValidator
from django.urls import reverse
from django.utils.text import slugify
from django_prose_editor.fields import ProseEditorField
+from typing import TYPE_CHECKING, Optional
-from .base import Category, ReusableText, ManagedServiceProvider, validate_image_size
+from .base import Category, ReusableText, ManagedServiceProvider, validate_image_size, Currency
from .providers import CloudProvider
+if TYPE_CHECKING:
+ from .services import PlanPrice
+
class Service(models.Model):
name = models.CharField(max_length=200)
@@ -97,10 +101,31 @@ class ServiceOffering(models.Model):
)
+class PlanPrice(models.Model):
+ plan = models.ForeignKey(
+ 'Plan', on_delete=models.CASCADE, related_name='plan_prices'
+ )
+ currency = models.CharField(
+ max_length=3,
+ choices=Currency.choices,
+ )
+ amount = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ help_text="Price in the specified currency, excl. VAT",
+ )
+
+ class Meta:
+ unique_together = ("plan", "currency")
+ ordering = ["currency"]
+
+ def __str__(self):
+ return f"{self.plan.name} - {self.amount} {self.currency}"
+
+
class Plan(models.Model):
name = models.CharField(max_length=100)
description = ProseEditorField(blank=True, null=True)
- pricing = ProseEditorField(blank=True, null=True)
plan_description = models.ForeignKey(
ReusableText,
on_delete=models.PROTECT,
@@ -122,6 +147,13 @@ class Plan(models.Model):
def __str__(self):
return f"{self.offering} - {self.name}"
+ def get_price(self, currency_code: str) -> Optional[float]:
+ from hub.services.models.services import PlanPrice
+ price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first()
+ if price_obj:
+ return price_obj.amount
+ return None
+
class ExternalLinkOffering(models.Model):
offering = models.ForeignKey(
diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js
index 54b2af9..90112b9 100644
--- a/hub/services/static/js/price-calculator.js
+++ b/hub/services/static/js/price-calculator.js
@@ -982,850 +982,6 @@ Please contact me with next steps for ordering this configuration.`;
if (this.instancesValue) this.instancesValue.textContent = '1';
}
}
-
- // Setup order button click handler
- setupOrderButton() {
- if (this.orderButton) {
- this.orderButton.addEventListener('click', (e) => {
- e.preventDefault();
- this.handleOrderClick();
- });
- }
- }
-
- // Handle order button click
- handleOrderClick() {
- if (this.selectedConfiguration) {
- // Pre-fill the contact form with configuration details
- this.prefillContactForm();
-
- // Scroll to the contact form
- const contactForm = document.getElementById('order-form');
- if (contactForm) {
- contactForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }
- }
- }
-
- // Pre-fill contact form with selected configuration
- prefillContactForm() {
- if (!this.selectedConfiguration) return;
-
- const config = this.selectedConfiguration;
-
- // Create configuration summary message
- const configMessage = this.generateConfigurationMessage(config);
-
- // Find and fill the message textarea in the contact form
- const messageField = document.querySelector('#order-form textarea[name="message"]');
- if (messageField) {
- messageField.value = configMessage;
- }
-
- // Store configuration details in hidden field
- const detailsField = document.querySelector('#order-form input[name="details"]');
- if (detailsField) {
- detailsField.value = JSON.stringify({
- plan: config.planName,
- vcpus: config.vcpus,
- memory: config.memory,
- storage: config.storage,
- instances: config.instances,
- serviceLevel: config.serviceLevel,
- totalPrice: config.totalPrice,
- addons: config.addons || []
- });
- }
- }
-
- // Generate human-readable configuration message
- generateConfigurationMessage(config) {
- let message = `I would like to order the following configuration:
-
-Plan: ${config.planName} (${config.planGroup})
-vCPUs: ${config.vcpus}
-Memory: ${config.memory} GB
-Storage: ${config.storage} GB
-Instances: ${config.instances}
-Service Level: ${config.serviceLevel}`;
-
- // Add addons to the message if any are selected
- if (config.addons && config.addons.length > 0) {
- message += '\n\nSelected Add-ons:';
- config.addons.forEach(addon => {
- message += `\n- ${addon.name}: CHF ${addon.price}`;
- });
- }
-
- message += `\n\nTotal Monthly Price: CHF ${config.totalPrice}
-
-Please contact me with next steps for ordering this configuration.`;
-
- return message;
- }
-
- // Load pricing data from API endpoint
- async loadPricingData() {
- try {
- const response = await fetch(`/offering/${this.currentOffering.provider_slug}/${this.currentOffering.service_slug}/?pricing=json`);
- if (!response.ok) {
- throw new Error('Failed to load pricing data');
- }
-
- const data = await response.json();
- this.pricingData = data.pricing || data;
-
- // Extract addons data from the plans - addons are embedded in each plan
- this.extractAddonsData();
-
- // Extract storage price from the first available plan
- this.extractStoragePrice();
-
- this.setupEventListeners();
- this.populatePlanDropdown();
- this.updateAddons();
- this.updatePricing();
- } catch (error) {
- console.error('Error loading pricing data:', error);
- this.showError('Failed to load pricing information');
- }
- }
-
- // Extract replica information and storage price from pricing data
- extractStoragePrice() {
- if (!this.pricingData) return;
-
- // Find the first plan with storage pricing data and replica info
- for (const groupName of Object.keys(this.pricingData)) {
- const group = this.pricingData[groupName];
- for (const serviceLevel of Object.keys(group)) {
- const plans = group[serviceLevel];
- if (plans.length > 0 && plans[0].storage_price !== undefined) {
- this.storagePrice = parseFloat(plans[0].storage_price);
- this.replicaInfo = {
- ha_replica_min: plans[0].ha_replica_min || 1,
- ha_replica_max: plans[0].ha_replica_max || 1
- };
- return;
- }
- }
- }
- }
-
- // Extract addons data from pricing plans
- extractAddonsData() {
- if (!this.pricingData) return;
-
- this.addonsData = {};
-
- // Extract addons from the first available plan for each service level
- Object.keys(this.pricingData).forEach(groupName => {
- const group = this.pricingData[groupName];
- Object.keys(group).forEach(serviceLevel => {
- const plans = group[serviceLevel];
- if (plans.length > 0) {
- // Use the first plan's addon data for this service level
- const plan = plans[0];
- const allAddons = [];
-
- // Add mandatory addons
- if (plan.mandatory_addons) {
- plan.mandatory_addons.forEach(addon => {
- allAddons.push({
- ...addon,
- is_mandatory: true,
- addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE"
- });
- });
- }
-
- // Add optional addons
- if (plan.optional_addons) {
- plan.optional_addons.forEach(addon => {
- allAddons.push({
- ...addon,
- is_mandatory: false,
- addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE"
- });
- });
- }
-
- this.addonsData[serviceLevel] = allAddons;
- }
- });
- });
- }
-
- // Setup event listeners for calculator controls
- setupEventListeners() {
- if (!this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return;
-
- // Setup service levels based on available data
- this.setupServiceLevels();
-
- // Slider event listeners
- this.cpuRange.addEventListener('input', () => {
- this.cpuValue.textContent = this.cpuRange.value;
- this.updatePricing();
- });
-
- this.memoryRange.addEventListener('input', () => {
- this.memoryValue.textContent = this.memoryRange.value;
- this.updatePricing();
- });
-
- this.storageRange.addEventListener('input', () => {
- this.storageValue.textContent = this.storageRange.value;
- this.updatePricing();
- });
-
- this.instancesRange.addEventListener('input', () => {
- this.instancesValue.textContent = this.instancesRange.value;
- this.updatePricing();
- });
-
- // Service level change listeners
- this.serviceLevelInputs.forEach(input => {
- input.addEventListener('change', () => {
- this.updateInstancesSlider();
- this.populatePlanDropdown();
- this.updateAddons();
- this.updatePricing();
- });
- });
-
- // Plan selection listener
- if (this.planSelect) {
- this.planSelect.addEventListener('change', () => {
- if (this.planSelect.value) {
- const selectedPlan = JSON.parse(this.planSelect.value);
-
- // Update sliders to match selected plan
- this.cpuRange.value = selectedPlan.vcpus;
- this.memoryRange.value = selectedPlan.ram;
- this.cpuValue.textContent = selectedPlan.vcpus;
- this.memoryValue.textContent = selectedPlan.ram;
-
- // Fade out CPU and Memory sliders since plan is manually selected
- this.fadeOutSliders(['cpu', 'memory']);
-
- // Update addons for the new configuration
- this.updateAddons();
- // Update pricing with the selected plan
- this.updatePricingWithPlan(selectedPlan);
- } else {
- // Auto-select mode - reset sliders to default values
- this.resetSlidersToDefaults();
-
- // Auto-select mode - fade sliders back in
- this.fadeInSliders(['cpu', 'memory']);
-
- // Auto-select mode - update addons and recalculate
- this.updateAddons();
- this.updatePricing();
- }
- });
- }
-
- // Initialize instances slider
- this.updateInstancesSlider();
- }
-
- // Update instances slider based on service level and replica info
- updateInstancesSlider() {
- if (!this.instancesRange || !this.replicaInfo) return;
-
- const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
-
- if (serviceLevel === 'Guaranteed Availability') {
- // For GA, min is ha_replica_min
- this.instancesRange.min = this.replicaInfo.ha_replica_min;
- this.instancesRange.value = Math.max(this.instancesRange.value, this.replicaInfo.ha_replica_min);
- } else {
- // For BE, min is 1
- this.instancesRange.min = 1;
- this.instancesRange.value = Math.max(this.instancesRange.value, 1);
- }
-
- // Set max to ha_replica_max
- this.instancesRange.max = this.replicaInfo.ha_replica_max;
-
- // Update display value
- this.instancesValue.textContent = this.instancesRange.value;
-
- // Update the min/max display under the slider using direct IDs
- const instancesMinDisplay = document.getElementById('instancesMinDisplay');
- const instancesMaxDisplay = document.getElementById('instancesMaxDisplay');
-
- if (instancesMinDisplay) instancesMinDisplay.textContent = this.instancesRange.min;
- if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.instancesRange.max;
- }
-
- // Setup service levels dynamically from pricing data
- setupServiceLevels() {
- if (!this.pricingData) return;
-
- const serviceLevelGroup = document.getElementById('serviceLevelGroup');
- if (!serviceLevelGroup) return;
-
- // Get all available service levels from the pricing data
- const availableServiceLevels = new Set();
- Object.keys(this.pricingData).forEach(groupName => {
- const group = this.pricingData[groupName];
- Object.keys(group).forEach(serviceLevel => {
- availableServiceLevels.add(serviceLevel);
- });
- });
-
- // Clear existing service level buttons
- serviceLevelGroup.innerHTML = '';
-
- // Create buttons for each available service level
- let isFirst = true;
- availableServiceLevels.forEach(serviceLevel => {
- const inputId = `serviceLevel${serviceLevel.replace(/\s+/g, '')}`;
-
- // Create radio input
- const input = document.createElement('input');
- input.type = 'radio';
- input.className = 'btn-check';
- input.name = 'serviceLevel';
- input.id = inputId;
- input.value = serviceLevel;
- if (isFirst) {
- input.checked = true;
- isFirst = false;
- }
-
- // Create label
- const label = document.createElement('label');
- label.className = 'btn btn-outline-primary';
- label.setAttribute('for', inputId);
- label.textContent = serviceLevel;
-
- // Add event listener
- input.addEventListener('change', () => {
- this.updateInstancesSlider();
- this.populatePlanDropdown();
- this.updateAddons();
- this.updatePricing();
- });
-
- serviceLevelGroup.appendChild(input);
- serviceLevelGroup.appendChild(label);
- });
-
- // Update the serviceLevelInputs reference
- this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
-
- // Calculate and set slider maximums based on available plans - this will call updateSliderDisplayValues()
- this.updateSliderMaximums();
- }
-
- // Calculate maximum values for sliders based on available plans
- updateSliderMaximums() {
- if (!this.pricingData || !this.cpuRange || !this.memoryRange) return;
-
- let maxCpus = 0;
- let maxMemory = 0;
-
- // Find maximum CPU and memory across all plans
- Object.keys(this.pricingData).forEach(groupName => {
- const group = this.pricingData[groupName];
- Object.keys(group).forEach(serviceLevel => {
- group[serviceLevel].forEach(plan => {
- const planCpus = parseFloat(plan.vcpus);
- const planMemory = parseFloat(plan.ram);
-
- if (planCpus > maxCpus) maxCpus = planCpus;
- if (planMemory > maxMemory) maxMemory = planMemory;
- });
- });
- });
-
- // Set slider maximums with some padding
- if (maxCpus > 0) {
- this.cpuRange.max = Math.ceil(maxCpus);
- }
-
- if (maxMemory > 0) {
- this.memoryRange.max = Math.ceil(maxMemory);
- }
-
- // Update display values after changing min/max - moved to end and call explicitly
- this.updateSliderDisplayValues();
- }
-
- // Populate plan dropdown based on selected service level
- populatePlanDropdown() {
- if (!this.planSelect || !this.pricingData) return;
-
- const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
- if (!serviceLevel) return;
-
- // Clear existing options
- this.planSelect.innerHTML = '';
-
- // Collect all plans for selected service level
- const availablePlans = [];
- Object.keys(this.pricingData).forEach(groupName => {
- const group = this.pricingData[groupName];
- if (group[serviceLevel]) {
- group[serviceLevel].forEach(plan => {
- availablePlans.push({
- ...plan,
- groupName: groupName
- });
- });
- }
- });
-
- // Sort plans by vCPU, then by RAM
- availablePlans.sort((a, b) => {
- if (parseInt(a.vcpus) !== parseInt(b.vcpus)) {
- return parseInt(a.vcpus) - parseInt(b.vcpus);
- }
- return parseInt(a.ram) - parseInt(b.ram);
- });
-
- // Add plans to dropdown
- availablePlans.forEach(plan => {
- const option = document.createElement('option');
- option.value = JSON.stringify(plan);
- option.textContent = `${plan.compute_plan} - ${plan.vcpus} vCPUs, ${plan.ram} GB RAM`;
- this.planSelect.appendChild(option);
- });
- }
-
- // Update addons based on current configuration
- updateAddons() {
- if (!this.addonsContainer || !this.addonsData) {
- // Hide addons section if no container or data
- const addonsSection = document.getElementById('addonsSection');
- if (addonsSection) addonsSection.style.display = 'none';
- return;
- }
-
- const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
- if (!serviceLevel || !this.addonsData[serviceLevel]) {
- // Hide addons section if no service level or no addons for this level
- const addonsSection = document.getElementById('addonsSection');
- if (addonsSection) addonsSection.style.display = 'none';
- return;
- }
-
- const addons = this.addonsData[serviceLevel];
-
- // Clear existing addons
- this.addonsContainer.innerHTML = '';
-
- // Show or hide addons section based on availability
- const addonsSection = document.getElementById('addonsSection');
- if (addons && addons.length > 0) {
- if (addonsSection) addonsSection.style.display = 'block';
- } else {
- if (addonsSection) addonsSection.style.display = 'none';
- return;
- }
-
- // Add each addon
- addons.forEach(addon => {
- const addonElement = document.createElement('div');
- addonElement.className = `addon-item mb-2 p-2 border rounded ${addon.is_mandatory ? 'bg-light' : ''}`;
-
- addonElement.innerHTML = `
-
- `;
-
- this.addonsContainer.appendChild(addonElement);
-
- // Add event listener for optional addons
- if (!addon.is_mandatory) {
- const checkbox = addonElement.querySelector('.addon-checkbox');
- checkbox.addEventListener('change', () => {
- // Update addon prices and recalculate total
- this.updateAddonPrices();
- this.updatePricing();
- });
- }
- });
-
- // Update addon prices
- this.updateAddonPrices();
- } // Update addon prices based on current configuration
- updateAddonPrices() {
- if (!this.addonsContainer) return;
-
- const cpus = parseInt(this.cpuRange?.value || 2);
- const memory = parseInt(this.memoryRange?.value || 4);
- const storage = parseInt(this.storageRange?.value || 20);
- const instances = parseInt(this.instancesRange?.value || 1);
-
- // Find the current plan data to get variable_unit for addon calculations
- const matchedPlan = this.getCurrentPlan();
- const variableUnit = matchedPlan?.variable_unit || 'CPU';
- const units = variableUnit === 'CPU' ? cpus : memory;
- const totalUnits = units * instances;
-
- const addonCheckboxes = this.addonsContainer.querySelectorAll('.addon-checkbox');
- addonCheckboxes.forEach(checkbox => {
- const addon = JSON.parse(checkbox.dataset.addon);
- const priceElement = checkbox.parentElement.querySelector('.addon-price-value');
-
- let calculatedPrice = 0;
-
- // Calculate addon price based on type
- if (addon.addon_type === 'BASE_FEE') {
- // Base fee: price per instance
- calculatedPrice = parseFloat(addon.price || 0) * instances;
- } else if (addon.addon_type === 'UNIT_RATE') {
- // Unit rate: price per unit (CPU or memory) across all instances
- calculatedPrice = parseFloat(addon.price_per_unit || 0) * totalUnits;
- }
-
- // Update the display price
- if (priceElement) {
- priceElement.textContent = calculatedPrice.toFixed(2);
- }
-
- // Store the calculated price for later use in total calculations
- checkbox.dataset.calculatedPrice = calculatedPrice.toString();
- });
- }
-
- // Get current plan based on configuration
- getCurrentPlan() {
- const cpus = parseInt(this.cpuRange?.value || 2);
- const memory = parseInt(this.memoryRange?.value || 4);
- const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
-
- if (this.planSelect?.value) {
- return JSON.parse(this.planSelect.value);
- }
-
- return this.findBestMatchingPlan(cpus, memory, serviceLevel);
- }
-
- // Find best matching plan based on requirements
- findBestMatchingPlan(cpus, memory, serviceLevel) {
- if (!this.pricingData) return null;
-
- let bestMatch = null;
- let bestScore = Infinity;
-
- // Iterate through all groups and service levels
- Object.keys(this.pricingData).forEach(groupName => {
- const group = this.pricingData[groupName];
-
- if (group[serviceLevel]) {
- group[serviceLevel].forEach(plan => {
- const planCpus = parseInt(plan.vcpus);
- const planMemory = parseInt(plan.ram);
-
- // Check if plan meets minimum requirements
- if (planCpus >= cpus && planMemory >= memory) {
- // Calculate efficiency score (lower is better)
- const cpuOverhead = planCpus - cpus;
- const memoryOverhead = planMemory - memory;
- const score = cpuOverhead + memoryOverhead + plan.final_price * 0.1;
-
- if (score < bestScore) {
- bestScore = score;
- bestMatch = {
- ...plan,
- groupName: groupName
- };
- }
- }
- });
- }
- });
-
- return bestMatch;
- }
-
- // Update pricing with specific plan
- updatePricingWithPlan(selectedPlan) {
- const storage = parseInt(this.storageRange?.value || 20);
- const instances = parseInt(this.instancesRange?.value || 1);
-
- // Update addon prices first to ensure calculated prices are current
- this.updateAddonPrices();
-
- this.showPlanDetails(selectedPlan, storage, instances);
- this.updateStatusMessage('Plan selected directly!', 'success');
- }
-
- // Main pricing update function
- updatePricing() {
- if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return;
-
- // Update addon prices first to ensure they're current
- this.updateAddonPrices();
-
- // Reset plan selection if in auto-select mode
- if (!this.planSelect?.value) {
- const cpus = parseInt(this.cpuRange.value);
- const memory = parseInt(this.memoryRange.value);
- const storage = parseInt(this.storageRange.value);
- const instances = parseInt(this.instancesRange.value);
- const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
-
- if (!serviceLevel) return;
-
- // Find best matching plan
- const matchedPlan = this.findBestMatchingPlan(cpus, memory, serviceLevel);
-
- if (matchedPlan) {
- this.showPlanDetails(matchedPlan, storage, instances);
- this.updateStatusMessage('Perfect match found!', 'success');
- } else {
- this.showNoMatch();
- }
- } else {
- // Plan is directly selected, update storage pricing
- const selectedPlan = JSON.parse(this.planSelect.value);
- const storage = parseInt(this.storageRange.value);
- const instances = parseInt(this.instancesRange.value);
-
- // Update addon prices for current configuration
- this.updateAddonPrices();
- this.showPlanDetails(selectedPlan, storage, instances);
- this.updateStatusMessage('Plan selected directly!', 'success');
- }
- }
-
- // Show plan details in the UI
- showPlanDetails(plan, storage, instances) {
- if (!this.selectedPlanDetails) return;
-
- // Show plan details section
- this.planMatchStatus.style.display = 'block';
- this.selectedPlanDetails.style.display = 'block';
- if (this.noMatchFound) this.noMatchFound.style.display = 'none';
-
- // Get current service level
- const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value || 'Best Effort';
-
- // Update plan information
- if (this.planGroup) this.planGroup.textContent = plan.groupName;
- if (this.planName) this.planName.textContent = plan.compute_plan;
- if (this.planDescription) this.planDescription.textContent = plan.compute_plan_group_description || '';
- if (this.planCpus) this.planCpus.textContent = plan.vcpus;
- if (this.planMemory) this.planMemory.textContent = plan.ram + ' GB';
- if (this.planInstances) this.planInstances.textContent = instances;
- if (this.planServiceLevel) this.planServiceLevel.textContent = serviceLevel;
-
- // Ensure addon prices are calculated with current configuration
- this.updateAddonPrices();
-
- // Calculate pricing using final price from plan data (which already includes mandatory addons)
- // plan.final_price = compute_plan_price + sla_price (where sla_price includes mandatory addons)
- const managedServicePricePerInstance = parseFloat(plan.final_price);
-
- // Collect addon information for display and calculation
- let mandatoryAddonTotal = 0;
- let optionalAddonTotal = 0;
- const mandatoryAddons = [];
- const selectedOptionalAddons = [];
-
- if (this.addonsContainer) {
- const addonCheckboxes = this.addonsContainer.querySelectorAll('.addon-checkbox');
- addonCheckboxes.forEach(checkbox => {
- const addon = JSON.parse(checkbox.dataset.addon);
- const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0);
-
- if (addon.is_mandatory) {
- // Mandatory addons are already included in plan.final_price
- // We collect them for display purposes only
- mandatoryAddons.push({
- name: addon.name,
- price: calculatedPrice.toFixed(2)
- });
- } else if (checkbox.checked) {
- // Only count checked optional addons
- optionalAddonTotal += calculatedPrice;
- selectedOptionalAddons.push({
- name: addon.name,
- price: calculatedPrice.toFixed(2)
- });
- }
- });
- }
-
- const managedServicePrice = managedServicePricePerInstance * instances;
-
- // Use storage price from plan data or fallback to instance variable
- const storageUnitPrice = plan.storage_price !== undefined ? parseFloat(plan.storage_price) : this.storagePrice;
- const storagePriceValue = storage * storageUnitPrice * instances;
-
- // Total price = managed service price (includes mandatory addons) + storage + optional addons
- const totalPriceValue = managedServicePrice + storagePriceValue + optionalAddonTotal;
-
- // Update pricing display
- if (this.managedServicePrice) this.managedServicePrice.textContent = managedServicePrice.toFixed(2);
- if (this.storagePriceEl) this.storagePriceEl.textContent = storagePriceValue.toFixed(2);
- if (this.storageAmount) this.storageAmount.textContent = storage;
- if (this.totalPrice) this.totalPrice.textContent = totalPriceValue.toFixed(2);
-
- // Update addon pricing display
- this.updateAddonPricingDisplay(mandatoryAddons, selectedOptionalAddons);
-
- // Store current configuration for order button
- this.selectedConfiguration = {
- planName: plan.compute_plan,
- planGroup: plan.groupName,
- vcpus: plan.vcpus,
- memory: plan.ram,
- storage: storage,
- instances: instances,
- serviceLevel: serviceLevel,
- totalPrice: totalPriceValue.toFixed(2),
- addons: [...mandatoryAddons, ...selectedOptionalAddons]
- };
- }
-
- // Update addon pricing display in the results panel
- updateAddonPricingDisplay(mandatoryAddons, selectedOptionalAddons) {
- if (!this.addonPricingContainer) return;
-
- // Clear existing addon pricing display
- this.addonPricingContainer.innerHTML = '';
-
- // Add mandatory addons to pricing breakdown (for informational purposes only)
- if (mandatoryAddons && mandatoryAddons.length > 0) {
- // Add a note explaining mandatory addons are included
- const mandatoryNote = document.createElement('div');
- mandatoryNote.className = 'text-muted small mb-2';
- mandatoryNote.innerHTML = 'Required add-ons (included in managed service price):';
- this.addonPricingContainer.appendChild(mandatoryNote);
-
- mandatoryAddons.forEach(addon => {
- const addonRow = document.createElement('div');
- addonRow.className = 'd-flex justify-content-between mb-1 ps-3';
- addonRow.innerHTML = `
- ${addon.name}
- CHF ${addon.price}
- `;
- this.addonPricingContainer.appendChild(addonRow);
- });
-
- // Add separator if there are also optional addons
- if (selectedOptionalAddons && selectedOptionalAddons.length > 0) {
- const separator = document.createElement('hr');
- separator.className = 'my-2';
- this.addonPricingContainer.appendChild(separator);
- }
- }
-
- // Add optional addons to pricing breakdown (these are added to total)
- if (selectedOptionalAddons && selectedOptionalAddons.length > 0) {
- selectedOptionalAddons.forEach(addon => {
- const addonRow = document.createElement('div');
- addonRow.className = 'd-flex justify-content-between mb-2';
- addonRow.innerHTML = `
- Add-on: ${addon.name}
- CHF ${addon.price}
- `;
- this.addonPricingContainer.appendChild(addonRow);
- });
- }
- }
-
- // Show no matching plan found
- showNoMatch() {
- if (this.planMatchStatus) this.planMatchStatus.style.display = 'none';
- if (this.selectedPlanDetails) this.selectedPlanDetails.style.display = 'none';
- if (this.noMatchFound) this.noMatchFound.style.display = 'block';
- }
-
- // Update status message
- updateStatusMessage(message, type) {
- if (!this.planMatchStatus) return;
-
- const iconClass = type === 'success' ? 'bi-check-circle' : 'bi-info-circle';
- const textClass = type === 'success' ? 'text-success' : '';
- const alertClass = type === 'success' ? 'alert-success' : 'alert-info';
-
- this.planMatchStatus.innerHTML = `${message}`;
- this.planMatchStatus.className = `alert ${alertClass} mb-3`;
- this.planMatchStatus.style.display = 'block';
- }
-
- // Show error message
- showError(message) {
- if (this.planMatchStatus) {
- this.planMatchStatus.innerHTML = `${message}`;
- this.planMatchStatus.className = 'alert alert-danger mb-3';
- this.planMatchStatus.style.display = 'block';
- }
- }
-
- // Fade out specified sliders when plan is manually selected
- fadeOutSliders(sliderTypes) {
- sliderTypes.forEach(type => {
- const sliderContainer = this.getSliderContainer(type);
- if (sliderContainer) {
- sliderContainer.style.transition = 'opacity 0.3s ease-in-out';
- sliderContainer.style.opacity = '0.3';
- sliderContainer.style.pointerEvents = 'none';
-
- // Add visual indicator that sliders are disabled
- const slider = sliderContainer.querySelector('.form-range');
- if (slider) {
- slider.style.cursor = 'not-allowed';
- }
- }
- });
- }
-
- // Fade in specified sliders when auto-select mode is chosen
- fadeInSliders(sliderTypes) {
- sliderTypes.forEach(type => {
- const sliderContainer = this.getSliderContainer(type);
- if (sliderContainer) {
- sliderContainer.style.transition = 'opacity 0.3s ease-in-out';
- sliderContainer.style.opacity = '1';
- sliderContainer.style.pointerEvents = 'auto';
-
- // Remove visual indicator
- const slider = sliderContainer.querySelector('.form-range');
- if (slider) {
- slider.style.cursor = 'pointer';
- }
- }
- });
- }
-
- // Get slider container element by type
- getSliderContainer(type) {
- switch (type) {
- case 'cpu':
- return this.cpuRange?.closest('.mb-4');
- case 'memory':
- return this.memoryRange?.closest('.mb-4');
- case 'storage':
- return this.storageRange?.closest('.mb-4');
- case 'instances':
- return this.instancesRange?.closest('.mb-4');
- default:
- return null;
- }
- }
}
// Initialize calculator when DOM is loaded
@@ -1834,4 +990,45 @@ document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('cpuRange')) {
new PriceCalculator();
}
+});
+
+// New simple plan table population for multi-currency pricing
+
+document.addEventListener('DOMContentLoaded', function() {
+ const currencySelect = document.getElementById('currencySelect');
+ const plansTableBody = document.querySelector('#plansTable tbody');
+ if (!currencySelect || !plansTableBody) return;
+
+ let allPlans = [];
+
+ function fetchPlans() {
+ fetch(window.location.pathname + '?pricing=json')
+ .then(response => response.json())
+ .then(data => {
+ allPlans = data.plans || [];
+ updateTable();
+ });
+ }
+
+ function updateTable() {
+ const selectedCurrency = currencySelect.value;
+ plansTableBody.innerHTML = '';
+ const filtered = allPlans.filter(plan => plan.currency === selectedCurrency);
+ if (filtered.length === 0) {
+ plansTableBody.innerHTML = 'No plans available in this currency. |
';
+ return;
+ }
+ filtered.forEach(plan => {
+ const row = document.createElement('tr');
+ row.innerHTML = `
+ ${plan.plan_name} |
+ ${plan.description || ''} |
+ ${plan.amount.toFixed(2)} ${plan.currency} |
+ `;
+ plansTableBody.appendChild(row);
+ });
+ }
+
+ currencySelect.addEventListener('change', updateTable);
+ fetchPlans();
});
\ No newline at end of file
diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html
index 000d13d..8d97ada 100644
--- a/hub/services/templates/services/offering_detail.html
+++ b/hub/services/templates/services/offering_detail.html
@@ -204,199 +204,28 @@
{% if offering.msp == "VS" and price_calculator_enabled and pricing_data_by_group_and_service_level %}
-
-
Choose your Plan
-
-
-
-
-
-
-
-
-
-
-
- 1
- 32
-
-
-
-
-
-
-
-
- 1 GB
- 128 GB
-
-
-
-
-
-
-
-
- 10 GB
- 1000 GB
-
-
-
-
-
-
-
-
- 1
- 1
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Selecting a plan will override the slider configuration
-
Interested in a custom plan? Let us know via the contact form.
-
-
-
-
-
-
-
-
-
-
Your Plan
-
-
-
-
- Finding best matching plan...
-
-
-
-
-
-
-
-
-
-
-
-
-
- Managed Service (incl. Compute)
- CHF 0.00
-
-
- Storage - 20 GB
- CHF 0.00
-
-
-
-
-
-
-
-
-
- Total Monthly Price
- CHF 0.00
-
-
-
- Monthly pricing based on 30 days (720 hours). Billing is conducted per hour.
-
-
-
-
-
-
-
- No matching plan found for your requirements. Please adjust your configuration.
-
-
-
-
-
+
Available Plans & Pricing
+
+
+
-
-
-
-
-
-
-
Order Your Configuration
-
-
- {% embedded_contact_form source="Configuration Order" service=offering.service offering_id=offering.id %}
-
-
+
+
+
+
+ Plan Name |
+ Description |
+ Price |
+
+
+
+
+
+
{% elif offering.plans.all %}
diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py
index e135aec..fe01c7c 100644
--- a/hub/services/views/offerings.py
+++ b/hub/services/views/offerings.py
@@ -242,221 +242,19 @@ def generate_exoscale_marketplace_yaml(offering):
def generate_pricing_data(offering):
- """Generate pricing data for a specific offering and cloud provider"""
- # Fetch compute plans for this cloud provider
- compute_plans = (
- ComputePlan.objects.filter(active=True, cloud_provider=offering.cloud_provider)
- .select_related("cloud_provider", "group")
- .prefetch_related("prices")
- .order_by("group__order", "group__name")
- )
+ """Generate pricing data for a specific offering and its plans with multi-currency support"""
+ # Fetch all plans for this offering
+ plans = offering.plans.prefetch_related("plan_prices")
- # Apply natural sorting for compute plan names
- compute_plans = sorted(
- compute_plans,
- key=lambda x: (
- x.group.order if x.group else 999,
- x.group.name if x.group else "ZZZ",
- natural_sort_key(x.name),
- ),
- )
+ pricing_data = []
+ for plan in plans:
+ for plan_price in plan.plan_prices.all():
+ pricing_data.append({
+ "plan_id": plan.id,
+ "plan_name": plan.name,
+ "description": plan.description,
+ "currency": plan_price.currency,
+ "amount": float(plan_price.amount),
+ })
- # Fetch storage plans for this cloud provider
- storage_plans = (
- StoragePlan.objects.filter(cloud_provider=offering.cloud_provider)
- .prefetch_related("prices")
- .order_by("name")
- )
-
- # Get default storage pricing (use first available storage plan)
- storage_price_data = {}
- if storage_plans.exists():
- default_storage_plan = storage_plans.first()
- for currency in ["CHF", "EUR", "USD"]: # Add currencies as needed
- price = default_storage_plan.get_price(currency)
- if price is not None:
- storage_price_data[currency] = price
-
- # Fetch pricing for this specific service
- try:
- appcat_price = (
- VSHNAppCatPrice.objects.select_related("service", "discount_model")
- .prefetch_related("base_fees", "unit_rates", "discount_model__tiers")
- .get(service=offering.service)
- )
- except VSHNAppCatPrice.DoesNotExist:
- return None
-
- pricing_data_by_group_and_service_level = defaultdict(lambda: defaultdict(list))
- processed_combinations = set()
-
- # Generate pricing combinations for each compute plan
- for plan in compute_plans:
- plan_currencies = set(plan.prices.values_list("currency", flat=True))
-
- # Determine units based on variable unit type
- if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
- units = int(plan.ram)
- elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU:
- units = int(plan.vcpus)
- else:
- continue
-
- base_fee_currencies = set(
- appcat_price.base_fees.values_list("currency", flat=True)
- )
-
- service_levels = appcat_price.unit_rates.values_list(
- "service_level", flat=True
- ).distinct()
-
- for service_level in service_levels:
- unit_rate_currencies = set(
- appcat_price.unit_rates.filter(service_level=service_level).values_list(
- "currency", flat=True
- )
- )
-
- # Find currencies that exist across all pricing components
- matching_currencies = plan_currencies.intersection(
- base_fee_currencies
- ).intersection(unit_rate_currencies)
-
- if not matching_currencies:
- continue
-
- for currency in matching_currencies:
- combination_key = (
- plan.name,
- service_level,
- currency,
- )
-
- # Skip if combination already processed
- if combination_key in processed_combinations:
- continue
-
- processed_combinations.add(combination_key)
-
- # Get pricing components
- compute_plan_price = plan.get_price(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
- if any(
- price is None for price in [compute_plan_price, base_fee, unit_rate]
- ):
- continue
-
- # Calculate replica enforcement based on service level
- if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
- replica_enforce = appcat_price.ha_replica_min
- else:
- replica_enforce = 1
-
- total_units = units * replica_enforce
- standard_sla_price = base_fee + (total_units * unit_rate)
-
- # Apply discount if available
- if appcat_price.discount_model and appcat_price.discount_model.active:
- discounted_price = appcat_price.discount_model.calculate_discount(
- unit_rate, total_units
- )
- sla_price = base_fee + discounted_price
- else:
- sla_price = standard_sla_price
-
- # Get addons information
- addons = appcat_price.addons.filter(active=True)
- mandatory_addons = []
- optional_addons = []
-
- # Calculate additional price from mandatory addons
- addon_total = 0
-
- for addon in addons:
- addon_price = None
- addon_price_per_unit = None
-
- if addon.addon_type == "BF": # Base Fee
- 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:
- addon_price = addon_price_per_unit * total_units
-
- addon_info = {
- "id": addon.id,
- "name": addon.name,
- "description": addon.description,
- "commercial_description": addon.commercial_description,
- "addon_type": addon.get_addon_type_display(),
- "price": addon_price,
- "price_per_unit": addon_price_per_unit, # Add per-unit price for frontend calculations
- }
-
- if addon.mandatory:
- mandatory_addons.append(addon_info)
- if addon_price:
- addon_total += addon_price
- sla_price += addon_price
- else:
- optional_addons.append(addon_info)
-
- final_price = compute_plan_price + sla_price
- service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
- service_level
- ]
-
- group_name = plan.group.name if plan.group else "No Group"
-
- # Add pricing data to the grouped structure
- pricing_data_by_group_and_service_level[group_name][
- service_level_display
- ].append(
- {
- "compute_plan": plan.name,
- "compute_plan_group": group_name,
- "compute_plan_group_description": (
- plan.group.description if plan.group else ""
- ),
- "vcpus": plan.vcpus,
- "ram": plan.ram,
- "currency": currency,
- "compute_plan_price": compute_plan_price,
- "sla_price": sla_price,
- "final_price": final_price,
- "storage_price": storage_price_data.get(currency, 0),
- "ha_replica_min": appcat_price.ha_replica_min,
- "ha_replica_max": appcat_price.ha_replica_max,
- "variable_unit": appcat_price.variable_unit,
- "units": units,
- "total_units": total_units,
- "mandatory_addons": mandatory_addons,
- "optional_addons": optional_addons,
- }
- )
-
- # Order groups correctly, placing "No Group" last
- ordered_groups = {}
- all_group_names = list(pricing_data_by_group_and_service_level.keys())
-
- if "No Group" in all_group_names:
- all_group_names.remove("No Group")
- all_group_names.append("No Group")
-
- for group_name_key in all_group_names:
- ordered_groups[group_name_key] = pricing_data_by_group_and_service_level[
- group_name_key
- ]
-
- # Convert defaultdicts to regular dicts for the template
- final_context_data = {}
- for group_key, service_levels_dict in ordered_groups.items():
- final_context_data[group_key] = {
- sl_key: list(plans_list)
- for sl_key, plans_list in service_levels_dict.items()
- }
-
- return final_context_data
+ return {"plans": pricing_data}