diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 2b5ac2f5..1ad1c09b 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -26,6 +26,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **Custom VM price states its billing period** (issue #302) — `POST /api/v1/vm/custom-template/price` gains `interval_amount` and `interval_type`, the same shape a standard template's cost plan already uses. Custom builds renew monthly, so the values are always `1` and `"month"`; the point is that a client no longer has to hard-code the period to render a recurring price. Additive: no existing field changed. + - **Admin can create a custom-spec VM** (issue #335) — `POST /api/admin/v1/vms/custom` creates a VM from an arbitrary spec for any user, billed against a custom pricing plan, instead of only from the fixed template catalog. Body is the customer custom-order shape plus `user_id`/`reason`: `pricing_id` (also decides the region), `cpu`, `memory` and `disk` in bytes, `disk_type`, `disk_interface`, optional `cpu_mfg`/`cpu_arch`/`cpu_feature`, `image_id`, `ssh_key_id` (must belong to the target user), optional `ref_code`. Returns `{ "job_id": string }`; the VM is created unpaid like a customer order. Unknown enum spellings are `400` and are never defaulted. Additive: `POST /api/admin/v1/vms` (template path) is unchanged. - **A VM can hold more than one address per family** (issue #343) — every assignment is now placed on the VM's single NIC through a per-VM cloud-init network config (`cicustom network=`), instead of the built-in `ipconfig`, which carries only one IPv4 and one IPv6 per interface. A VM with one address per family is unaffected and keeps its existing `ipconfig`, so nothing already deployed is reconfigured. The interface is matched by MAC so the config does not depend on guest device naming; one default route per family is emitted (the first assignment's gateway wins, as before); a SLAAC range contributes router advertisement rather than a pinned address. No response shape changed — the offered maximum is now simply whatever an operator sets on the plan or template. diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index 62ee0d4c..33435fa8 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -59,6 +59,10 @@ pub struct ApiCustomVmPrice { /// currencies. Use `GET /api/v1/exchange-rate` instead. Still populated for /// backward compatibility; will be removed in a future release. pub other_price: Vec, + /// Billing period the amount buys, same shape as a standard template's cost + /// plan. Custom builds renew monthly, so this is always 1 month. + pub interval_amount: u64, + pub interval_type: ApiIntervalType, } impl ApiCustomVmPrice { @@ -80,6 +84,8 @@ impl ApiCustomVmPrice { currency: amount.currency().into(), amount: amount.value(), other_price, + interval_amount: CUSTOM_VM_INTERVAL_AMOUNT, + interval_type: CUSTOM_VM_INTERVAL_TYPE.into(), }) } } diff --git a/lnvps_api_common/src/pricing.rs b/lnvps_api_common/src/pricing.rs index d49e7bbd..468fca5e 100644 --- a/lnvps_api_common/src/pricing.rs +++ b/lnvps_api_common/src/pricing.rs @@ -60,6 +60,15 @@ pub struct RemainingTimeInfo { pub prorated_cost: CurrencyAmount, } +/// Billing period a custom VM price buys. Custom builds have no cost plan row, +/// so the interval is fixed here and is what both the renewal and the quoted +/// price report. +pub const CUSTOM_VM_INTERVAL_AMOUNT: u64 = 1; +pub const CUSTOM_VM_INTERVAL_TYPE: IntervalType = IntervalType::Month; +// The renewal below advances by months, so a change of unit here has to be made +// there too. +const _: () = assert!(matches!(CUSTOM_VM_INTERVAL_TYPE, IntervalType::Month)); + /// ISO 3166-1 alpha-3 codes treated as inside the EU VAT area (27 member states). const EU_VAT_COUNTRIES: [&str; 27] = [ "AUT", "BEL", "BGR", "HRV", "CYP", "CZE", "DNK", "EST", "FIN", "FRA", "DEU", "GRC", "HUN", @@ -885,13 +894,14 @@ impl PricingEngine { let template = self.db.get_custom_vm_template(template_id).await?; let price = Self::get_custom_vm_cost_amount(&self.db, &template).await?; - // custom templates are always 1-month intervals; clamp base to now for expired VMs + // clamp base to now for expired VMs let base = self .vm_subscription_expires(vm) .await .unwrap_or_else(Utc::now) .max(Utc::now()); - let time_value = (base.add(Months::new(1)) - base).num_seconds() as u64; + let time_value = + (base.add(Months::new(CUSTOM_VM_INTERVAL_AMOUNT as u32)) - base).num_seconds() as u64; let converted_amount = self .get_amount_and_rate( CurrencyAmount::from_u64(price.currency, price.total()), @@ -1396,7 +1406,10 @@ impl PricingEngine { } else if let Some(cid) = vm.custom_template_id { let template = self.db.get_custom_vm_template(cid).await?; let price = Self::get_custom_vm_cost_amount(&self.db, &template).await?; - let time_value = Self::cost_plan_interval_to_seconds(IntervalType::Month, 1); + let time_value = Self::cost_plan_interval_to_seconds( + CUSTOM_VM_INTERVAL_TYPE, + CUSTOM_VM_INTERVAL_AMOUNT, + ); ( CurrencyAmount::from_u64(price.currency, price.total()), time_value, @@ -1557,7 +1570,8 @@ impl PricingEngine { let new_price = CurrencyAmount::from_u64(new_price.currency, new_price.total()); // Get the time value for the custom template - let custom_plan_seconds = Self::cost_plan_interval_to_seconds(IntervalType::Month, 1); + let custom_plan_seconds = + Self::cost_plan_interval_to_seconds(CUSTOM_VM_INTERVAL_TYPE, CUSTOM_VM_INTERVAL_AMOUNT); let new_cost_per_second = new_price.value() as f64 / custom_plan_seconds as f64; // calculate the cost based on the time until the vm expires diff --git a/lnvps_e2e/src/user_api.rs b/lnvps_e2e/src/user_api.rs index ebbbb40a..a5164341 100644 --- a/lnvps_e2e/src/user_api.rs +++ b/lnvps_e2e/src/user_api.rs @@ -295,6 +295,13 @@ mod tests { "Custom template price calc should return 200, 400, or 500, got: {}", resp.status() ); + if resp.status() == StatusCode::OK { + // A recurring price with no period reads as a total, so the + // interval has to be on the wire. + let price: ApiData = parse_data(resp).await.unwrap(); + assert_eq!(price.data["interval_amount"], 1); + assert_eq!(price.data["interval_type"], "month"); + } } } }