From 61cabd1b1e49e75914f8360856a7b645b0bfa91c Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 20 Jun 2025 17:40:38 +0200 Subject: [PATCH] implement plan pricing --- hub/services/admin/content.py | 2 +- hub/services/admin/services.py | 20 +- .../0037_remove_plan_pricing_planprice.py | 63 ++ hub/services/models/services.py | 36 +- hub/services/static/js/price-calculator.js | 885 +----------------- .../templates/services/offering_detail.html | 213 +---- hub/services/views/offerings.py | 230 +---- 7 files changed, 192 insertions(+), 1257 deletions(-) create mode 100644 hub/services/migrations/0037_remove_plan_pricing_planprice.py 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... -
- - - - - - -
-
-
-
+

Available Plans & Pricing

+
+ +
- - - - - -
-

Order Your Configuration

-
-
- {% embedded_contact_form source="Configuration Order" service=offering.service offering_id=offering.id %} -
-
+
+ + + + + + + + + + + +
Plan NameDescriptionPrice
{% 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} -- 2.47.3