Classic Plan Pricing #18

Merged
tobru merged 9 commits from plan-pricing2 into main 2025-06-23 14:27:16 +00:00
11 changed files with 444 additions and 66 deletions

View file

@ -2,7 +2,7 @@ name: Django Tests
on: on:
push: push:
branches: ["*"] branches: [main]
pull_request: pull_request:
jobs: jobs:

View file

@ -5,7 +5,14 @@ Admin classes for services and service offerings
from django.contrib import admin from django.contrib import admin
from django.utils.html import format_html 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): class ExternalLinkInline(admin.TabularInline):
@ -26,14 +33,25 @@ class ExternalLinkOfferingInline(admin.TabularInline):
ordering = ("order", "description") ordering = ("order", "description")
class PlanPriceInline(admin.TabularInline):
"""Inline admin for PlanPrice model"""
model = PlanPrice
extra = 1
fields = ("currency", "amount")
ordering = ("currency",)
class PlanInline(admin.StackedInline): class PlanInline(admin.StackedInline):
"""Inline admin for Plan model""" """Inline admin for Plan model with sortable ordering"""
model = Plan model = Plan
extra = 1 extra = 1
fieldsets = ( fieldsets = (
(None, {"fields": ("name", "description", "pricing", "plan_description")}), (None, {"fields": ("name", "description", "plan_description")}),
("Display Options", {"fields": ("is_best",)}),
) )
show_change_link = True # This allows clicking through to the Plan admin where prices can be managed
class OfferingInline(admin.StackedInline): class OfferingInline(admin.StackedInline):
@ -102,7 +120,59 @@ class ServiceAdmin(admin.ModelAdmin):
class ServiceOfferingAdmin(admin.ModelAdmin): class ServiceOfferingAdmin(admin.ModelAdmin):
"""Admin configuration for ServiceOffering model""" """Admin configuration for ServiceOffering model"""
list_display = ("service", "cloud_provider") list_display = ("service", "cloud_provider", "plan_count", "total_prices")
list_filter = ("service", "cloud_provider") list_filter = ("service", "cloud_provider")
search_fields = ("service__name", "cloud_provider__name", "description") search_fields = ("service__name", "cloud_provider__name", "description")
inlines = [ExternalLinkOfferingInline, PlanInline] inlines = [ExternalLinkOfferingInline, PlanInline]
def plan_count(self, obj):
"""Display number of plans for this offering"""
return obj.plans.count()
plan_count.short_description = "Plans"
def total_prices(self, obj):
"""Display total number of plan prices for this offering"""
total = sum(plan.plan_prices.count() for plan in obj.plans.all())
return f"{total} prices"
total_prices.short_description = "Total Prices"
@admin.register(Plan)
class PlanAdmin(admin.ModelAdmin):
"""Admin configuration for Plan model with sortable ordering"""
list_display = ("name", "offering", "is_best", "price_summary", "order")
list_filter = ("offering__service", "offering__cloud_provider", "is_best")
search_fields = ("name", "description", "offering__service__name")
list_editable = ("is_best",)
inlines = [PlanPriceInline]
fieldsets = (
(None, {"fields": ("name", "offering", "description", "plan_description")}),
("Display Options", {"fields": ("is_best", "order")}),
)
def price_summary(self, obj):
"""Display a summary of prices for this plan"""
prices = obj.plan_prices.all()
if prices:
price_strs = [f"{price.amount} {price.currency}" for price in prices]
return ", ".join(price_strs)
return "No prices set"
price_summary.short_description = "Prices"
@admin.register(PlanPrice)
class PlanPriceAdmin(admin.ModelAdmin):
"""Admin configuration for PlanPrice model"""
list_display = ("plan", "currency", "amount")
list_filter = (
"currency",
"plan__offering__service",
"plan__offering__cloud_provider",
)
search_fields = ("plan__name", "plan__offering__service__name")
ordering = ("plan__offering__service__name", "plan__name", "currency")

View file

@ -0,0 +1,63 @@
# Generated by Django 5.2 on 2025-06-23 07:58
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")},
},
),
]

View file

@ -0,0 +1,32 @@
# Generated by Django 5.2 on 2025-06-23 10:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0037_remove_plan_pricing_planprice"),
]
operations = [
migrations.AlterModelOptions(
name="plan",
options={"ordering": ["order", "name"]},
),
migrations.AddField(
model_name="plan",
name="is_best",
field=models.BooleanField(
default=False, help_text="Mark this plan as the best/recommended option"
),
),
migrations.AddField(
model_name="plan",
name="order",
field=models.PositiveIntegerField(
default=0,
help_text="Order of this plan in the offering (lower numbers appear first)",
),
),
]

View file

@ -5,7 +5,13 @@ from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from django_prose_editor.fields import ProseEditorField from django_prose_editor.fields import ProseEditorField
from .base import Category, ReusableText, ManagedServiceProvider, validate_image_size from .base import (
Category,
ReusableText,
ManagedServiceProvider,
validate_image_size,
Currency,
)
from .providers import CloudProvider from .providers import CloudProvider
@ -97,10 +103,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): class Plan(models.Model):
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
description = ProseEditorField(blank=True, null=True) description = ProseEditorField(blank=True, null=True)
pricing = ProseEditorField(blank=True, null=True)
plan_description = models.ForeignKey( plan_description = models.ForeignKey(
ReusableText, ReusableText,
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -112,16 +139,48 @@ class Plan(models.Model):
ServiceOffering, on_delete=models.CASCADE, related_name="plans" ServiceOffering, on_delete=models.CASCADE, related_name="plans"
) )
# Ordering and highlighting fields
order = models.PositiveIntegerField(
default=0,
help_text="Order of this plan in the offering (lower numbers appear first)",
)
is_best = models.BooleanField(
default=False, help_text="Mark this plan as the best/recommended option"
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
ordering = ["name"] ordering = ["order", "name"]
unique_together = [["offering", "name"]] unique_together = [["offering", "name"]]
def __str__(self): def __str__(self):
return f"{self.offering} - {self.name}" return f"{self.offering} - {self.name}"
def clean(self):
# Ensure only one plan per offering can be marked as "best"
if self.is_best:
existing_best = Plan.objects.filter(
offering=self.offering, is_best=True
).exclude(pk=self.pk)
if existing_best.exists():
from django.core.exceptions import ValidationError
raise ValidationError(
"Only one plan per offering can be marked as the best option."
)
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
def get_price(self, currency_code: str):
price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first()
if price_obj:
return price_obj.amount
return None
class ExternalLinkOffering(models.Model): class ExternalLinkOffering(models.Model):
offering = models.ForeignKey( offering = models.ForeignKey(

View file

@ -34,3 +34,36 @@
opacity: 1; opacity: 1;
} }
} }
/* Subtle styling for the best plan */
.card.border-success.border-2 {
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.1) !important;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.card.border-success.border-2:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(25, 135, 84, 0.15) !important;
}
/* Best choice badge styling */
.badge.bg-success {
background: linear-gradient(135deg, #198754 0%, #20c997 100%) !important;
border: 2px solid white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
white-space: nowrap;
font-size: 0.75rem;
padding: 0.5rem 0.75rem;
min-width: max-content;
}
/* Subtle enhancement for best plan button */
.btn-success.shadow {
transition: all 0.2s ease-in-out;
}
.btn-success.shadow:hover {
transform: translateY(-1px);
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.2) !important;
}

View file

@ -1835,3 +1835,21 @@ document.addEventListener('DOMContentLoaded', () => {
new PriceCalculator(); new PriceCalculator();
} }
}); });
function selectPlan(element) {
const planId = element.getAttribute('data-plan-id');
const planName = element.getAttribute('data-plan-name');
// Find the plan dropdown in the contact form
const planDropdown = document.getElementById('id_choice');
if (planDropdown) {
// Find the option with matching plan id and select it
for (let i = 0; i < planDropdown.options.length; i++) {
const optionValue = planDropdown.options[i].value;
if (optionValue.startsWith(planId + '|')) {
planDropdown.selectedIndex = i;
break;
}
}
}
}

View file

@ -1,12 +1,15 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %} {% load static %}
{% load contact_tags %} {% load contact_tags %}
{% load json_ld_tags %}
{% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %} {% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %}
{% block extra_js %} {% block extra_js %}
<script defer src="{% static "js/price-calculator.js" %}"></script> <script defer src="{% static "js/price-calculator.js" %}"></script>
<link rel="stylesheet" type="text/css" href='{% static "css/price-calculator.css" %}'> <link rel="stylesheet" type="text/css" href='{% static "css/price-calculator.css" %}'>
{% json_ld_structured_data %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -400,40 +403,102 @@
</div> </div>
{% elif offering.plans.all %} {% elif offering.plans.all %}
<!-- Traditional Plans --> <!-- Traditional Plans -->
<h3 class="fs-24 fw-semibold lh-1 mb-12">Available Plans</h3> <h3 class="fs-24 fw-semibold lh-1 mb-12">Choose your Plan</h3>
<div class="row"> <div class="bg-light rounded-4 p-4 mb-4">
{% for plan in offering.plans.all %} <div class="row">
<div class="col-12 col-lg-6 {% if not forloop.last %}mb-20 mb-lg-0{% endif %}"> {% for plan in offering.plans.all %}
<div class="bg-purple-50 rounded-16 border-all p-24"> <div class="col-12 {% if offering.plans.all|length == 1 %}col-lg-8 mx-auto{% elif offering.plans.all|length == 2 %}col-lg-6{% else %}col-lg-4{% endif %} mb-4">
<div class="bg-white border-all rounded-7 p-20 mb-20"> <div class="card h-100 {% if plan.is_best %}border-success border-2 shadow-sm{% else %}border-primary shadow-sm{% endif %} position-relative">
<h3 class="text-purple fs-22 fw-semibold lh-1-7 mb-0">{{ plan.name }}</h3> {% if plan.is_best %}
{% if plan.plan_description %} <!-- Best Plan Badge -->
<div class="text-black mb-20"> <div class="position-absolute top-0 start-50 translate-middle" style="z-index: 10;">
{{ plan.plan_description.text|safe }} <span class="badge bg-success px-3 py-2 fs-6 fw-bold shadow-sm text-nowrap">
<i class="bi bi-star-fill me-1"></i>Best Choice
</span>
</div> </div>
{% endif %} {% endif %}
{% if plan.description %} <div class="card-body pt-3 d-flex flex-column">
<div class="text-black mb-20"> <h5 class="card-title {% if plan.is_best %}text-success{% else %}text-primary{% endif %} mb-3 fw-bold">
{{ plan.description|safe }} <i class="bi bi-{% if plan.is_best %}award{% else %}box{% endif %} me-2"></i>{{ plan.name }}
</h5>
<!-- Plan Description -->
{% if plan.plan_description %}
<div class="mb-3">
<small class="text-muted">Description</small>
<div class="text-dark">
{{ plan.plan_description.text|safe }}
</div>
</div>
{% endif %}
{% if plan.description %}
<div class="mb-3">
<small class="text-muted">Details</small>
<div class="text-dark">
{{ plan.description|safe }}
</div>
</div>
{% endif %}
<!-- Pricing Information -->
{% if plan.plan_prices.exists %}
<div class="{% if plan.is_best %}border-top border-success{% else %}border-top{% endif %} pt-3 mt-3 flex-grow-1 d-flex flex-column">
<div class="mb-2">
<small class="{% if plan.is_best %}text-success fw-semibold{% else %}text-muted{% endif %}">Pricing</small>
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between mb-2">
<span>Monthly Price</span>
<div class="text-end">
{% for price in plan.plan_prices.all %}
<div class="fs-5 fw-bold {% if plan.is_best %}text-success{% else %}text-primary{% endif %}">{{ price.currency }} {{ price.amount }}</div>
{% endfor %}
</div>
</div>
</div>
<small class="text-muted mt-2 d-block">
<i class="bi bi-info-circle me-1"></i>
Prices exclude VAT. Monthly pricing based on 30 days.
</small>
</div>
{% else %}
<div class="{% if plan.is_best %}border-top border-success{% else %}border-top{% endif %} pt-3 mt-3 flex-grow-1 d-flex align-items-center justify-content-center">
<div class="text-center text-muted">
<i class="bi bi-envelope me-2"></i>Contact us for pricing details
</div>
</div>
{% endif %}
<!-- Plan Action Button -->
<div class="text-center mt-auto pt-3">
<a href="#plan-order-form" class="btn {% if plan.is_best %}btn-success btn-lg px-4 py-2 shadow{% else %}btn-primary btn-lg px-4 py-2{% endif %} fw-semibold w-100" data-plan-id="{{ plan.id }}" data-plan-name="{{ plan.name }}" onclick="selectPlan(this)">
<i class="bi bi-{% if plan.is_best %}star-fill{% else %}cart{% endif %} me-2"></i>{% if plan.is_best %}Select Best Plan{% else %}Select This Plan{% endif %}
</a>
</div>
</div> </div>
{% endif %}
{% if plan.pricing %}
<div class="text-black mb-20">
{{ plan.pricing|safe }}
</div>
{% endif %}
</div> </div>
</div> </div>
{% empty %}
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
<div class="alert alert-info">
<p>No plans available yet.</p>
<h4 class="mb-3">I'm interested in this offering</h4>
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
</div>
</div>
{% endfor %}
</div> </div>
{% empty %} </div>
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
<div class="alert alert-info"> <!-- Plan Order Forms -->
<p>No plans available yet.</p> <div id="plan-order-form" class="pt-40" style="scroll-margin-top: 30px;">
<h4 class="mb-3">I'm interested in this offering</h4> <h4 class="fs-22 fw-semibold lh-1 mb-12">Order Your Plan</h4>
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %} <div class="row">
<div class="col-12">
{% embedded_contact_form source="Plan Order" service=offering.service offering_id=offering.id choices=offering.plans.all choice_label="Select a Plan" %}
</div> </div>
</div> </div>
{% endfor %}
</div> </div>
{% else %} {% else %}
<!-- No Plans Available --> <!-- No Plans Available -->
@ -443,17 +508,6 @@
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %} {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
</div> </div>
{% endif %} {% endif %}
{% if offering.plans.exists and not pricing_data_by_group_and_service_level %}
<div id="form" class="pt-40">
<h4 class="fs-22 fw-semibold lh-1 mb-12">I'm interested in a plan</h4>
<div class="row">
<div class="col-12">
{% embedded_contact_form source="Plan Order" service=offering.service offering_id=offering.id choices=offering.plans.all choice_label="Select a Plan" %}
</div>
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View file

@ -209,7 +209,7 @@ def json_ld_structured_data(context):
data = { data = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Product", "@type": "Product",
"name": f"{offering.service.name} on {offering.cloud_provider.name}", "name": f"Managed {offering.service.name} on {offering.cloud_provider.name}",
"description": offering.description or offering.service.description, "description": offering.description or offering.service.description,
"url": offering_url, "url": offering_url,
"category": "Cloud Service", "category": "Cloud Service",
@ -224,18 +224,69 @@ def json_ld_structured_data(context):
# Add offers if available # Add offers if available
if hasattr(offering, "plans") and offering.plans.exists(): if hasattr(offering, "plans") and offering.plans.exists():
data["offers"] = { # Get all plans with pricing
"@type": "AggregateOffer", plans_with_prices = offering.plans.filter(
"availability": "https://schema.org/InStock", plan_prices__isnull=False
"offerCount": offering.plans.count(), ).distinct()
"seller": {
"@type": "Organization", if plans_with_prices.exists():
"name": offering.cloud_provider.name, # Create individual offers for each plan
"url": request.build_absolute_uri( offers = []
offering.cloud_provider.get_absolute_url() all_prices = []
),
}, for plan in plans_with_prices:
} plan_prices = plan.plan_prices.all()
if plan_prices.exists():
first_price = plan_prices.first()
all_prices.extend([p.amount for p in plan_prices])
offer = {
"@type": "Offer",
"name": plan.name,
"price": str(first_price.amount),
"priceCurrency": first_price.currency,
"availability": "https://schema.org/InStock",
"url": offering_url + "#plan-order-form",
"seller": {"@type": "Organization", "name": "VSHN"},
}
offers.append(offer)
# Add aggregate offer with all individual offers
data["offers"] = {
"@type": "AggregateOffer",
"availability": "https://schema.org/InStock",
"offerCount": len(offers),
"offers": offers,
"seller": {"@type": "Organization", "name": "VSHN"},
}
# Add lowPrice, highPrice and priceCurrency if we have prices
if all_prices:
data["offers"]["lowPrice"] = str(min(all_prices))
data["offers"]["highPrice"] = str(max(all_prices))
# Use the currency from the first plan's first price
first_plan_with_prices = plans_with_prices.first()
first_currency = first_plan_with_prices.plan_prices.first().currency
data["offers"]["priceCurrency"] = first_currency
# Note: aggregateRating and review fields are not included as this is a B2B
# service marketplace without a review system. These could be added in the future
# if customer reviews/ratings are implemented.
# Example structure for future implementation:
# if hasattr(offering, 'reviews') and offering.reviews.exists():
# data["aggregateRating"] = {
# "@type": "AggregateRating",
# "ratingValue": "4.5",
# "reviewCount": "10"
# }
else:
# No pricing available, just basic offer info
data["offers"] = {
"@type": "AggregateOffer",
"availability": "https://schema.org/InStock",
"offerCount": offering.plans.count(),
"seller": {"@type": "Organization", "name": "VSHN"},
}
else: else:
# Default to organization data if no specific page type matches # Default to organization data if no specific page type matches

View file

@ -1,6 +1,5 @@
from decimal import Decimal from decimal import Decimal
from django.test import TestCase from django.test import TestCase
from django.core.exceptions import ValidationError
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
@ -10,16 +9,12 @@ from ..models.services import Service
from ..models.pricing import ( from ..models.pricing import (
ComputePlan, ComputePlan,
ComputePlanPrice, ComputePlanPrice,
StoragePlan,
StoragePlanPrice,
ProgressiveDiscountModel, ProgressiveDiscountModel,
DiscountTier, DiscountTier,
VSHNAppCatPrice, VSHNAppCatPrice,
VSHNAppCatBaseFee, VSHNAppCatBaseFee,
VSHNAppCatUnitRate, VSHNAppCatUnitRate,
VSHNAppCatAddon, VSHNAppCatAddon,
VSHNAppCatAddonBaseFee,
VSHNAppCatAddonUnitRate,
ExternalPricePlans, ExternalPricePlans,
) )
@ -163,7 +158,8 @@ class PricingEdgeCasesTestCase(TestCase):
) )
# Should return None when price doesn't exist # Should return None when price doesn't exist
price = addon.get_price(Currency.CHF) # For BASE_FEE addons, service_level is required
price = addon.get_price(Currency.CHF, service_level="standard")
self.assertIsNone(price) self.assertIsNone(price)
def test_compute_plan_with_validity_dates(self): def test_compute_plan_with_validity_dates(self):

View file

@ -255,6 +255,8 @@ JAZZMIN_SETTINGS = {
"services.ProgressiveDiscountModel": "single", "services.ProgressiveDiscountModel": "single",
"services.VSHNAppCatPrice": "single", "services.VSHNAppCatPrice": "single",
"services.VSHNAppCatAddon": "single", "services.VSHNAppCatAddon": "single",
"services.ServiceOffering": "single",
"services.Plan": "single",
}, },
"related_modal_active": True, "related_modal_active": True,
} }