diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index 9ddea6ee..bbe34cbd 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -3082,6 +3082,68 @@ Response: } ``` +#### Profit/Loss Report + +``` +GET /api/admin/v1/reports/profit-loss +``` + +Aggregates paid revenue against tracked resource costs (see *Resource Cost +Tracking*) into per-period profit/loss rows. **All amounts are converted into a +single target currency** (the company's base currency by default) using current +exchange rates, so each period yields one row. Recurring costs are normalized to +each calendar month they are active; `ip_range` per-IP costs are multiplied by +the range's current assigned-IP count; one-time (capital) costs are booked in +the period containing their `billing_start`. + +Query Parameters: + +- `start_date`: string (required) - YYYY-MM-DD +- `end_date`: string (required) - YYYY-MM-DD +- `group_by`: `month` (default) | `year` +- `company_id`: number (optional) - filter the revenue side to one company; `0`/omitted = all companies. Costs are global (not company-scoped) in this version. +- `region_id`: number (optional) - filter both revenue (payment's VM region) and costs (host / IP-range region); `0`/omitted = all regions. +- `currency`: string (optional) - target currency (e.g. `EUR`). Defaults to the selected company's base currency; **required when `company_id` is omitted**. + +Required Permission: `analytics::view` + +Response: + +```json +{ + "data": { + "start_date": "2026-01-01", + "end_date": "2026-12-31", + "group_by": "month", + "currency": "EUR", + "periods": [ + { + "period": "2026-01", + "revenue_net": 480000, + "revenue_tax": 96000, + "cost_recurring": 8000, + "cost_one_time": 250000, + "cost_total": 258000, + "profit": 222000 + } + ] + } +} +``` + +**Notes:** + +- **Revenue uses each payment's stored historical `rate`** to reconstruct its + company-base-currency value (never live rates). A further base→report-currency + step only applies when aggregating companies with different base currencies. +- **Costs** have no stored rate, so they are converted into the report `currency` + using current exchange rates (BTC-pivoted). +- `profit = revenue_net - cost_total` and may be negative. +- Amounts are in smallest currency units (cents for fiat, millisats for BTC). +- Recurring-cost month normalization uses ~30.44 days/month for `day` intervals, + the interval count directly for `month`, and ×12 for `year`. +- Payments/costs in a currency with no available exchange rate are skipped. + ## Error Responses All error responses follow the format: @@ -5154,3 +5216,124 @@ having newer active configurations. "notes": "IPv6 PI allocation from ARIN" } ``` + +--- + +## Resource Cost Tracking + +Optional, admin-only cost tracking (issue #82) used to compute profit/loss. +Costs are stored in a single generic `resource_cost` table weakly linked to any +resource via `(resource_type, resource_id)` — no schema change is needed to add +new cost-bearing resource kinds, and a single resource may carry multiple cost +records (e.g. a host with recurring rent **and** a one-time hardware investment). + +Cost data is **never** exposed to end users. All amounts are in the smallest +currency units (cents for fiat, millisats for BTC). For an `ip_range` recurring +cost, `amount` is the cost per single IP. + +The `generic` resource type covers costs **not tied to any internal entity** +(e.g. a colo cross-connect or upstream transit subscription): it carries a +free-form `label` and ignores `resource_id`. + +**Permission resource:** `resource_cost` (Create/View/Update/Delete) + +### List Resource Costs + +`GET /api/admin/v1/resource_costs` + +Query parameters: + +- `limit` (default 50, max 100), `offset` (default 0) +- `resource_type` (optional): `vm_host` | `ip_range` +- `resource_id` (optional): filter to a single resource + +### Get Resource Cost + +`GET /api/admin/v1/resource_costs/{id}` + +### Create Resource Cost + +`POST /api/admin/v1/resource_costs` + +```json +{ + "resource_type": "vm_host", + "resource_id": 12, + "cost_type": "recurring", + "amount": 8000, + "currency": "EUR", + "interval_amount": 1, + "interval_type": "month", + "billing_start": "2026-01-01T00:00:00Z", + "billing_end": null +} +``` + +Generic (unlinked) subscription cost — `resource_id` is ignored, `label` required: + +```json +{ + "resource_type": "generic", + "label": "Upstream transit (Cogent)", + "cost_type": "recurring", + "amount": 15000, + "currency": "USD", + "interval_amount": 1, + "interval_type": "month" +} +``` + +One-time capital cost (break-even) example: + +```json +{ + "resource_type": "vm_host", + "resource_id": 12, + "cost_type": "one_time", + "amount": 250000, + "currency": "EUR", + "billing_start": "2025-11-15T00:00:00Z" +} +``` + +### Update Resource Cost + +`PATCH /api/admin/v1/resource_costs/{id}` + +All fields optional. Interval/billing fields use PATCH-clear semantics: omit a +field to leave it unchanged, or send `null` to clear it. + +```json +{ "amount": 9000, "billing_end": "2026-06-01T00:00:00Z" } +``` + +### Delete Resource Cost + +`DELETE /api/admin/v1/resource_costs/{id}` + +### Data Types + +**AdminResourceCostDetail** + +| Field | Type | Notes | +|-----------------|----------------------------|---------------------------------------------------| +| id | u64 | | +| resource_type | `vm_host`\|`ip_range`\|`generic` | Weak link kind | +| resource_id | u64 | Id within the resource's table (no FK); 0 for `generic` | +| label | string \| null | Free-form label; required for `generic` | +| cost_type | `recurring` \| `one_time` | | +| amount | u64 | Smallest currency units; per-IP for ip_range | +| currency | string | e.g. `USD`, `EUR` | +| interval_amount | u64 \| null | Required for `recurring`; null for `one_time` | +| interval_type | `day`\|`month`\|`year`\|null | Billing interval unit | +| billing_start | datetime \| null | Cost start / one-time purchase date | +| billing_end | datetime \| null | Cost end date; null = still active/ongoing | +| created | datetime | | +| updated | datetime | | + +**Notes:** + +- All cost fields are optional; a resource with no `resource_cost` rows simply + has no cost data (no behaviour change). +- Currency conversion (when costs and revenues differ in currency) is deferred + to a follow-up issue. diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 00c8c828..4e08d181 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -8,6 +8,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **2026-07-16** - Cost tracking (P/L groundwork, issue #82) + - Optional, admin-only cost data stored in a new generic `resource_cost` table, weakly linked to any resource via `(resource_type, resource_id)` — no schema change needed to add new cost-bearing resource kinds, and a single resource can hold multiple cost records (e.g. a host's recurring rent plus a one-time hardware investment). + - `GET /api/admin/v1/resource_costs` — paginated list; optional `resource_type` (`vm_host`|`ip_range`) and `resource_id` filters (`resource_cost::view`). + - `GET /api/admin/v1/resource_costs/{id}` — fetch one (`resource_cost::view`). + - `POST /api/admin/v1/resource_costs` — create (`resource_cost::create`). + - `PATCH /api/admin/v1/resource_costs/{id}` — update; interval/billing fields use PATCH-clear semantics (omit = unchanged, `null` = clear) (`resource_cost::update`). + - `DELETE /api/admin/v1/resource_costs/{id}` — remove (`resource_cost::delete`). + - Each cost record has `cost_type` (`recurring`|`one_time`), `amount` (smallest currency units; per-IP for `ip_range` recurring), `currency`, an optional billing interval (`interval_amount` + `interval_type`), and optional `billing_start`/`billing_end` dates (`billing_end` null = still active). + - `resource_type` supports `vm_host`, `ip_range`, and `generic` — the latter is not tied to any internal entity and is identified by a free-form `label` (e.g. a colo/transit subscription); `resource_id` is ignored for `generic` costs. + - Cost data is never exposed to end users. Currency conversion between costs and revenues is deferred to a follow-up. + - Adds the `AdminResource::ResourceCost` (24) RBAC resource; a migration grants the full permission set to the default `super_admin` role. + - `GET /api/admin/v1/reports/profit-loss` — per-period (month/year) profit/loss report netting paid revenue against tracked resource costs. Reported in a single target currency (`currency` param, or the selected company's base currency), so each period is one row. Revenue uses each payment's stored historical exchange rate to value it in its company base currency; costs (no stored rate) use current exchange rates. Recurring costs are normalized per active calendar month, `ip_range` per-IP costs scale by the range's current assigned-IP count, and one-time costs are booked in their `billing_start` period. Optional `group_by` (`month`|`year`), `company_id` and `region_id` filters (`currency` required when `company_id` omitted). Requires `analytics::view`. + - **2026-07-15** - Admin management of users' saved payment methods - New admin endpoints to list/inspect/edit/delete the payment methods users save for automatic renewals (NWC connections and off-session Revolut cards). Distinct from the existing `payment_methods` provider-config endpoints. - `GET /api/admin/v1/user_payment_methods` — paginated list across all users; optional `user_id` filter (`user_payment_method::view`). diff --git a/lnvps_api_admin/src/admin/costs.rs b/lnvps_api_admin/src/admin/costs.rs new file mode 100644 index 00000000..7412c4ba --- /dev/null +++ b/lnvps_api_admin/src/admin/costs.rs @@ -0,0 +1,192 @@ +use crate::admin::RouterState; +use crate::admin::auth::AdminAuth; +use crate::admin::model::{ + AdminCostResourceType, AdminResourceCostDetail, CreateResourceCostRequest, + UpdateResourceCostRequest, +}; +use axum::extract::{Path, Query, State}; +use axum::routing::get; +use axum::{Json, Router}; +use lnvps_api_common::{ApiData, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery}; +use lnvps_db::{AdminAction, AdminResource, ResourceCost}; +use serde::Deserialize; + +pub fn router() -> Router { + Router::new() + .route( + "/api/admin/v1/resource_costs", + get(admin_list_resource_costs).post(admin_create_resource_cost), + ) + .route( + "/api/admin/v1/resource_costs/{id}", + get(admin_get_resource_cost) + .patch(admin_update_resource_cost) + .delete(admin_delete_resource_cost), + ) +} + +/// Optional filters for listing cost records. +#[derive(Deserialize)] +pub struct CostFilter { + pub resource_type: Option, + pub resource_id: Option, +} + +async fn admin_list_resource_costs( + auth: AdminAuth, + State(this): State, + Query(page): Query, + Query(filter): Query, +) -> ApiPaginatedResult { + auth.require_permission(AdminResource::ResourceCost, AdminAction::View)?; + + let limit = page.limit.unwrap_or(50).min(100); + let offset = page.offset.unwrap_or(0); + + let (rows, total) = this + .db + .admin_list_resource_costs( + limit, + offset, + filter.resource_type.map(Into::into), + filter.resource_id, + ) + .await?; + + let out = rows + .into_iter() + .map(AdminResourceCostDetail::from) + .collect(); + ApiPaginatedData::ok(out, total, limit, offset) +} + +async fn admin_get_resource_cost( + auth: AdminAuth, + State(this): State, + Path(id): Path, +) -> ApiResult { + auth.require_permission(AdminResource::ResourceCost, AdminAction::View)?; + + let cost = this.db.admin_get_resource_cost(id).await?; + ApiData::ok(cost.into()) +} + +fn validate( + cost_type: lnvps_db::CostType, + interval_amount: Option, + currency: &str, +) -> Result<(), &'static str> { + if currency.trim().is_empty() { + return Err("Currency cannot be empty"); + } + if cost_type == lnvps_db::CostType::Recurring && interval_amount.is_none() { + return Err("Recurring costs require an interval"); + } + Ok(()) +} + +async fn admin_create_resource_cost( + auth: AdminAuth, + State(this): State, + Json(req): Json, +) -> ApiResult { + auth.require_permission(AdminResource::ResourceCost, AdminAction::Create)?; + + let cost_type: lnvps_db::CostType = req.cost_type.into(); + if let Err(e) = validate(cost_type, req.interval_amount, &req.currency) { + return ApiData::err(e); + } + + let resource_type: lnvps_db::CostResourceType = req.resource_type.into(); + let label = req + .label + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()); + if resource_type == lnvps_db::CostResourceType::Generic && label.is_none() { + return ApiData::err("Generic costs require a label"); + } + + let cost = ResourceCost { + id: 0, + resource_type, + resource_id: if resource_type == lnvps_db::CostResourceType::Generic { + 0 + } else { + req.resource_id + }, + label, + cost_type, + amount: req.amount, + currency: req.currency.trim().to_string(), + interval_amount: req.interval_amount, + interval_type: req.interval_type.map(Into::into), + billing_start: req.billing_start, + billing_end: req.billing_end, + created: chrono::Utc::now(), + updated: chrono::Utc::now(), + }; + + let id = this.db.admin_create_resource_cost(&cost).await?; + let created = this.db.admin_get_resource_cost(id).await?; + ApiData::ok(created.into()) +} + +async fn admin_update_resource_cost( + auth: AdminAuth, + State(this): State, + Path(id): Path, + Json(req): Json, +) -> ApiResult { + auth.require_permission(AdminResource::ResourceCost, AdminAction::Update)?; + + let mut cost = this.db.admin_get_resource_cost(id).await?; + + if let Some(ct) = req.cost_type { + cost.cost_type = ct.into(); + } + if let Some(amount) = req.amount { + cost.amount = amount; + } + if let Some(currency) = req.currency { + cost.currency = currency.trim().to_string(); + } + if let Some(label) = req.label { + cost.label = label + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()); + } + if cost.resource_type == lnvps_db::CostResourceType::Generic && cost.label.is_none() { + return ApiData::err("Generic costs require a label"); + } + if let Some(v) = req.interval_amount { + cost.interval_amount = v; + } + if let Some(v) = req.interval_type { + cost.interval_type = v.map(Into::into); + } + if let Some(v) = req.billing_start { + cost.billing_start = v; + } + if let Some(v) = req.billing_end { + cost.billing_end = v; + } + + if let Err(e) = validate(cost.cost_type, cost.interval_amount, &cost.currency) { + return ApiData::err(e); + } + + this.db.admin_update_resource_cost(&cost).await?; + let updated = this.db.admin_get_resource_cost(id).await?; + ApiData::ok(updated.into()) +} + +async fn admin_delete_resource_cost( + auth: AdminAuth, + State(this): State, + Path(id): Path, +) -> ApiResult<()> { + auth.require_permission(AdminResource::ResourceCost, AdminAction::Delete)?; + + this.db.admin_delete_resource_cost(id).await?; + ApiData::ok(()) +} diff --git a/lnvps_api_admin/src/admin/mod.rs b/lnvps_api_admin/src/admin/mod.rs index 4b049c55..841a4d50 100644 --- a/lnvps_api_admin/src/admin/mod.rs +++ b/lnvps_api_admin/src/admin/mod.rs @@ -9,6 +9,7 @@ mod auth; mod bulk_message; mod companies; mod cost_plans; +mod costs; mod custom_pricing; mod dns_servers; mod docs; @@ -58,6 +59,7 @@ pub fn admin_router( .merge(vm_templates::router()) .merge(companies::router()) .merge(cost_plans::router()) + .merge(costs::router()) .merge(custom_pricing::router()) .merge(ip_ranges::router()) .merge(ip_space::router()) diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index a164bb53..edd2b4b2 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -209,6 +209,145 @@ impl CreateDnsServerRequest { } } +/// The kind of resource a cost record is attached to (weak/polymorphic link). +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AdminCostResourceType { + VmHost, + IpRange, + Generic, +} + +impl From for AdminCostResourceType { + fn from(v: lnvps_db::CostResourceType) -> Self { + match v { + lnvps_db::CostResourceType::VmHost => Self::VmHost, + lnvps_db::CostResourceType::IpRange => Self::IpRange, + lnvps_db::CostResourceType::Generic => Self::Generic, + } + } +} + +impl From for lnvps_db::CostResourceType { + fn from(v: AdminCostResourceType) -> Self { + match v { + AdminCostResourceType::VmHost => Self::VmHost, + AdminCostResourceType::IpRange => Self::IpRange, + AdminCostResourceType::Generic => Self::Generic, + } + } +} + +/// Recurring vs one-time capital cost. +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AdminCostType { + Recurring, + OneTime, +} + +impl From for AdminCostType { + fn from(v: lnvps_db::CostType) -> Self { + match v { + lnvps_db::CostType::Recurring => Self::Recurring, + lnvps_db::CostType::OneTime => Self::OneTime, + } + } +} + +impl From for lnvps_db::CostType { + fn from(v: AdminCostType) -> Self { + match v { + AdminCostType::Recurring => Self::Recurring, + AdminCostType::OneTime => Self::OneTime, + } + } +} + +#[derive(Serialize)] +pub struct AdminResourceCostDetail { + pub id: u64, + pub resource_type: AdminCostResourceType, + pub resource_id: u64, + /// Free-form label for `generic` costs (null for entity-linked costs) + pub label: Option, + pub cost_type: AdminCostType, + /// Cost amount in smallest currency units (per-IP for ip_range recurring) + pub amount: u64, + pub currency: String, + pub interval_amount: Option, + pub interval_type: Option, + pub billing_start: Option>, + pub billing_end: Option>, + pub created: DateTime, + pub updated: DateTime, +} + +impl From for AdminResourceCostDetail { + fn from(c: lnvps_db::ResourceCost) -> Self { + Self { + id: c.id, + resource_type: c.resource_type.into(), + resource_id: c.resource_id, + label: c.label, + cost_type: c.cost_type.into(), + amount: c.amount, + currency: c.currency, + interval_amount: c.interval_amount, + interval_type: c.interval_type.map(Into::into), + billing_start: c.billing_start, + billing_end: c.billing_end, + created: c.created, + updated: c.updated, + } + } +} + +#[derive(Deserialize)] +pub struct CreateResourceCostRequest { + pub resource_type: AdminCostResourceType, + /// Ignored for `generic` costs (defaults to 0) + #[serde(default)] + pub resource_id: u64, + /// Required for `generic` costs; optional otherwise + pub label: Option, + pub cost_type: AdminCostType, + pub amount: u64, + pub currency: String, + pub interval_amount: Option, + pub interval_type: Option, + pub billing_start: Option>, + pub billing_end: Option>, +} + +#[derive(Deserialize)] +pub struct UpdateResourceCostRequest { + pub cost_type: Option, + pub amount: Option, + pub currency: Option, + #[serde(default, deserialize_with = "crate::admin::model::double_option")] + pub label: Option>, + // Interval / billing fields: present-but-null clears the value, absent leaves unchanged. + #[serde(default, deserialize_with = "crate::admin::model::double_option")] + pub interval_amount: Option>, + #[serde(default, deserialize_with = "crate::admin::model::double_option")] + pub interval_type: Option>, + #[serde(default, deserialize_with = "crate::admin::model::double_option")] + pub billing_start: Option>>, + #[serde(default, deserialize_with = "crate::admin::model::double_option")] + pub billing_end: Option>>, +} + +/// Deserialize helper distinguishing an absent field (`None`) from an explicit +/// JSON `null` (`Some(None)`), enabling PATCH semantics that can clear a value. +pub fn double_option<'de, T, D>(de: D) -> Result>, D::Error> +where + T: Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + Deserialize::deserialize(de).map(Some) +} + #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AdminUserStatus { diff --git a/lnvps_api_admin/src/admin/reports.rs b/lnvps_api_admin/src/admin/reports.rs index 91dcb536..aceca56c 100644 --- a/lnvps_api_admin/src/admin/reports.rs +++ b/lnvps_api_admin/src/admin/reports.rs @@ -3,10 +3,13 @@ use crate::admin::auth::AdminAuth; use axum::Router; use axum::extract::{Query, State}; use axum::routing::get; -use chrono::NaiveDate; -use lnvps_api_common::{ApiData, ApiError, ApiResult}; -use lnvps_db::{AdminAction, AdminResource}; +use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc}; +use lnvps_api_common::{ApiData, ApiError, ApiResult, Ticker, TickerRate}; +use lnvps_db::{AdminAction, AdminResource, CostResourceType, CostType, IntervalType}; +use payments_rs::currency::{Currency, CurrencyAmount}; use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; +use std::str::FromStr; pub fn router() -> Router { Router::new() @@ -18,6 +21,10 @@ pub fn router() -> Router { "/api/admin/v1/reports/referral-usage/time-series", get(admin_referral_time_series_report), ) + .route( + "/api/admin/v1/reports/profit-loss", + get(admin_profit_loss_report), + ) } #[derive(Deserialize, Default)] @@ -239,3 +246,382 @@ async fn admin_referral_time_series_report( ApiData::ok(report) } + +#[derive(Deserialize, Default)] +#[serde(default)] +struct ProfitLossQuery { + start_date: String, + end_date: String, + /// "month" (default) or "year" + group_by: Option, + /// Optional company filter for the revenue side; 0 / omitted = all companies. + /// Costs are global (not company-scoped) in this version. + #[serde(deserialize_with = "lnvps_api_common::deserialize_from_str")] + company_id: u64, + /// Optional region filter; 0 / omitted = all regions. Filters both revenue + /// (payment's VM region) and costs (host/ip_range region). + #[serde(deserialize_with = "lnvps_api_common::deserialize_from_str")] + region_id: u64, + /// Target currency for the report. Defaults to the selected company's base + /// currency; required when `company_id` is omitted (all companies). + currency: Option, +} + +#[derive(Serialize, Deserialize)] +struct ProfitLossPeriod { + /// Period identifier ("2026-01" for month grouping, "2026" for year) + period: String, + /// Paid revenue net of tax, in smallest currency units + revenue_net: u64, + /// Tax collected, in smallest currency units + revenue_tax: u64, + /// Recurring costs attributable to this period (normalized), smallest units + cost_recurring: u64, + /// One-time (capital) costs booked in this period, smallest units + cost_one_time: u64, + /// cost_recurring + cost_one_time + cost_total: u64, + /// revenue_net - cost_total (same currency only); may be negative + profit: i64, +} + +#[derive(Serialize, Deserialize)] +struct ProfitLossReport { + start_date: String, + end_date: String, + group_by: String, + /// Currency all amounts in this report are expressed in (the company's base + /// currency, or an explicit `currency` override). + currency: String, + /// Per-period profit/loss rows, sorted by period. All revenue and costs are + /// converted into `currency` using current exchange rates. + periods: Vec, +} + +#[derive(Default)] +struct PlAccumulator { + revenue_net: u64, + revenue_tax: u64, + cost_recurring_f: f64, + cost_one_time: u64, +} + +/// Fraction of a recurring cost `amount` attributable to one calendar month. +fn per_month_fraction(interval_amount: u64, interval_type: IntervalType) -> f64 { + if interval_amount == 0 { + return 0.0; + } + let n = interval_amount as f64; + match interval_type { + // ~average days per month divided by the interval length in days + IntervalType::Day => 30.436875 / n, + IntervalType::Month => 1.0 / n, + IntervalType::Year => 1.0 / (n * 12.0), + } +} + +fn period_key(date: DateTime, group_by_year: bool) -> String { + if group_by_year { + format!("{:04}", date.year()) + } else { + format!("{:04}-{:02}", date.year(), date.month()) + } +} + +/// Reconstruct the base-currency value of a payment using its stored historical +/// `rate`. Lightning payments are stored in BTC (rate = per BTC); Revolut +/// payments are already in the base currency (rate = 1). Never uses live rates. +fn payment_base_amount(amount: u64, pay_cur: Currency, base: Currency, rate: f32) -> Option { + if amount == 0 || pay_cur == base { + return Some(amount); + } + if pay_cur == Currency::BTC { + // BTC -> base fiat using the stored rate + return TickerRate { + ticker: Ticker(Currency::BTC, base), + rate, + } + .convert(CurrencyAmount::from_u64(Currency::BTC, amount)) + .ok() + .map(|c| c.value()); + } + if base == Currency::BTC { + // fiat payment -> BTC base using the stored rate + return TickerRate { + ticker: Ticker(Currency::BTC, pay_cur), + rate, + } + .convert(CurrencyAmount::from_u64(pay_cur, amount)) + .ok() + .map(|c| c.value()); + } + None +} + +/// Convert `amount` (smallest units of `from`) into `to`, pivoting through BTC +/// using the supplied BTC/ rate map. Returns `None` if a required rate is +/// missing. `rates` maps each fiat currency to the price of 1 BTC in it. +fn convert_amount( + amount: u64, + from: Currency, + to: Currency, + rates: &HashMap, +) -> Option { + if from == to { + return Some(amount); + } + let src = CurrencyAmount::from_u64(from, amount); + // Step 1: source -> BTC + let btc = if from == Currency::BTC { + src + } else { + let r = *rates.get(&from)?; + TickerRate { + ticker: Ticker(Currency::BTC, from), + rate: r, + } + .convert(src) + .ok()? + }; + // Step 2: BTC -> target + let out = if to == Currency::BTC { + btc + } else { + let r = *rates.get(&to)?; + TickerRate { + ticker: Ticker(Currency::BTC, to), + rate: r, + } + .convert(btc) + .ok()? + }; + Some(out.value()) +} + +async fn admin_profit_loss_report( + auth: AdminAuth, + State(this): State, + Query(params): Query, +) -> ApiResult { + auth.require_permission(AdminResource::Analytics, AdminAction::View)?; + + let group_by = params + .group_by + .clone() + .unwrap_or_else(|| "month".to_string()) + .to_lowercase(); + let group_by_year = match group_by.as_str() { + "month" | "year" => group_by == "year", + _ => return Err(ApiError::bad_request("group_by must be 'month' or 'year'")), + }; + + let start_date = NaiveDate::parse_from_str(¶ms.start_date, "%Y-%m-%d") + .map_err(|_| anyhow::anyhow!("Invalid start_date format. Use YYYY-MM-DD"))?; + let end_date = NaiveDate::parse_from_str(¶ms.end_date, "%Y-%m-%d") + .map_err(|_| anyhow::anyhow!("Invalid end_date format. Use YYYY-MM-DD"))?; + if start_date >= end_date { + return Err(ApiError::bad_request("start_date must be before end_date")); + } + + let start_dt = start_date.and_hms_opt(0, 0, 0).unwrap().and_utc(); + let end_dt = end_date.and_hms_opt(23, 59, 59).unwrap().and_utc(); + + // Resolve the target currency: explicit override, else the company's base + // currency. Required when reporting across all companies. + let target_str = if let Some(c) = ¶ms.currency { + c.trim().to_uppercase() + } else if params.company_id != 0 { + this.db + .admin_get_company(params.company_id) + .await? + .base_currency + } else { + return Err(ApiError::bad_request( + "currency is required when company_id is omitted", + )); + }; + let target: Currency = Currency::from_str(&target_str) + .map_err(|_| anyhow::anyhow!("Invalid currency: {}", target_str))?; + + // Snapshot current BTC/ rates for conversion into the target currency. + let rates: HashMap = this + .exchange + .list_rates() + .await? + .into_iter() + .filter(|r| r.ticker.0 == Currency::BTC) + .map(|r| (r.ticker.1, r.rate)) + .collect(); + + let mut acc: BTreeMap = BTreeMap::new(); + + // --- Revenue side (paid payments, converted to target currency) --- + let company_ids: Vec = if params.company_id != 0 { + vec![params.company_id] + } else { + let (companies, _) = this.db.admin_list_companies(10_000, 0).await?; + companies.into_iter().map(|c| c.id).collect() + }; + for cid in company_ids { + let payments = this + .db + .admin_get_payments_with_company_info(start_dt, end_dt, cid, None) + .await?; + for p in payments { + if params.region_id != 0 && p.region_id != Some(params.region_id) { + continue; + } + let (Ok(pay_cur), Ok(base_cur)) = ( + Currency::from_str(&p.currency), + Currency::from_str(&p.company_base_currency), + ) else { + continue; + }; + let net = p.amount.saturating_sub(p.tax); + // 1) payment -> its company base currency using the stored historical rate + let (Some(net_base), Some(tax_base)) = ( + payment_base_amount(net, pay_cur, base_cur, p.rate), + payment_base_amount(p.tax, pay_cur, base_cur, p.rate), + ) else { + continue; + }; + // 2) base -> report target (no-op when they match; live rate only + // needed when aggregating companies with differing base currencies) + let (Some(net_c), Some(tax_c)) = ( + convert_amount(net_base, base_cur, target, &rates), + convert_amount(tax_base, base_cur, target, &rates), + ) else { + continue; + }; + let e = acc.entry(period_key(p.created, group_by_year)).or_default(); + e.revenue_net = e.revenue_net.saturating_add(net_c); + e.revenue_tax = e.revenue_tax.saturating_add(tax_c); + } + } + + // --- Cost side --- + let costs = this + .db + .admin_list_resource_costs_active_between(start_dt, end_dt) + .await?; + + // Cache assigned-IP counts per ip_range so per-IP recurring costs scale correctly. + let mut ip_counts: HashMap = HashMap::new(); + for c in &costs { + if c.resource_type == CostResourceType::IpRange && !ip_counts.contains_key(&c.resource_id) { + let n = this + .db + .admin_count_ip_range_assignments(c.resource_id) + .await + .unwrap_or(0); + ip_counts.insert(c.resource_id, n); + } + } + + for c in &costs { + // Region filter: resolve the cost's resource region and skip mismatches. + if params.region_id != 0 { + let region = match c.resource_type { + CostResourceType::VmHost => this + .db + .get_host(c.resource_id) + .await + .ok() + .map(|h| h.region_id), + CostResourceType::IpRange => this + .db + .admin_get_ip_range(c.resource_id) + .await + .ok() + .map(|r| r.region_id), + // Generic costs aren't tied to a region; exclude when filtering. + CostResourceType::Generic => None, + }; + if region != Some(params.region_id) { + continue; + } + } + let Ok(from) = Currency::from_str(&c.currency) else { + continue; + }; + let Some(amount_c) = convert_amount(c.amount, from, target, &rates) else { + continue; + }; + match c.cost_type { + CostType::OneTime => { + // Book the whole amount in the period containing billing_start. + if let Some(bs) = c.billing_start + && bs >= start_dt + && bs <= end_dt + { + let e = acc.entry(period_key(bs, group_by_year)).or_default(); + e.cost_one_time = e.cost_one_time.saturating_add(amount_c); + } + } + CostType::Recurring => { + let units = if c.resource_type == CostResourceType::IpRange { + *ip_counts.get(&c.resource_id).unwrap_or(&0) + } else { + 1 + }; + if units == 0 { + continue; + } + let (Some(ia), Some(it)) = (c.interval_amount, c.interval_type) else { + continue; + }; + let monthly = amount_c as f64 * per_month_fraction(ia, it) * units as f64; + let active_start = c.billing_start.unwrap_or(DateTime::::MIN_UTC); + let active_end = c.billing_end.unwrap_or(DateTime::::MAX_UTC); + + // Walk each calendar month in the report window and add the + // monthly-normalized cost for every month the cost is active. + let mut y = start_date.year(); + let mut m = start_date.month(); + loop { + let month_start = Utc.with_ymd_and_hms(y, m, 1, 0, 0, 0).unwrap(); + let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + let month_end = Utc.with_ymd_and_hms(ny, nm, 1, 0, 0, 0).unwrap() + - chrono::Duration::seconds(1); + + if active_start <= month_end && active_end >= month_start { + acc.entry(period_key(month_start, group_by_year)) + .or_default() + .cost_recurring_f += monthly; + } + + if (y, m) == (end_date.year(), end_date.month()) { + break; + } + y = ny; + m = nm; + } + } + } + } + + let periods = acc + .into_iter() + .map(|(period, a)| { + let cost_recurring = a.cost_recurring_f.round() as u64; + let cost_total = cost_recurring.saturating_add(a.cost_one_time); + ProfitLossPeriod { + period, + revenue_net: a.revenue_net, + revenue_tax: a.revenue_tax, + cost_recurring, + cost_one_time: a.cost_one_time, + cost_total, + profit: a.revenue_net as i64 - cost_total as i64, + } + }) + .collect(); + + ApiData::ok(ProfitLossReport { + start_date: params.start_date, + end_date: params.end_date, + group_by, + currency: target_str, + periods, + }) +} diff --git a/lnvps_api_admin/src/bin/generate_demo_data.rs b/lnvps_api_admin/src/bin/generate_demo_data.rs index 190d0daf..56a95980 100644 --- a/lnvps_api_admin/src/bin/generate_demo_data.rs +++ b/lnvps_api_admin/src/bin/generate_demo_data.rs @@ -1085,6 +1085,9 @@ async fn create_users(db: &LNVpsDbMysql) -> Result> { whatsapp_verified: false, whatsapp_verify_code: None, country_code: None, + geo_country_code: None, + geo_ip: None, + geo_updated: None, billing_name: None, billing_address_1: None, billing_address_2: None, diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 2d16506e..fa8f6905 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -3396,6 +3396,48 @@ impl lnvps_db::AdminDb for MockDb { // Mock implementation Ok(()) } + + async fn admin_list_resource_costs( + &self, + _limit: u64, + _offset: u64, + _resource_type: Option, + _resource_id: Option, + ) -> DbResult<(Vec, u64)> { + Ok((vec![], 0)) + } + + async fn admin_list_resource_costs_for( + &self, + _resource_type: lnvps_db::CostResourceType, + _resource_id: u64, + ) -> DbResult> { + Ok(vec![]) + } + + async fn admin_get_resource_cost(&self, _id: u64) -> DbResult { + todo!() + } + + async fn admin_create_resource_cost(&self, _cost: &lnvps_db::ResourceCost) -> DbResult { + Ok(1) + } + + async fn admin_update_resource_cost(&self, _cost: &lnvps_db::ResourceCost) -> DbResult<()> { + Ok(()) + } + + async fn admin_delete_resource_cost(&self, _id: u64) -> DbResult<()> { + Ok(()) + } + + async fn admin_list_resource_costs_active_between( + &self, + _start: chrono::DateTime, + _end: chrono::DateTime, + ) -> DbResult> { + Ok(vec![]) + } } // Nostr trait implementation with stub methods diff --git a/lnvps_db/migrations/20260716000000_resource_cost.sql b/lnvps_db/migrations/20260716000000_resource_cost.sql new file mode 100644 index 00000000..515d208d --- /dev/null +++ b/lnvps_db/migrations/20260716000000_resource_cost.sql @@ -0,0 +1,47 @@ +-- Optional cost tracking (issue #82). +-- +-- Rather than adding cost columns directly to `vm_host` and `ip_range`, costs +-- are stored in a single generic table weakly linked to any resource by +-- (resource_type, resource_id). This keeps the cost model extensible (new +-- resource kinds need no schema change) and lets a single resource carry +-- multiple cost records (e.g. a host has a recurring monthly rent AND a +-- one-time hardware investment). +-- +-- All cost data is admin-only and optional: absence of a row means no cost +-- data for that resource (no behaviour change). Amounts are in the smallest +-- currency units (cents for fiat, millisats for BTC). For an `ip_range` +-- recurring cost, `amount` is the cost per single IP. +-- +-- resource_type: 0 = vm_host, 1 = ip_range, 2 = generic (no FK; identified by +-- `label`, e.g. a colo/transit subscription) +-- cost_type: 0 = recurring, 1 = one_time (capital investment) +-- interval_type: 0 = Day, 1 = Month, 2 = Year (NULL for one_time) +CREATE TABLE resource_cost +( + id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + -- Weak link: what kind of resource this cost belongs to + resource_type TINYINT UNSIGNED NOT NULL, + -- Weak link: id within that resource's table (no FK, may be soft-deleted). + -- Unused (0) for generic costs. + resource_id INTEGER UNSIGNED NOT NULL, + -- Free-form label for costs not tied to an internal entity (required for + -- generic costs; optional otherwise) + label VARCHAR(200) NULL, + -- Recurring charge vs one-time capital cost + cost_type TINYINT UNSIGNED NOT NULL, + -- Cost amount in smallest currency units (per-IP for ip_range recurring) + amount BIGINT UNSIGNED NOT NULL, + -- Currency code, e.g. 'USD', 'EUR' + currency VARCHAR(10) NOT NULL, + -- Billing interval for recurring costs (e.g. 1 month); NULL for one-time + interval_amount INTEGER UNSIGNED NULL, + interval_type TINYINT UNSIGNED NULL, + -- Date the cost starts / one-time purchase was made + billing_start TIMESTAMP NULL, + -- Date the recurring cost stops being paid; NULL = still active/ongoing. + -- Costs only count towards P/L while now() is within [billing_start, billing_end). + billing_end TIMESTAMP NULL, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_resource_cost_lookup (resource_type, resource_id, cost_type) +) DEFAULT CHARSET = utf8mb4; diff --git a/lnvps_db/migrations/20260716000001_resource_cost_rbac_permissions.sql b/lnvps_db/migrations/20260716000001_resource_cost_rbac_permissions.sql new file mode 100644 index 00000000..b4d095a6 --- /dev/null +++ b/lnvps_db/migrations/20260716000001_resource_cost_rbac_permissions.sql @@ -0,0 +1,14 @@ +-- RBAC permissions for cost tracking (issue #82). +-- +-- Adds AdminResource::ResourceCost = 24. Cost data is admin-only, so grant the +-- full permission set to the default super_admin role. +-- +-- AdminAction: Create = 0, View = 1, Update = 2, Delete = 3 +INSERT IGNORE INTO admin_role_permissions (role_id, resource, action, created_at) +SELECT id, 24, 0, NOW() FROM admin_roles WHERE name = 'super_admin' +UNION ALL +SELECT id, 24, 1, NOW() FROM admin_roles WHERE name = 'super_admin' +UNION ALL +SELECT id, 24, 2, NOW() FROM admin_roles WHERE name = 'super_admin' +UNION ALL +SELECT id, 24, 3, NOW() FROM admin_roles WHERE name = 'super_admin'; diff --git a/lnvps_db/src/admin.rs b/lnvps_db/src/admin.rs index 3ff1fcaa..6d97e6cd 100644 --- a/lnvps_db/src/admin.rs +++ b/lnvps_db/src/admin.rs @@ -366,4 +366,43 @@ pub trait AdminDb: Send + Sync { /// Delete VM IP assignment (soft delete) async fn admin_delete_vm_ip_assignment(&self, assignment_id: u64) -> DbResult<()>; + + // Resource cost tracking (issue #82) - admin-only, optional cost data + /// List cost records, optionally filtered by resource, with pagination. + /// Returns (rows, total_count). + async fn admin_list_resource_costs( + &self, + limit: u64, + offset: u64, + resource_type: Option, + resource_id: Option, + ) -> DbResult<(Vec, u64)>; + + /// List all cost records attached to a specific resource + async fn admin_list_resource_costs_for( + &self, + resource_type: crate::CostResourceType, + resource_id: u64, + ) -> DbResult>; + + /// Get a single cost record by id + async fn admin_get_resource_cost(&self, id: u64) -> DbResult; + + /// Create a new cost record, returns its id + async fn admin_create_resource_cost(&self, cost: &crate::ResourceCost) -> DbResult; + + /// Update an existing cost record + async fn admin_update_resource_cost(&self, cost: &crate::ResourceCost) -> DbResult<()>; + + /// Delete a cost record + async fn admin_delete_resource_cost(&self, id: u64) -> DbResult<()>; + + /// List cost records that are active at any point within `[start, end]`, + /// for P/L reporting. Includes recurring costs whose active window overlaps + /// the range and one-time costs whose `billing_start` falls within it. + async fn admin_list_resource_costs_active_between( + &self, + start: chrono::DateTime, + end: chrono::DateTime, + ) -> DbResult>; } diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 161ac949..b0ede234 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -919,6 +919,112 @@ pub enum IntervalType { Year = 2, } +/// The kind of resource a cost record is attached to (weak/polymorphic link). +#[derive(Clone, Copy, Debug, PartialEq, Eq, sqlx::Type, Serialize, Deserialize, Default)] +#[repr(u8)] +#[serde(rename_all = "snake_case")] +pub enum CostResourceType { + /// Links to `vm_host.id` + #[default] + VmHost = 0, + /// Links to `ip_range.id` + IpRange = 1, + /// Not tied to any internal entity — a free-form cost/subscription + /// identified only by its user-supplied `label` (e.g. "Colo cross-connect", + /// "Upstream transit"). `resource_id` is unused (0). + Generic = 2, +} + +impl Display for CostResourceType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + CostResourceType::VmHost => write!(f, "vm_host"), + CostResourceType::IpRange => write!(f, "ip_range"), + CostResourceType::Generic => write!(f, "generic"), + } + } +} + +impl FromStr for CostResourceType { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "vm_host" | "host" => Ok(CostResourceType::VmHost), + "ip_range" => Ok(CostResourceType::IpRange), + "generic" | "subscription" => Ok(CostResourceType::Generic), + _ => Err(anyhow!("unknown cost resource type: {}", s)), + } + } +} + +/// Whether a cost is a recurring charge or a one-time capital outlay. +#[derive(Clone, Copy, Debug, PartialEq, Eq, sqlx::Type, Serialize, Deserialize, Default)] +#[repr(u8)] +#[serde(rename_all = "snake_case")] +pub enum CostType { + /// Recurring cost billed every `interval_amount` `interval_type` (rent/colo, + /// or per-IP monthly for an ip_range). + #[default] + Recurring = 0, + /// One-time capital investment (e.g. hardware purchase) used for break-even. + OneTime = 1, +} + +impl Display for CostType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + CostType::Recurring => write!(f, "recurring"), + CostType::OneTime => write!(f, "one_time"), + } + } +} + +impl FromStr for CostType { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "recurring" => Ok(CostType::Recurring), + "one_time" | "onetime" | "investment" => Ok(CostType::OneTime), + _ => Err(anyhow!("unknown cost type: {}", s)), + } + } +} + +/// An optional cost record weakly linked to another resource by +/// `(resource_type, resource_id)`. Used to compute P/L; admin-only, never +/// exposed to end users. Costs are all in `amount` smallest currency units. +#[derive(FromRow, Clone, Debug)] +pub struct ResourceCost { + pub id: u64, + /// What kind of resource this cost is attached to + pub resource_type: CostResourceType, + /// Id of the resource within its table (weak link, no FK). Unused (0) for + /// `Generic` costs, which are identified by `label` instead. + pub resource_id: u64, + /// Free-form label for costs not tied to an internal entity (required for + /// `Generic`; optional/ignored for entity-linked costs). + pub label: Option, + /// Recurring vs one-time capital cost + pub cost_type: CostType, + /// Cost amount in smallest currency units (cents for fiat, millisats for BTC). + /// For an `ip_range` recurring cost this is the cost per single IP. + pub amount: u64, + /// Currency code (e.g. USD, EUR) + pub currency: String, + /// Number of intervals per billing cycle (e.g. 1 for "every 1 month"). + /// NULL for one-time costs. + pub interval_amount: Option, + /// Interval unit (Day, Month, Year). NULL for one-time costs. + pub interval_type: Option, + /// Date the cost starts / the one-time purchase was made. + pub billing_start: Option>, + /// Date the recurring cost stops being paid. `None` = still active/ongoing. + /// Only counts towards P/L while now() is within `[billing_start, billing_end)`. + pub billing_end: Option>, + pub created: DateTime, + pub updated: DateTime, +} + #[derive(FromRow, Clone, Debug)] pub struct VmCostPlan { pub id: u64, @@ -1596,6 +1702,7 @@ pub enum AdminResource { PaymentMethodConfig = 21, DnsServer = 22, UserPaymentMethod = 23, + ResourceCost = 24, } /// Actions that can be performed on administrative resources @@ -1635,6 +1742,7 @@ impl Display for AdminResource { AdminResource::PaymentMethodConfig => write!(f, "payment_method_config"), AdminResource::DnsServer => write!(f, "dns_server"), AdminResource::UserPaymentMethod => write!(f, "user_payment_method"), + AdminResource::ResourceCost => write!(f, "resource_cost"), } } } @@ -1668,6 +1776,7 @@ impl FromStr for AdminResource { "payment_method_config" => Ok(AdminResource::PaymentMethodConfig), "dns_server" => Ok(AdminResource::DnsServer), "user_payment_method" => Ok(AdminResource::UserPaymentMethod), + "resource_cost" => Ok(AdminResource::ResourceCost), _ => Err(anyhow!("unknown admin resource: {}", s)), } } @@ -1702,6 +1811,7 @@ impl TryFrom for AdminResource { 21 => Ok(AdminResource::PaymentMethodConfig), 22 => Ok(AdminResource::DnsServer), 23 => Ok(AdminResource::UserPaymentMethod), + 24 => Ok(AdminResource::ResourceCost), _ => Err(anyhow!("unknown admin resource value: {}", value)), } } @@ -1735,6 +1845,7 @@ impl AdminResource { AdminResource::PaymentMethodConfig, AdminResource::DnsServer, AdminResource::UserPaymentMethod, + AdminResource::ResourceCost, ] } } @@ -2074,6 +2185,44 @@ mod tests { assert!(AdminResource::all().contains(&AdminResource::UserPaymentMethod)); } + #[test] + fn test_admin_resource_cost_roundtrip() { + assert_eq!( + "resource_cost".parse::().unwrap(), + AdminResource::ResourceCost + ); + assert_eq!(AdminResource::ResourceCost.to_string(), "resource_cost"); + assert_eq!( + AdminResource::try_from(24u16).unwrap(), + AdminResource::ResourceCost + ); + assert!(AdminResource::all().contains(&AdminResource::ResourceCost)); + } + + #[test] + fn test_cost_type_and_resource_type_roundtrip() { + assert_eq!( + "vm_host".parse::().unwrap(), + CostResourceType::VmHost + ); + assert_eq!( + "ip_range".parse::().unwrap(), + CostResourceType::IpRange + ); + assert_eq!(CostResourceType::IpRange.to_string(), "ip_range"); + assert_eq!( + "generic".parse::().unwrap(), + CostResourceType::Generic + ); + assert_eq!(CostResourceType::Generic.to_string(), "generic"); + assert_eq!( + "recurring".parse::().unwrap(), + CostType::Recurring + ); + assert_eq!("one_time".parse::().unwrap(), CostType::OneTime); + assert_eq!(CostType::OneTime.to_string(), "one_time"); + } + #[test] fn test_cpu_mfg_from_str() { assert_eq!("intel".parse::().unwrap(), CpuMfg::Intel); diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index d9077293..6b709ca8 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -5344,4 +5344,142 @@ impl AdminDb for LNVpsDbMysql { Ok(()) } + + async fn admin_list_resource_costs( + &self, + limit: u64, + offset: u64, + resource_type: Option, + resource_id: Option, + ) -> DbResult<(Vec, u64)> { + let rows = sqlx::query_as::<_, crate::ResourceCost>( + "SELECT * FROM resource_cost \ + WHERE (? IS NULL OR resource_type = ?) \ + AND (? IS NULL OR resource_id = ?) \ + ORDER BY id DESC LIMIT ? OFFSET ?", + ) + .bind(resource_type) + .bind(resource_type) + .bind(resource_id) + .bind(resource_id) + .bind(limit) + .bind(offset) + .fetch_all(&self.db) + .await?; + + let total: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM resource_cost \ + WHERE (? IS NULL OR resource_type = ?) \ + AND (? IS NULL OR resource_id = ?)", + ) + .bind(resource_type) + .bind(resource_type) + .bind(resource_id) + .bind(resource_id) + .fetch_one(&self.db) + .await?; + + Ok((rows, total.0 as u64)) + } + + async fn admin_list_resource_costs_for( + &self, + resource_type: crate::CostResourceType, + resource_id: u64, + ) -> DbResult> { + Ok(sqlx::query_as::<_, crate::ResourceCost>( + "SELECT * FROM resource_cost WHERE resource_type = ? AND resource_id = ? ORDER BY id DESC", + ) + .bind(resource_type) + .bind(resource_id) + .fetch_all(&self.db) + .await?) + } + + async fn admin_get_resource_cost(&self, id: u64) -> DbResult { + Ok(sqlx::query_as("SELECT * FROM resource_cost WHERE id = ?") + .bind(id) + .fetch_one(&self.db) + .await?) + } + + async fn admin_create_resource_cost(&self, cost: &crate::ResourceCost) -> DbResult { + let result = sqlx::query( + "INSERT INTO resource_cost \ + (resource_type, resource_id, label, cost_type, amount, currency, \ + interval_amount, interval_type, billing_start, billing_end) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(cost.resource_type) + .bind(cost.resource_id) + .bind(&cost.label) + .bind(cost.cost_type) + .bind(cost.amount) + .bind(&cost.currency) + .bind(cost.interval_amount) + .bind(cost.interval_type) + .bind(cost.billing_start) + .bind(cost.billing_end) + .execute(&self.db) + .await?; + + Ok(result.last_insert_id()) + } + + async fn admin_update_resource_cost(&self, cost: &crate::ResourceCost) -> DbResult<()> { + sqlx::query( + "UPDATE resource_cost SET \ + resource_type = ?, resource_id = ?, label = ?, cost_type = ?, amount = ?, currency = ?, \ + interval_amount = ?, interval_type = ?, billing_start = ?, billing_end = ? \ + WHERE id = ?", + ) + .bind(cost.resource_type) + .bind(cost.resource_id) + .bind(&cost.label) + .bind(cost.cost_type) + .bind(cost.amount) + .bind(&cost.currency) + .bind(cost.interval_amount) + .bind(cost.interval_type) + .bind(cost.billing_start) + .bind(cost.billing_end) + .bind(cost.id) + .execute(&self.db) + .await?; + + Ok(()) + } + + async fn admin_delete_resource_cost(&self, id: u64) -> DbResult<()> { + sqlx::query("DELETE FROM resource_cost WHERE id = ?") + .bind(id) + .execute(&self.db) + .await?; + + Ok(()) + } + + async fn admin_list_resource_costs_active_between( + &self, + start: chrono::DateTime, + end: chrono::DateTime, + ) -> DbResult> { + // cost_type: 0 = recurring, 1 = one_time + Ok(sqlx::query_as::<_, crate::ResourceCost>( + "SELECT * FROM resource_cost WHERE \ + (cost_type = 0 \ + AND (billing_start IS NULL OR billing_start <= ?) \ + AND (billing_end IS NULL OR billing_end >= ?)) \ + OR (cost_type = 1 \ + AND billing_start IS NOT NULL \ + AND billing_start >= ? AND billing_start <= ?) \ + ORDER BY id", + ) + .bind(end) + .bind(start) + .bind(start) + .bind(end) + .fetch_all(&self.db) + .await?) + } }