From 84c0220af06755f1d867b4ef97064a1dcc897177 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 4 Dec 2025 16:43:59 +0100 Subject: [PATCH] Add billing annotations and tests --- src/servala/core/models/service.py | 46 +++++- src/tests/test_compute_plans.py | 219 ++++++++++++++++++++++++++++- 2 files changed, 258 insertions(+), 7 deletions(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 3465d54..906f0d8 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -715,10 +715,18 @@ class ServiceInstance(ServalaModelMixin, models.Model): return spec_data @staticmethod - def _build_billing_annotations(compute_plan_assignment, control_plane): + def _build_billing_annotations( + compute_plan_assignment, + control_plane, + instance_name=None, + organization=None, + service=None, + ): """ Build Kubernetes annotations for billing integration. """ + from servala.core.models.organization import InvoiceGroupingChoice + annotations = {} if compute_plan_assignment: @@ -738,6 +746,30 @@ class ServiceInstance(ServalaModelMixin, models.Model): control_plane.storage_plan_odoo_unit_id ) + if organization and instance_name: + invoice_grouping = organization.origin.invoice_grouping + cloud_provider_name = control_plane.cloud_provider.name + control_plane_name = control_plane.name + + if invoice_grouping == InvoiceGroupingChoice.BY_SERVICE: + if service: + annotations["servala.com/erp_item_group_description"] = ( + f"Servala Service: {service.name}" + ) + annotations["servala.com/erp_item_description"] = ( + f"{instance_name} on {cloud_provider_name} {control_plane_name}" + ) + else: + group_description = f"Organization: {organization.name}" + item_description = f"{instance_name} on {control_plane_name}" + + if organization.osb_guid: + group_description += f" ({organization.osb_guid})" + item_description += f" [Org: {organization.osb_guid}]" + + annotations["servala.com/erp_item_group_description"] = group_description + annotations["servala.com/erp_item_description"] = item_description + return annotations @classmethod @@ -846,7 +878,11 @@ class ServiceInstance(ServalaModelMixin, models.Model): } annotations = cls._build_billing_annotations( - compute_plan_assignment, context.control_plane + compute_plan_assignment=compute_plan_assignment, + control_plane=context.control_plane, + instance_name=name, + organization=organization, + service=context.service_offering.service, ) if annotations: create_data["metadata"]["annotations"] = annotations @@ -900,7 +936,11 @@ class ServiceInstance(ServalaModelMixin, models.Model): patch_body = {"spec": spec_data} annotations = self._build_billing_annotations( - plan_to_use, self.context.control_plane + compute_plan_assignment=plan_to_use, + control_plane=self.context.control_plane, + instance_name=self.name, + organization=self.organization, + service=self.context.service_offering.service, ) if annotations: patch_body["metadata"] = {"annotations": annotations} diff --git a/src/tests/test_compute_plans.py b/src/tests/test_compute_plans.py index 0317229..1954176 100644 --- a/src/tests/test_compute_plans.py +++ b/src/tests/test_compute_plans.py @@ -5,6 +5,7 @@ import pytest from servala.core.models import ( ComputePlan, ComputePlanAssignment, + InvoiceGroupingChoice, ServiceInstance, ) @@ -125,7 +126,8 @@ def test_build_billing_annotations_complete(): control_plane.storage_plan_odoo_unit_id = "storage-unit-id" annotations = ServiceInstance._build_billing_annotations( - compute_plan_assignment, control_plane + compute_plan_assignment=compute_plan_assignment, + control_plane=control_plane, ) assert annotations["servala.com/erp_product_id_resource"] == "test-product-123" @@ -140,7 +142,10 @@ def test_build_billing_annotations_no_compute_plan(): control_plane.storage_plan_odoo_product_id = "storage-product-id" control_plane.storage_plan_odoo_unit_id = "storage-unit-id" - annotations = ServiceInstance._build_billing_annotations(None, control_plane) + annotations = ServiceInstance._build_billing_annotations( + compute_plan_assignment=None, + control_plane=control_plane, + ) assert "servala.com/erp_product_id_resource" not in annotations assert "servala.com/erp_unit_id_resource" not in annotations @@ -158,7 +163,8 @@ def test_build_billing_annotations_no_storage_plan(): control_plane.storage_plan_odoo_unit_id = None annotations = ServiceInstance._build_billing_annotations( - compute_plan_assignment, control_plane + compute_plan_assignment=compute_plan_assignment, + control_plane=control_plane, ) assert annotations["servala.com/erp_product_id_resource"] == "product-id" @@ -172,7 +178,10 @@ def test_build_billing_annotations_empty(): control_plane.storage_plan_odoo_product_id = None control_plane.storage_plan_odoo_unit_id = None - annotations = ServiceInstance._build_billing_annotations(None, control_plane) + annotations = ServiceInstance._build_billing_annotations( + compute_plan_assignment=None, + control_plane=control_plane, + ) assert annotations == {} @@ -197,3 +206,205 @@ def test_all_billing_units(): assert str(choices["day"]) == "Day" assert "Month" in str(choices["month"]) assert str(choices["year"]) == "Year" + + +def test_build_billing_annotations_by_service_grouping(): + control_plane = Mock() + control_plane.storage_plan_odoo_product_id = None + control_plane.storage_plan_odoo_unit_id = None + control_plane.cloud_provider = Mock() + control_plane.cloud_provider.name = "Exoscale" + control_plane.name = "Geneva (CH-GVA-2)" + + organization = Mock() + organization.name = "ACME Corp" + organization.osb_guid = "01998651-dc86-7d43-9e49-cdb790fcc4f0" + organization.origin = Mock() + organization.origin.invoice_grouping = InvoiceGroupingChoice.BY_SERVICE + + service = Mock() + service.name = "Redis" + + annotations = ServiceInstance._build_billing_annotations( + compute_plan_assignment=None, + control_plane=control_plane, + instance_name="MyProdRedis", + organization=organization, + service=service, + ) + + assert ( + annotations["servala.com/erp_item_group_description"] + == "Servala Service: Redis" + ) + assert ( + annotations["servala.com/erp_item_description"] + == "MyProdRedis on Exoscale Geneva (CH-GVA-2)" + ) + + +def test_build_billing_annotations_by_organization_grouping_with_osb_guid(): + control_plane = Mock() + control_plane.storage_plan_odoo_product_id = None + control_plane.storage_plan_odoo_unit_id = None + control_plane.cloud_provider = Mock() + control_plane.cloud_provider.name = "Exoscale" + control_plane.name = "Geneva (CH-GVA-2)" + + organization = Mock() + organization.name = "ACME" + organization.osb_guid = "01998651-dc86-7d43-9e49-cdb790fcc4f0" + organization.origin = Mock() + organization.origin.invoice_grouping = InvoiceGroupingChoice.BY_ORGANIZATION + + service = Mock() + service.name = "Redis" + + annotations = ServiceInstance._build_billing_annotations( + compute_plan_assignment=None, + control_plane=control_plane, + instance_name="MyProdRedis", + organization=organization, + service=service, + ) + + assert ( + annotations["servala.com/erp_item_group_description"] + == "Organization: ACME (01998651-dc86-7d43-9e49-cdb790fcc4f0)" + ) + assert ( + annotations["servala.com/erp_item_description"] + == "MyProdRedis on Geneva (CH-GVA-2) [Org: 01998651-dc86-7d43-9e49-cdb790fcc4f0]" + ) + + +def test_build_billing_annotations_by_organization_grouping_without_osb_guid(): + control_plane = Mock() + control_plane.storage_plan_odoo_product_id = None + control_plane.storage_plan_odoo_unit_id = None + control_plane.cloud_provider = Mock() + control_plane.cloud_provider.name = "Exoscale" + control_plane.name = "Geneva (CH-GVA-2)" + + organization = Mock() + organization.name = "ACME Corp" + organization.osb_guid = None # No OSB GUID + organization.origin = Mock() + organization.origin.invoice_grouping = InvoiceGroupingChoice.BY_ORGANIZATION + + service = Mock() + service.name = "Redis" + + annotations = ServiceInstance._build_billing_annotations( + compute_plan_assignment=None, + control_plane=control_plane, + instance_name="MyProdRedis", + organization=organization, + service=service, + ) + + assert ( + annotations["servala.com/erp_item_group_description"] + == "Organization: ACME Corp" + ) + assert ( + annotations["servala.com/erp_item_description"] + == "MyProdRedis on Geneva (CH-GVA-2)" + ) + + +def test_build_billing_annotations_by_organization_grouping_with_empty_osb_guid(): + control_plane = Mock() + control_plane.storage_plan_odoo_product_id = None + control_plane.storage_plan_odoo_unit_id = None + control_plane.cloud_provider = Mock() + control_plane.cloud_provider.name = "Exoscale" + control_plane.name = "Geneva (CH-GVA-2)" + + organization = Mock() + organization.name = "ACME Corp" + organization.osb_guid = "" # Empty string OSB GUID + organization.origin = Mock() + organization.origin.invoice_grouping = InvoiceGroupingChoice.BY_ORGANIZATION + + service = Mock() + service.name = "Redis" + + annotations = ServiceInstance._build_billing_annotations( + compute_plan_assignment=None, + control_plane=control_plane, + instance_name="MyProdRedis", + organization=organization, + service=service, + ) + + assert ( + annotations["servala.com/erp_item_group_description"] + == "Organization: ACME Corp" + ) + assert ( + annotations["servala.com/erp_item_description"] + == "MyProdRedis on Geneva (CH-GVA-2)" + ) + + +def test_build_billing_annotations_no_item_description_without_organization(): + control_plane = Mock() + control_plane.storage_plan_odoo_product_id = "storage-product" + control_plane.storage_plan_odoo_unit_id = "storage-unit" + + annotations = ServiceInstance._build_billing_annotations( + compute_plan_assignment=None, + control_plane=control_plane, + instance_name="MyProdRedis", + organization=None, # No organization + service=None, + ) + + assert "servala.com/erp_item_group_description" not in annotations + assert "servala.com/erp_item_description" not in annotations + # Storage annotations should still be there + assert annotations["servala.com/erp_product_id_storage"] == "storage-product" + + +def test_build_billing_annotations_combined(): + compute_plan_assignment = Mock() + compute_plan_assignment.odoo_product_id = "compute-product" + compute_plan_assignment.odoo_unit_id = "compute-unit" + + control_plane = Mock() + control_plane.storage_plan_odoo_product_id = "storage-product" + control_plane.storage_plan_odoo_unit_id = "storage-unit" + control_plane.cloud_provider = Mock() + control_plane.cloud_provider.name = "Exoscale" + control_plane.name = "Geneva (CH-GVA-2)" + + organization = Mock() + organization.name = "ACME" + organization.osb_guid = "test-guid" + organization.origin = Mock() + organization.origin.invoice_grouping = InvoiceGroupingChoice.BY_SERVICE + + service = Mock() + service.name = "PostgreSQL" + + annotations = ServiceInstance._build_billing_annotations( + compute_plan_assignment=compute_plan_assignment, + control_plane=control_plane, + instance_name="ProdDB", + organization=organization, + service=service, + ) + + assert annotations["servala.com/erp_product_id_resource"] == "compute-product" + assert annotations["servala.com/erp_unit_id_resource"] == "compute-unit" + assert annotations["servala.com/erp_product_id_storage"] == "storage-product" + assert annotations["servala.com/erp_unit_id_storage"] == "storage-unit" + assert ( + annotations["servala.com/erp_item_group_description"] + == "Servala Service: PostgreSQL" + ) + assert ( + annotations["servala.com/erp_item_description"] + == "ProdDB on Exoscale Geneva (CH-GVA-2)" + )