1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
//! API request/response models for credit transactions.
use super::pagination::Pagination;
use crate::{
db::models::credits::{CreditTransactionDBResponse, CreditTransactionType},
types::UserId,
};
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
// Subset of the DB Transaction Type enum for API use as only admin transactions are allowed here
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TransactionType {
AdminGrant,
AdminRemoval,
}
impl From<&TransactionType> for CreditTransactionType {
fn from(tx_type: &TransactionType) -> Self {
match tx_type {
TransactionType::AdminGrant => CreditTransactionType::AdminGrant,
TransactionType::AdminRemoval => CreditTransactionType::AdminRemoval,
}
}
}
// Request models
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreditTransactionCreate {
/// User ID (required - UUID format)
#[schema(value_type = String, format = "uuid")]
pub user_id: UserId,
/// Type of transaction (only admin_grant and admin_removal allowed for admin API)
pub transaction_type: TransactionType,
/// Amount of credits (absolute value, sent as string to preserve precision)
#[schema(value_type = String)]
pub amount: Decimal,
/// Source ID for the transaction (user UUID, or UUID-suffix for grants)
pub source_id: String,
/// Optional description of the transaction
pub description: Option<String>,
}
// Response models
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreditTransactionResponse {
/// Transaction ID
#[schema(value_type = String, format = "uuid")]
pub id: Uuid,
/// User ID
#[schema(value_type = String, format = "uuid")]
pub user_id: UserId,
/// Transaction type
pub transaction_type: CreditTransactionType,
/// Batch ID (present when this is a grouped batch of multiple usage transactions)
#[schema(value_type = Option<String>, format = "uuid")]
pub batch_id: Option<Uuid>,
/// Amount of credits (returned as string to preserve precision)
#[schema(value_type = String)]
pub amount: Decimal,
/// Source ID
pub source_id: String,
/// Description
pub description: Option<String>,
/// When the transaction was created
pub created_at: DateTime<Utc>,
/// Service tier: "realtime", "flex", "async", or "batch".
/// Only present for usage transactions
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<String>,
/// Number of requests in this batch (only present for batch transactions, always > 1)
#[serde(skip_serializing_if = "Option::is_none")]
pub batch_request_count: Option<i32>,
}
/// Paginated response for transaction listing with balance context.
/// Mirrors PaginatedResponse structure with additional balance field.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TransactionListResponse {
/// The transactions for the current page
pub data: Vec<CreditTransactionResponse>,
/// Whether more transactions exist beyond this page. There is no total
/// count: computing one would scan the whole filtered history for a
/// pager widget.
pub has_more: bool,
/// Current user balance when skip=0, or balance at the pagination point (before the
/// first transaction on this page) when skip>0. Frontend can compute each row's balance
/// by subtracting signed amounts from this value.
#[schema(value_type = String)]
pub page_start_balance: Decimal,
}
/// Query parameters for listing transactions
#[derive(Debug, Deserialize, IntoParams)]
pub struct ListTransactionsQuery {
/// Filter by user ID (optional, BillingManager only for other users)
#[serde(skip_serializing_if = "Option::is_none")]
#[param(value_type = Option<String>, format = "uuid")]
pub user_id: Option<UserId>,
/// Return all transactions across all users (BillingManager only)
pub all: Option<bool>,
/// Group transactions by fusillade_batch_id (merges batch requests into single entries)
pub group_batches: Option<bool>,
/// Search term for description (case-insensitive)
pub search: Option<String>,
/// Filter by transaction types (comma-separated: "admin_grant,purchase" or "usage,admin_removal")
pub transaction_types: Option<String>,
/// Filter transactions created on or after this date/time (ISO 8601 format)
#[serde(skip_serializing_if = "Option::is_none")]
#[param(value_type = Option<String>, format = "date-time")]
pub start_date: Option<DateTime<Utc>>,
/// Filter transactions created on or before this date/time (ISO 8601 format)
#[serde(skip_serializing_if = "Option::is_none")]
#[param(value_type = Option<String>, format = "date-time")]
pub end_date: Option<DateTime<Utc>>,
/// Pagination parameters
#[serde(flatten)]
#[param(inline)]
pub pagination: Pagination,
}
/// Internal filter struct for repository layer
#[derive(Debug, Default, Clone)]
pub struct TransactionFilters {
pub search: Option<String>,
pub transaction_types: Option<Vec<CreditTransactionType>>,
pub start_date: Option<DateTime<Utc>>,
pub end_date: Option<DateTime<Utc>>,
}
impl ListTransactionsQuery {
/// Parse query parameters into TransactionFilters struct
pub fn to_filters(&self) -> TransactionFilters {
let transaction_types = self.transaction_types.as_ref().map(|types_str| {
types_str
.split(',')
.filter_map(|t| match t.trim() {
"admin_grant" => Some(CreditTransactionType::AdminGrant),
"admin_removal" => Some(CreditTransactionType::AdminRemoval),
"usage" => Some(CreditTransactionType::Usage),
"purchase" => Some(CreditTransactionType::Purchase),
_ => None,
})
.collect()
});
TransactionFilters {
search: self.search.clone(),
transaction_types,
start_date: self.start_date,
end_date: self.end_date,
}
}
}
// Conversions
impl CreditTransactionResponse {
/// Convert from DB response with optional batch_id (without batch-grouping metadata).
/// Carries the denormalized `service_tier` from the ledger row so the non-grouped
/// transactions lists expose the tier just like the grouped path (COR-514).
pub fn from_db_with_batch_id(db: CreditTransactionDBResponse, batch_id: Option<Uuid>) -> Self {
Self {
id: db.id,
user_id: db.user_id,
transaction_type: db.transaction_type,
batch_id,
amount: db.amount,
source_id: db.source_id,
description: db.description,
created_at: db.created_at,
service_tier: db.service_tier,
batch_request_count: None,
}
}
/// Convert from DB response with full category information
pub fn from_db_with_metadata(
db: CreditTransactionDBResponse,
batch_id: Option<Uuid>,
service_tier: Option<String>,
batch_count: i32,
) -> Self {
// Only include batch_request_count for actual batches (count > 1)
let batch_request_count = if batch_count > 1 { Some(batch_count) } else { None };
Self {
id: db.id,
user_id: db.user_id,
transaction_type: db.transaction_type,
batch_id,
amount: db.amount,
source_id: db.source_id,
description: db.description,
created_at: db.created_at,
service_tier,
batch_request_count,
}
}
}
impl From<CreditTransactionDBResponse> for CreditTransactionResponse {
fn from(db: CreditTransactionDBResponse) -> Self {
Self::from_db_with_batch_id(db, None)
}
}