Skip to content

Commit 57a2741

Browse files
committed
feat(manage): close the Phase 6 gaps
Phase 6 of the spec-coverage rollout — five sub-tasks across Manage. 6a. Usage::get_usage_breakdown for /v1/projects/{id}/usage/breakdown (with UsageGrouping enum and richer response shape — agent hours, token counts, TTS characters, per-row grouping). Existing Usage::get_usage marked #[deprecated(since = "0.10.0")] — /v1/projects/{id}/usage is deprecated server-side. 6b. Invitations CRUD: Invitations::{list, create, delete} alongside the existing leave_project. options::Options{email, scope} + response::{Invites, Invite}. 6c. Billing::{breakdown, fields, purchases} with a new breakdown_options module (BillingGrouping enum, 4 values). Custom-Serialize options to emit repeated grouping=… params correctly through serde_urlencoded. New response types: BillingBreakdown(Result|Grouping), BillingFields, PurchaseOrders, PurchaseOrder, Resolution. 6d. list_requests_options gains page, accessor, request_id, deployment (DeploymentFilter), endpoint (EndpointFilter), and method (HttpMethodFilter — name-disambiguated from the existing get_usage_options::Method which means a different thing). 6e. Breaking signature changes for 0.10.0: - Keys::list(project_id, status: Option<KeyStatus>) - Projects::get(project_id, limit: Option<u32>, page: Option<u32>) 223 lib tests pass (+12), 7 integration tests pass, 139 doctests pass. All feature combinations build clean.
1 parent b7a83c4 commit 57a2741

13 files changed

Lines changed: 1033 additions & 30 deletions

src/manage/billing.rs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
//! [api]: https://developers.deepgram.com/api-reference/#billing
66
77
use crate::{
8-
manage::billing::response::{Balance, Balances},
8+
manage::billing::response::{
9+
Balance, Balances, BillingBreakdown, BillingFields, PurchaseOrders,
10+
},
911
send_and_translate_response, Deepgram,
1012
};
1113

14+
pub mod breakdown_options;
1215
pub mod response;
1316

1417
/// Get the outstanding balances for a Deepgram Project.
@@ -113,6 +116,63 @@ impl Billing<'_> {
113116

114117
send_and_translate_response(self.0.client.get(url)).await
115118
}
119+
120+
/// `GET /v1/projects/{project_id}/billing/breakdown` — billing
121+
/// summary for the project, with optional filters and grouping.
122+
pub async fn breakdown(
123+
&self,
124+
project_id: &str,
125+
options: &breakdown_options::Options,
126+
) -> crate::Result<BillingBreakdown> {
127+
let url = format!("https://api.deepgram.com/v1/projects/{project_id}/billing/breakdown");
128+
let request = self
129+
.0
130+
.client
131+
.get(url)
132+
.query(&breakdown_options::SerializableOptions(options));
133+
send_and_translate_response(request).await
134+
}
135+
136+
/// `GET /v1/projects/{project_id}/billing/fields` — list the
137+
/// dimensions (accessors, deployments, tags, line items) available
138+
/// for filtering [`Billing::breakdown`] queries over the given
139+
/// date range.
140+
pub async fn fields(
141+
&self,
142+
project_id: &str,
143+
start: Option<&str>,
144+
end: Option<&str>,
145+
) -> crate::Result<BillingFields> {
146+
let url = format!("https://api.deepgram.com/v1/projects/{project_id}/billing/fields");
147+
let mut request = self.0.client.get(url);
148+
let mut query: Vec<(&str, &str)> = Vec::new();
149+
if let Some(start) = start {
150+
query.push(("start", start));
151+
}
152+
if let Some(end) = end {
153+
query.push(("end", end));
154+
}
155+
if !query.is_empty() {
156+
request = request.query(&query);
157+
}
158+
send_and_translate_response(request).await
159+
}
160+
161+
/// `GET /v1/projects/{project_id}/purchases` — list purchase
162+
/// orders for the project. `limit` is forwarded as the
163+
/// per-page-size query param (1-1000 per spec).
164+
pub async fn purchases(
165+
&self,
166+
project_id: &str,
167+
limit: Option<usize>,
168+
) -> crate::Result<PurchaseOrders> {
169+
let url = format!("https://api.deepgram.com/v1/projects/{project_id}/purchases");
170+
let mut request = self.0.client.get(url);
171+
if let Some(limit) = limit {
172+
request = request.query(&[("limit", limit)]);
173+
}
174+
send_and_translate_response(request).await
175+
}
116176
}
117177

118178
#[cfg(test)]
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
//! Options for [`Billing::breakdown`](super::Billing::breakdown).
2+
//!
3+
//! Mirrors the query params on `GET /v1/projects/{id}/billing/breakdown`.
4+
5+
use serde::ser::SerializeSeq;
6+
use serde::{Serialize, Serializer};
7+
8+
/// Options for the billing breakdown endpoint.
9+
#[derive(Debug, Default, PartialEq, Clone)]
10+
pub struct Options {
11+
start: Option<String>,
12+
end: Option<String>,
13+
accessor: Option<String>,
14+
deployment: Option<String>,
15+
tag: Option<String>,
16+
line_item: Option<String>,
17+
grouping: Vec<BillingGrouping>,
18+
}
19+
20+
/// `?grouping=` value for `billing/breakdown`. Distinct from
21+
/// [`crate::manage::usage::get_usage_breakdown_options::UsageGrouping`]
22+
/// — billing supports a strict 4-value subset.
23+
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
24+
#[non_exhaustive]
25+
pub enum BillingGrouping {
26+
/// Group by accessor (e.g. API key UUID).
27+
Accessor,
28+
/// Group by deployment type.
29+
Deployment,
30+
/// Group by line item (e.g. `streaming::nova-3`).
31+
LineItem,
32+
/// Group by tag.
33+
Tags,
34+
}
35+
36+
impl BillingGrouping {
37+
fn as_str(&self) -> &'static str {
38+
match self {
39+
Self::Accessor => "accessor",
40+
Self::Deployment => "deployment",
41+
Self::LineItem => "line_item",
42+
Self::Tags => "tags",
43+
}
44+
}
45+
}
46+
47+
/// Builder for [`Options`].
48+
#[derive(Debug, Default, PartialEq, Clone)]
49+
pub struct OptionsBuilder(Options);
50+
51+
/// Wire serializer for [`Options`]. Emits a flat sequence of
52+
/// `(key, value)` tuples so that repeated `grouping=…` params work
53+
/// (`serde_urlencoded` doesn't support sequence-typed fields inside a
54+
/// struct, only at the top level).
55+
#[doc(hidden)]
56+
#[derive(Debug)]
57+
pub struct SerializableOptions<'a>(pub(crate) &'a Options);
58+
59+
impl Serialize for SerializableOptions<'_> {
60+
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
61+
let opts = self.0;
62+
let mut seq = serializer.serialize_seq(None)?;
63+
64+
if let Some(start) = &opts.start {
65+
seq.serialize_element(&("start", start))?;
66+
}
67+
if let Some(end) = &opts.end {
68+
seq.serialize_element(&("end", end))?;
69+
}
70+
if let Some(accessor) = &opts.accessor {
71+
seq.serialize_element(&("accessor", accessor))?;
72+
}
73+
if let Some(deployment) = &opts.deployment {
74+
seq.serialize_element(&("deployment", deployment))?;
75+
}
76+
if let Some(tag) = &opts.tag {
77+
seq.serialize_element(&("tag", tag))?;
78+
}
79+
if let Some(line_item) = &opts.line_item {
80+
seq.serialize_element(&("line_item", line_item))?;
81+
}
82+
for g in &opts.grouping {
83+
seq.serialize_element(&("grouping", g.as_str()))?;
84+
}
85+
86+
seq.end()
87+
}
88+
}
89+
90+
impl Options {
91+
/// Construct a new builder.
92+
pub fn builder() -> OptionsBuilder {
93+
OptionsBuilder::default()
94+
}
95+
96+
/// URL-encoded query string (without leading `?`).
97+
pub fn urlencoded(&self) -> Result<String, serde_urlencoded::ser::Error> {
98+
serde_urlencoded::to_string(SerializableOptions(self))
99+
}
100+
}
101+
102+
impl OptionsBuilder {
103+
/// Construct a fresh empty builder.
104+
pub fn new() -> Self {
105+
Self(Options::default())
106+
}
107+
108+
/// Start of the requested date range. `YYYY-MM-DD` format.
109+
pub fn start(mut self, start: impl Into<String>) -> Self {
110+
self.0.start = Some(start.into());
111+
self
112+
}
113+
114+
/// End of the requested date range. `YYYY-MM-DD` format.
115+
pub fn end(mut self, end: impl Into<String>) -> Self {
116+
self.0.end = Some(end.into());
117+
self
118+
}
119+
120+
/// Filter by accessor (UUID).
121+
pub fn accessor(mut self, accessor: impl Into<String>) -> Self {
122+
self.0.accessor = Some(accessor.into());
123+
self
124+
}
125+
126+
/// Filter by deployment type.
127+
pub fn deployment(mut self, deployment: impl Into<String>) -> Self {
128+
self.0.deployment = Some(deployment.into());
129+
self
130+
}
131+
132+
/// Filter by tag.
133+
pub fn tag(mut self, tag: impl Into<String>) -> Self {
134+
self.0.tag = Some(tag.into());
135+
self
136+
}
137+
138+
/// Filter by line item (e.g. `streaming::nova-3`).
139+
pub fn line_item(mut self, line_item: impl Into<String>) -> Self {
140+
self.0.line_item = Some(line_item.into());
141+
self
142+
}
143+
144+
/// Replace the grouping list. Multiple groupings are sent as
145+
/// repeated query params.
146+
pub fn grouping<I>(mut self, grouping: I) -> Self
147+
where
148+
I: IntoIterator<Item = BillingGrouping>,
149+
{
150+
self.0.grouping = grouping.into_iter().collect();
151+
self
152+
}
153+
154+
/// Finish building.
155+
pub fn build(self) -> Options {
156+
self.0
157+
}
158+
}
159+
160+
#[cfg(test)]
161+
mod tests {
162+
use super::*;
163+
164+
#[test]
165+
fn empty_options() {
166+
let q = Options::builder().build().urlencoded().unwrap();
167+
assert_eq!(q, "");
168+
}
169+
170+
#[test]
171+
fn full_options_serialize() {
172+
let q = Options::builder()
173+
.start("2025-01-01")
174+
.end("2025-01-31")
175+
.accessor("acc-1")
176+
.deployment("hosted")
177+
.tag("prod")
178+
.line_item("streaming::nova-3")
179+
.grouping([BillingGrouping::Deployment, BillingGrouping::LineItem])
180+
.build()
181+
.urlencoded()
182+
.unwrap();
183+
assert_eq!(
184+
q,
185+
"start=2025-01-01&end=2025-01-31&accessor=acc-1\
186+
&deployment=hosted&tag=prod&line_item=streaming%3A%3Anova-3\
187+
&grouping=deployment&grouping=line_item"
188+
);
189+
}
190+
}

src/manage/billing/response.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
//! Deepgram billing API response types.
22
3+
use std::collections::HashMap;
4+
35
use serde::{Deserialize, Serialize};
46
use uuid::Uuid;
57

@@ -53,3 +55,116 @@ pub enum BillingUnits {
5355
#[serde(rename = "hour")]
5456
Hour,
5557
}
58+
59+
/// Returned by [`Billing::breakdown`](super::Billing::breakdown).
60+
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
61+
#[non_exhaustive]
62+
pub struct BillingBreakdown {
63+
/// Start of the billing period.
64+
pub start: String,
65+
/// End of the billing period.
66+
pub end: String,
67+
/// Resolution of each row in `results`.
68+
pub resolution: Resolution,
69+
/// One row per grouping bucket.
70+
pub results: Vec<BillingBreakdownResult>,
71+
}
72+
73+
/// Time resolution shared by [`BillingBreakdown`] and `UsageBreakdown`.
74+
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
75+
#[non_exhaustive]
76+
pub struct Resolution {
77+
/// Time unit for the resolution (e.g. `day`).
78+
pub units: String,
79+
/// Amount of units (e.g. `1`).
80+
pub amount: f64,
81+
}
82+
83+
/// One row of a [`BillingBreakdown`].
84+
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
85+
#[non_exhaustive]
86+
pub struct BillingBreakdownResult {
87+
/// USD cost for this grouping bucket.
88+
pub dollars: f64,
89+
/// Grouping dimensions that produced this row.
90+
pub grouping: BillingBreakdownGrouping,
91+
}
92+
93+
/// Grouping metadata on a [`BillingBreakdownResult`].
94+
#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)]
95+
#[non_exhaustive]
96+
pub struct BillingBreakdownGrouping {
97+
#[allow(missing_docs)]
98+
#[serde(default, skip_serializing_if = "Option::is_none")]
99+
pub start: Option<String>,
100+
101+
#[allow(missing_docs)]
102+
#[serde(default, skip_serializing_if = "Option::is_none")]
103+
pub end: Option<String>,
104+
105+
#[allow(missing_docs)]
106+
#[serde(default, skip_serializing_if = "Option::is_none")]
107+
pub accessor: Option<String>,
108+
109+
#[allow(missing_docs)]
110+
#[serde(default, skip_serializing_if = "Option::is_none")]
111+
pub deployment: Option<String>,
112+
113+
#[allow(missing_docs)]
114+
#[serde(default, skip_serializing_if = "Option::is_none")]
115+
pub line_item: Option<String>,
116+
117+
#[allow(missing_docs)]
118+
#[serde(default, skip_serializing_if = "Option::is_none")]
119+
pub tags: Option<Vec<String>>,
120+
}
121+
122+
/// Returned by [`Billing::fields`](super::Billing::fields). Lists the
123+
/// dimensions available for filtering [`BillingBreakdown`] queries.
124+
#[derive(Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize)]
125+
#[non_exhaustive]
126+
pub struct BillingFields {
127+
/// Accessor UUIDs that have produced billing in the time range.
128+
#[serde(default)]
129+
pub accessors: Vec<String>,
130+
131+
/// Deployment types that have produced billing.
132+
#[serde(default)]
133+
pub deployments: Vec<String>,
134+
135+
/// Tags that have produced billing.
136+
#[serde(default)]
137+
pub tags: Vec<String>,
138+
139+
/// Line item identifiers mapped to human-readable descriptions.
140+
/// e.g. `"streaming::nova-3" -> "Nova-3 (Stream)"`.
141+
#[serde(default)]
142+
pub line_items: HashMap<String, String>,
143+
}
144+
145+
/// Returned by [`Billing::purchases`](super::Billing::purchases).
146+
#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)]
147+
#[non_exhaustive]
148+
pub struct PurchaseOrders {
149+
/// Purchase orders.
150+
#[serde(default)]
151+
pub orders: Vec<PurchaseOrder>,
152+
}
153+
154+
/// A single purchase order.
155+
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
156+
#[non_exhaustive]
157+
pub struct PurchaseOrder {
158+
/// Order UUID.
159+
pub order_id: Uuid,
160+
/// Expiration timestamp (ISO 8601).
161+
pub expiration: String,
162+
/// Creation timestamp (ISO 8601).
163+
pub created: String,
164+
/// Amount of the purchase.
165+
pub amount: f64,
166+
/// Units of the amount (e.g. `usd`).
167+
pub units: String,
168+
/// Type of order (e.g. `promotional`).
169+
pub order_type: String,
170+
}

0 commit comments

Comments
 (0)