mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
refactor: 拆分 gateway 单体为独立 crate,新增 systemd 部署方案
将 gateway 内部的 model-fetch、provider-transport、scheduler-core、 usage-runtime、video-tasks-core 模块提取为独立 crate;重构 gateway 内部模块结构(state/router/cache/data/query 等);移除大量遗留模块 文件;新增 systemd 二进制部署骨架及相关文档;更新前端 usage 相关 API 和组件。
This commit is contained in:
301
crates/aether-data/src/repository/audit.rs
Normal file
301
crates/aether-data/src/repository/audit.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::repository::auth::ResolvedAuthApiKeySnapshot;
|
||||
use crate::repository::candidates::DecisionTrace;
|
||||
use crate::repository::usage::StoredRequestUsageAudit;
|
||||
use crate::DataLayerError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RequestAuditBundle {
|
||||
pub request_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<StoredRequestUsageAudit>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub decision_trace: Option<DecisionTrace>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_snapshot: Option<ResolvedAuthApiKeySnapshot>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RequestAuditReader {
|
||||
async fn find_request_usage_audit_by_request_id(
|
||||
&self,
|
||||
request_id: &str,
|
||||
) -> Result<Option<StoredRequestUsageAudit>, DataLayerError>;
|
||||
|
||||
async fn read_request_decision_trace(
|
||||
&self,
|
||||
request_id: &str,
|
||||
attempted_only: bool,
|
||||
) -> Result<Option<DecisionTrace>, DataLayerError>;
|
||||
|
||||
async fn read_resolved_auth_api_key_snapshot(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<Option<ResolvedAuthApiKeySnapshot>, DataLayerError>;
|
||||
}
|
||||
|
||||
pub async fn read_request_audit_bundle(
|
||||
state: &impl RequestAuditReader,
|
||||
request_id: &str,
|
||||
attempted_only: bool,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<Option<RequestAuditBundle>, DataLayerError> {
|
||||
let usage = state
|
||||
.find_request_usage_audit_by_request_id(request_id)
|
||||
.await?;
|
||||
let decision_trace = state
|
||||
.read_request_decision_trace(request_id, attempted_only)
|
||||
.await?;
|
||||
|
||||
let auth_snapshot = if let Some(usage) = usage.as_ref() {
|
||||
match (usage.user_id.as_deref(), usage.api_key_id.as_deref()) {
|
||||
(Some(user_id), Some(api_key_id)) => {
|
||||
state
|
||||
.read_resolved_auth_api_key_snapshot(user_id, api_key_id, now_unix_secs)
|
||||
.await?
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if usage.is_none() && decision_trace.is_none() && auth_snapshot.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(RequestAuditBundle {
|
||||
request_id: request_id.to_string(),
|
||||
usage,
|
||||
decision_trace,
|
||||
auth_snapshot,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::{read_request_audit_bundle, RequestAuditReader};
|
||||
use crate::repository::auth::{ResolvedAuthApiKeySnapshot, StoredAuthApiKeySnapshot};
|
||||
use crate::repository::candidates::{
|
||||
DecisionTrace, DecisionTraceCandidate, RequestCandidateFinalStatus, RequestCandidateStatus,
|
||||
StoredRequestCandidate,
|
||||
};
|
||||
use crate::repository::usage::StoredRequestUsageAudit;
|
||||
use crate::DataLayerError;
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeRequestAuditReader {
|
||||
usage: Option<StoredRequestUsageAudit>,
|
||||
decision_trace: Option<DecisionTrace>,
|
||||
auth_snapshot: Option<ResolvedAuthApiKeySnapshot>,
|
||||
auth_snapshot_reads: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RequestAuditReader for FakeRequestAuditReader {
|
||||
async fn find_request_usage_audit_by_request_id(
|
||||
&self,
|
||||
_request_id: &str,
|
||||
) -> Result<Option<StoredRequestUsageAudit>, DataLayerError> {
|
||||
Ok(self.usage.clone())
|
||||
}
|
||||
|
||||
async fn read_request_decision_trace(
|
||||
&self,
|
||||
_request_id: &str,
|
||||
_attempted_only: bool,
|
||||
) -> Result<Option<DecisionTrace>, DataLayerError> {
|
||||
Ok(self.decision_trace.clone())
|
||||
}
|
||||
|
||||
async fn read_resolved_auth_api_key_snapshot(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
_api_key_id: &str,
|
||||
_now_unix_secs: u64,
|
||||
) -> Result<Option<ResolvedAuthApiKeySnapshot>, DataLayerError> {
|
||||
self.auth_snapshot_reads.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(self.auth_snapshot.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_request_audit_bundle_resolves_usage_trace_and_auth_snapshot() {
|
||||
let state = FakeRequestAuditReader {
|
||||
usage: Some(sample_usage("req-audit-1")),
|
||||
decision_trace: Some(sample_decision_trace("req-audit-1")),
|
||||
auth_snapshot: Some(sample_resolved_auth_snapshot("user-1", "api-key-1")),
|
||||
auth_snapshot_reads: AtomicUsize::new(0),
|
||||
};
|
||||
|
||||
let bundle = read_request_audit_bundle(&state, "req-audit-1", true, 123)
|
||||
.await
|
||||
.expect("bundle should read")
|
||||
.expect("bundle should exist");
|
||||
|
||||
assert_eq!(bundle.request_id, "req-audit-1");
|
||||
assert_eq!(
|
||||
bundle
|
||||
.usage
|
||||
.as_ref()
|
||||
.map(|usage| usage.provider_name.as_str()),
|
||||
Some("OpenAI")
|
||||
);
|
||||
assert_eq!(
|
||||
bundle
|
||||
.decision_trace
|
||||
.as_ref()
|
||||
.map(|trace| trace.total_candidates),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
bundle
|
||||
.auth_snapshot
|
||||
.as_ref()
|
||||
.map(|snapshot| snapshot.api_key_id.as_str()),
|
||||
Some("api-key-1")
|
||||
);
|
||||
assert_eq!(state.auth_snapshot_reads.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_request_audit_bundle_returns_none_when_all_sources_are_empty() {
|
||||
let state = FakeRequestAuditReader::default();
|
||||
|
||||
let bundle = read_request_audit_bundle(&state, "req-audit-empty", false, 123)
|
||||
.await
|
||||
.expect("bundle should read");
|
||||
|
||||
assert!(bundle.is_none());
|
||||
assert_eq!(state.auth_snapshot_reads.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
fn sample_usage(request_id: &str) -> StoredRequestUsageAudit {
|
||||
StoredRequestUsageAudit::new(
|
||||
"usage-1".to_string(),
|
||||
request_id.to_string(),
|
||||
Some("user-1".to_string()),
|
||||
Some("api-key-1".to_string()),
|
||||
Some("alice".to_string()),
|
||||
Some("default".to_string()),
|
||||
"OpenAI".to_string(),
|
||||
"gpt-4.1".to_string(),
|
||||
None,
|
||||
Some("provider-1".to_string()),
|
||||
Some("endpoint-1".to_string()),
|
||||
Some("provider-key-1".to_string()),
|
||||
Some("chat".to_string()),
|
||||
Some("openai:chat".to_string()),
|
||||
Some("openai".to_string()),
|
||||
Some("chat".to_string()),
|
||||
Some("openai:chat".to_string()),
|
||||
Some("openai".to_string()),
|
||||
Some("chat".to_string()),
|
||||
false,
|
||||
false,
|
||||
120,
|
||||
40,
|
||||
160,
|
||||
0.24,
|
||||
0.36,
|
||||
Some(200),
|
||||
None,
|
||||
None,
|
||||
Some(450),
|
||||
Some(120),
|
||||
"completed".to_string(),
|
||||
"settled".to_string(),
|
||||
100,
|
||||
101,
|
||||
Some(102),
|
||||
)
|
||||
.expect("usage should build")
|
||||
}
|
||||
|
||||
fn sample_decision_trace(request_id: &str) -> DecisionTrace {
|
||||
let candidate = StoredRequestCandidate::new(
|
||||
"cand-1".to_string(),
|
||||
request_id.to_string(),
|
||||
Some("user-1".to_string()),
|
||||
Some("api-key-1".to_string()),
|
||||
Some("alice".to_string()),
|
||||
Some("default".to_string()),
|
||||
0,
|
||||
0,
|
||||
Some("provider-1".to_string()),
|
||||
Some("endpoint-1".to_string()),
|
||||
Some("provider-key-1".to_string()),
|
||||
RequestCandidateStatus::Success,
|
||||
None,
|
||||
false,
|
||||
Some(200),
|
||||
None,
|
||||
None,
|
||||
Some(37),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
100,
|
||||
Some(101),
|
||||
Some(102),
|
||||
)
|
||||
.expect("candidate should build");
|
||||
DecisionTrace {
|
||||
request_id: request_id.to_string(),
|
||||
total_candidates: 1,
|
||||
final_status: RequestCandidateFinalStatus::Success,
|
||||
total_latency_ms: 37,
|
||||
candidates: vec![DecisionTraceCandidate {
|
||||
candidate,
|
||||
provider_name: Some("OpenAI".to_string()),
|
||||
provider_website: None,
|
||||
provider_type: Some("custom".to_string()),
|
||||
endpoint_api_format: Some("openai:chat".to_string()),
|
||||
endpoint_api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
provider_key_name: Some("prod".to_string()),
|
||||
provider_key_auth_type: Some("api_key".to_string()),
|
||||
provider_key_capabilities: None,
|
||||
provider_key_is_active: Some(true),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_resolved_auth_snapshot(
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
) -> ResolvedAuthApiKeySnapshot {
|
||||
let stored = StoredAuthApiKeySnapshot::new(
|
||||
user_id.to_string(),
|
||||
"alice".to_string(),
|
||||
Some("alice@example.com".to_string()),
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
true,
|
||||
false,
|
||||
Some(serde_json::json!(["openai"])),
|
||||
Some(serde_json::json!(["openai:chat"])),
|
||||
Some(serde_json::json!(["gpt-4.1"])),
|
||||
api_key_id.to_string(),
|
||||
Some("default".to_string()),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
Some(60),
|
||||
Some(5),
|
||||
Some(4_102_444_800),
|
||||
Some(serde_json::json!(["openai"])),
|
||||
Some(serde_json::json!(["openai:chat"])),
|
||||
Some(serde_json::json!(["gpt-4.1"])),
|
||||
)
|
||||
.expect("auth snapshot should build");
|
||||
ResolvedAuthApiKeySnapshot::from_stored(stored, 123)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@ mod types;
|
||||
pub use memory::InMemoryAuthApiKeySnapshotRepository;
|
||||
pub use sql::SqlxAuthApiKeySnapshotReadRepository;
|
||||
pub use types::{
|
||||
AuthApiKeyExportSummary, AuthApiKeyLookupKey, AuthApiKeyReadRepository,
|
||||
AuthApiKeyWriteRepository, AuthRepository, CreateStandaloneApiKeyRecord,
|
||||
CreateUserApiKeyRecord, StandaloneApiKeyExportListQuery, StoredAuthApiKeyExportRecord,
|
||||
StoredAuthApiKeySnapshot, UpdateStandaloneApiKeyBasicRecord, UpdateUserApiKeyBasicRecord,
|
||||
read_resolved_auth_api_key_snapshot, read_resolved_auth_api_key_snapshot_by_key_hash,
|
||||
read_resolved_auth_api_key_snapshot_by_user_api_key_ids, AuthApiKeyExportSummary,
|
||||
AuthApiKeyLookupKey, AuthApiKeyReadRepository, AuthApiKeyWriteRepository, AuthRepository,
|
||||
CreateStandaloneApiKeyRecord, CreateUserApiKeyRecord, ResolvedAuthApiKeySnapshot,
|
||||
ResolvedAuthApiKeySnapshotReader, StandaloneApiKeyExportListQuery,
|
||||
StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot, UpdateStandaloneApiKeyBasicRecord,
|
||||
UpdateUserApiKeyBasicRecord,
|
||||
};
|
||||
|
||||
@@ -124,6 +124,131 @@ impl StoredAuthApiKeySnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ResolvedAuthApiKeySnapshot {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub user_role: String,
|
||||
pub user_auth_source: String,
|
||||
pub user_is_active: bool,
|
||||
pub user_is_deleted: bool,
|
||||
pub user_rate_limit: Option<i32>,
|
||||
pub user_allowed_providers: Option<Vec<String>>,
|
||||
pub user_allowed_api_formats: Option<Vec<String>>,
|
||||
pub user_allowed_models: Option<Vec<String>>,
|
||||
pub api_key_id: String,
|
||||
pub api_key_name: Option<String>,
|
||||
pub api_key_is_active: bool,
|
||||
pub api_key_is_locked: bool,
|
||||
pub api_key_is_standalone: bool,
|
||||
pub api_key_rate_limit: Option<i32>,
|
||||
pub api_key_concurrent_limit: Option<i32>,
|
||||
pub api_key_expires_at_unix_secs: Option<u64>,
|
||||
pub api_key_allowed_providers: Option<Vec<String>>,
|
||||
pub api_key_allowed_api_formats: Option<Vec<String>>,
|
||||
pub api_key_allowed_models: Option<Vec<String>>,
|
||||
pub currently_usable: bool,
|
||||
}
|
||||
|
||||
impl ResolvedAuthApiKeySnapshot {
|
||||
pub fn from_stored(snapshot: StoredAuthApiKeySnapshot, now_unix_secs: u64) -> Self {
|
||||
let currently_usable = snapshot.is_currently_usable(now_unix_secs);
|
||||
Self {
|
||||
user_id: snapshot.user_id,
|
||||
username: snapshot.username,
|
||||
email: snapshot.email,
|
||||
user_role: snapshot.user_role,
|
||||
user_auth_source: snapshot.user_auth_source,
|
||||
user_is_active: snapshot.user_is_active,
|
||||
user_is_deleted: snapshot.user_is_deleted,
|
||||
user_rate_limit: snapshot.user_rate_limit,
|
||||
user_allowed_providers: snapshot.user_allowed_providers,
|
||||
user_allowed_api_formats: snapshot.user_allowed_api_formats,
|
||||
user_allowed_models: snapshot.user_allowed_models,
|
||||
api_key_id: snapshot.api_key_id,
|
||||
api_key_name: snapshot.api_key_name,
|
||||
api_key_is_active: snapshot.api_key_is_active,
|
||||
api_key_is_locked: snapshot.api_key_is_locked,
|
||||
api_key_is_standalone: snapshot.api_key_is_standalone,
|
||||
api_key_rate_limit: snapshot.api_key_rate_limit,
|
||||
api_key_concurrent_limit: snapshot.api_key_concurrent_limit,
|
||||
api_key_expires_at_unix_secs: snapshot.api_key_expires_at_unix_secs,
|
||||
api_key_allowed_providers: snapshot.api_key_allowed_providers,
|
||||
api_key_allowed_api_formats: snapshot.api_key_allowed_api_formats,
|
||||
api_key_allowed_models: snapshot.api_key_allowed_models,
|
||||
currently_usable,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effective_allowed_providers(&self) -> Option<&[String]> {
|
||||
self.api_key_allowed_providers
|
||||
.as_deref()
|
||||
.or(self.user_allowed_providers.as_deref())
|
||||
}
|
||||
|
||||
pub fn effective_allowed_api_formats(&self) -> Option<&[String]> {
|
||||
self.api_key_allowed_api_formats
|
||||
.as_deref()
|
||||
.or(self.user_allowed_api_formats.as_deref())
|
||||
}
|
||||
|
||||
pub fn effective_allowed_models(&self) -> Option<&[String]> {
|
||||
self.api_key_allowed_models
|
||||
.as_deref()
|
||||
.or(self.user_allowed_models.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ResolvedAuthApiKeySnapshotReader: Send + Sync {
|
||||
async fn find_stored_auth_api_key_snapshot(
|
||||
&self,
|
||||
key: AuthApiKeyLookupKey<'_>,
|
||||
) -> Result<Option<StoredAuthApiKeySnapshot>, crate::DataLayerError>;
|
||||
}
|
||||
|
||||
pub async fn read_resolved_auth_api_key_snapshot(
|
||||
reader: &impl ResolvedAuthApiKeySnapshotReader,
|
||||
key: AuthApiKeyLookupKey<'_>,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<Option<ResolvedAuthApiKeySnapshot>, crate::DataLayerError> {
|
||||
Ok(reader
|
||||
.find_stored_auth_api_key_snapshot(key)
|
||||
.await?
|
||||
.map(|snapshot| ResolvedAuthApiKeySnapshot::from_stored(snapshot, now_unix_secs)))
|
||||
}
|
||||
|
||||
pub async fn read_resolved_auth_api_key_snapshot_by_key_hash(
|
||||
reader: &impl ResolvedAuthApiKeySnapshotReader,
|
||||
key_hash: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<Option<ResolvedAuthApiKeySnapshot>, crate::DataLayerError> {
|
||||
read_resolved_auth_api_key_snapshot(
|
||||
reader,
|
||||
AuthApiKeyLookupKey::KeyHash(key_hash),
|
||||
now_unix_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn read_resolved_auth_api_key_snapshot_by_user_api_key_ids(
|
||||
reader: &impl ResolvedAuthApiKeySnapshotReader,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<Option<ResolvedAuthApiKeySnapshot>, crate::DataLayerError> {
|
||||
read_resolved_auth_api_key_snapshot(
|
||||
reader,
|
||||
AuthApiKeyLookupKey::UserApiKeyIds {
|
||||
user_id,
|
||||
api_key_id,
|
||||
},
|
||||
now_unix_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAuthApiKeyExportRecord {
|
||||
pub user_id: String,
|
||||
@@ -485,7 +610,14 @@ fn parse_u64_i64(value: i64, field_name: &str) -> Result<u64, crate::DataLayerEr
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot};
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::{
|
||||
read_resolved_auth_api_key_snapshot_by_key_hash,
|
||||
read_resolved_auth_api_key_snapshot_by_user_api_key_ids, AuthApiKeyLookupKey,
|
||||
ResolvedAuthApiKeySnapshot, ResolvedAuthApiKeySnapshotReader, StoredAuthApiKeyExportRecord,
|
||||
StoredAuthApiKeySnapshot,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn rejects_non_array_allowed_providers() {
|
||||
@@ -611,6 +743,50 @@ mod tests {
|
||||
assert!(!snapshot.is_currently_usable(101));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_snapshot_prefers_api_key_lists_over_user_lists() {
|
||||
let snapshot = StoredAuthApiKeySnapshot::new(
|
||||
"user-1".to_string(),
|
||||
"alice".to_string(),
|
||||
None,
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
true,
|
||||
false,
|
||||
Some(serde_json::json!(["openai"])),
|
||||
Some(serde_json::json!(["openai:chat"])),
|
||||
Some(serde_json::json!(["gpt-4.1"])),
|
||||
"key-1".to_string(),
|
||||
Some("default".to_string()),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
Some(60),
|
||||
Some(5),
|
||||
Some(200),
|
||||
Some(serde_json::json!(["anthropic"])),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("snapshot should build");
|
||||
|
||||
let resolved = ResolvedAuthApiKeySnapshot::from_stored(snapshot, 150);
|
||||
|
||||
assert!(resolved.currently_usable);
|
||||
assert_eq!(
|
||||
resolved.effective_allowed_providers(),
|
||||
Some(&["anthropic".to_string()][..])
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.effective_allowed_api_formats(),
|
||||
Some(&["openai:chat".to_string()][..])
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.effective_allowed_models(),
|
||||
Some(&["gpt-4.1".to_string()][..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_record_rejects_negative_totals() {
|
||||
assert!(StoredAuthApiKeyExportRecord::new(
|
||||
@@ -665,4 +841,89 @@ mod tests {
|
||||
assert_eq!(record.total_requests, 12);
|
||||
assert_eq!(record.total_cost_usd, 1.25);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeResolvedAuthApiKeySnapshotReader {
|
||||
stored: Option<StoredAuthApiKeySnapshot>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ResolvedAuthApiKeySnapshotReader for FakeResolvedAuthApiKeySnapshotReader {
|
||||
async fn find_stored_auth_api_key_snapshot(
|
||||
&self,
|
||||
_key: AuthApiKeyLookupKey<'_>,
|
||||
) -> Result<Option<StoredAuthApiKeySnapshot>, crate::DataLayerError> {
|
||||
Ok(self.stored.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_resolved_auth_snapshot_by_user_api_key_ids() {
|
||||
let reader = FakeResolvedAuthApiKeySnapshotReader {
|
||||
stored: Some(sample_auth_snapshot("key-1", "user-1")),
|
||||
};
|
||||
|
||||
let snapshot = read_resolved_auth_api_key_snapshot_by_user_api_key_ids(
|
||||
&reader, "user-1", "key-1", 150,
|
||||
)
|
||||
.await
|
||||
.expect("snapshot should read")
|
||||
.expect("snapshot should exist");
|
||||
|
||||
assert_eq!(snapshot.user_id, "user-1");
|
||||
assert_eq!(snapshot.api_key_id, "key-1");
|
||||
assert!(snapshot.currently_usable);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_resolved_auth_snapshot_by_key_hash() {
|
||||
let reader = FakeResolvedAuthApiKeySnapshotReader {
|
||||
stored: Some(sample_auth_snapshot("key-1", "user-1")),
|
||||
};
|
||||
|
||||
let snapshot = read_resolved_auth_api_key_snapshot_by_key_hash(&reader, "hash-lookup", 150)
|
||||
.await
|
||||
.expect("snapshot should read")
|
||||
.expect("snapshot should exist");
|
||||
|
||||
assert_eq!(
|
||||
snapshot.effective_allowed_providers(),
|
||||
Some(&["openai".to_string()][..])
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.effective_allowed_api_formats(),
|
||||
Some(&["openai:chat".to_string()][..])
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.effective_allowed_models(),
|
||||
Some(&["gpt-4.1".to_string()][..])
|
||||
);
|
||||
}
|
||||
|
||||
fn sample_auth_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot {
|
||||
StoredAuthApiKeySnapshot::new(
|
||||
user_id.to_string(),
|
||||
"alice".to_string(),
|
||||
Some("alice@example.com".to_string()),
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
true,
|
||||
false,
|
||||
Some(serde_json::json!(["openai"])),
|
||||
Some(serde_json::json!(["openai:chat"])),
|
||||
Some(serde_json::json!(["gpt-4.1"])),
|
||||
api_key_id.to_string(),
|
||||
Some("default".to_string()),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
Some(60),
|
||||
Some(5),
|
||||
Some(200),
|
||||
Some(serde_json::json!(["openai"])),
|
||||
Some(serde_json::json!(["openai:chat"])),
|
||||
Some(serde_json::json!(["gpt-4.1"])),
|
||||
)
|
||||
.expect("snapshot should build")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,8 @@ mod types;
|
||||
|
||||
pub use memory::InMemoryBillingReadRepository;
|
||||
pub use sql::SqlxBillingReadRepository;
|
||||
pub use types::{BillingReadRepository, StoredBillingModelContext};
|
||||
pub use types::{
|
||||
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingPresetApplyResult,
|
||||
AdminBillingRuleRecord, AdminBillingRuleWriteInput, BillingReadRepository,
|
||||
StoredBillingModelContext,
|
||||
};
|
||||
|
||||
@@ -74,6 +74,74 @@ impl StoredBillingModelContext {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdminBillingRuleRecord {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub task_type: String,
|
||||
pub global_model_id: Option<String>,
|
||||
pub model_id: Option<String>,
|
||||
pub expression: String,
|
||||
pub variables: Value,
|
||||
pub dimension_mappings: Value,
|
||||
pub is_enabled: bool,
|
||||
pub created_at_unix_secs: u64,
|
||||
pub updated_at_unix_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AdminBillingRuleWriteInput {
|
||||
pub name: String,
|
||||
pub task_type: String,
|
||||
pub global_model_id: Option<String>,
|
||||
pub model_id: Option<String>,
|
||||
pub expression: String,
|
||||
pub variables: Value,
|
||||
pub dimension_mappings: Value,
|
||||
pub is_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdminBillingCollectorRecord {
|
||||
pub id: String,
|
||||
pub api_format: String,
|
||||
pub task_type: String,
|
||||
pub dimension_name: String,
|
||||
pub source_type: String,
|
||||
pub source_path: Option<String>,
|
||||
pub value_type: String,
|
||||
pub transform_expression: Option<String>,
|
||||
pub default_value: Option<String>,
|
||||
pub priority: i32,
|
||||
pub is_enabled: bool,
|
||||
pub created_at_unix_secs: u64,
|
||||
pub updated_at_unix_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AdminBillingCollectorWriteInput {
|
||||
pub api_format: String,
|
||||
pub task_type: String,
|
||||
pub dimension_name: String,
|
||||
pub source_type: String,
|
||||
pub source_path: Option<String>,
|
||||
pub value_type: String,
|
||||
pub transform_expression: Option<String>,
|
||||
pub default_value: Option<String>,
|
||||
pub priority: i32,
|
||||
pub is_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdminBillingPresetApplyResult {
|
||||
pub preset: String,
|
||||
pub mode: String,
|
||||
pub created: u64,
|
||||
pub updated: u64,
|
||||
pub skipped: u64,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BillingReadRepository: Send + Sync {
|
||||
async fn find_model_context(
|
||||
|
||||
@@ -5,7 +5,9 @@ mod types;
|
||||
pub use memory::InMemoryRequestCandidateRepository;
|
||||
pub use sql::SqlxRequestCandidateReadRepository;
|
||||
pub use types::{
|
||||
PublicHealthStatusCount, PublicHealthTimelineBucket, RequestCandidateReadRepository,
|
||||
RequestCandidateRepository, RequestCandidateStatus, RequestCandidateWriteRepository,
|
||||
build_decision_trace, derive_request_candidate_final_status, DecisionTrace,
|
||||
DecisionTraceCandidate, PublicHealthStatusCount, PublicHealthTimelineBucket,
|
||||
RequestCandidateFinalStatus, RequestCandidateReadRepository, RequestCandidateRepository,
|
||||
RequestCandidateStatus, RequestCandidateTrace, RequestCandidateWriteRepository,
|
||||
StoredRequestCandidate, UpsertRequestCandidateRecord,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RequestCandidateStatus {
|
||||
@@ -185,6 +191,210 @@ impl StoredRequestCandidate {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RequestCandidateFinalStatus {
|
||||
Success,
|
||||
Failed,
|
||||
Cancelled,
|
||||
Streaming,
|
||||
Pending,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RequestCandidateTrace {
|
||||
pub request_id: String,
|
||||
pub total_candidates: usize,
|
||||
pub final_status: RequestCandidateFinalStatus,
|
||||
pub total_latency_ms: u64,
|
||||
pub candidates: Vec<StoredRequestCandidate>,
|
||||
}
|
||||
|
||||
impl RequestCandidateTrace {
|
||||
pub fn from_candidates(
|
||||
request_id: impl Into<String>,
|
||||
all_candidates: Vec<StoredRequestCandidate>,
|
||||
attempted_only: bool,
|
||||
) -> Option<Self> {
|
||||
if all_candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let candidates = if attempted_only {
|
||||
all_candidates
|
||||
.iter()
|
||||
.filter(|candidate| {
|
||||
candidate
|
||||
.status
|
||||
.is_attempted(candidate.started_at_unix_secs)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
all_candidates.clone()
|
||||
};
|
||||
|
||||
let total_latency_ms = candidates
|
||||
.iter()
|
||||
.filter(|candidate| {
|
||||
matches!(
|
||||
candidate.status,
|
||||
RequestCandidateStatus::Success
|
||||
| RequestCandidateStatus::Failed
|
||||
| RequestCandidateStatus::Cancelled
|
||||
) && candidate.latency_ms.is_some()
|
||||
})
|
||||
.map(|candidate| candidate.latency_ms.unwrap_or(0))
|
||||
.sum();
|
||||
let final_status_source = if attempted_only && candidates.is_empty() {
|
||||
&all_candidates
|
||||
} else {
|
||||
&candidates
|
||||
};
|
||||
|
||||
Some(Self {
|
||||
request_id: request_id.into(),
|
||||
total_candidates: candidates.len(),
|
||||
final_status: derive_request_candidate_final_status(final_status_source),
|
||||
total_latency_ms,
|
||||
candidates,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn derive_request_candidate_final_status(
|
||||
candidates: &[StoredRequestCandidate],
|
||||
) -> RequestCandidateFinalStatus {
|
||||
let has_success = candidates.iter().any(|candidate| {
|
||||
candidate.status == RequestCandidateStatus::Success
|
||||
|| matches!(candidate.status_code, Some(status_code) if (200..300).contains(&status_code))
|
||||
});
|
||||
if has_success {
|
||||
return RequestCandidateFinalStatus::Success;
|
||||
}
|
||||
|
||||
if candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.status == RequestCandidateStatus::Streaming)
|
||||
{
|
||||
return RequestCandidateFinalStatus::Streaming;
|
||||
}
|
||||
|
||||
if candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.status == RequestCandidateStatus::Pending)
|
||||
{
|
||||
return RequestCandidateFinalStatus::Pending;
|
||||
}
|
||||
|
||||
let has_cancelled = candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.status == RequestCandidateStatus::Cancelled);
|
||||
let has_failed = candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.status == RequestCandidateStatus::Failed);
|
||||
if has_cancelled && !has_failed {
|
||||
return RequestCandidateFinalStatus::Cancelled;
|
||||
}
|
||||
|
||||
RequestCandidateFinalStatus::Failed
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct DecisionTraceCandidate {
|
||||
#[serde(flatten)]
|
||||
pub candidate: StoredRequestCandidate,
|
||||
pub provider_name: Option<String>,
|
||||
pub provider_website: Option<String>,
|
||||
pub provider_type: Option<String>,
|
||||
pub endpoint_api_format: Option<String>,
|
||||
pub endpoint_api_family: Option<String>,
|
||||
pub endpoint_kind: Option<String>,
|
||||
pub provider_key_name: Option<String>,
|
||||
pub provider_key_auth_type: Option<String>,
|
||||
pub provider_key_capabilities: Option<serde_json::Value>,
|
||||
pub provider_key_is_active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct DecisionTrace {
|
||||
pub request_id: String,
|
||||
pub total_candidates: usize,
|
||||
pub final_status: RequestCandidateFinalStatus,
|
||||
pub total_latency_ms: u64,
|
||||
pub candidates: Vec<DecisionTraceCandidate>,
|
||||
}
|
||||
|
||||
pub fn build_decision_trace(
|
||||
trace: RequestCandidateTrace,
|
||||
providers: Vec<StoredProviderCatalogProvider>,
|
||||
endpoints: Vec<StoredProviderCatalogEndpoint>,
|
||||
keys: Vec<StoredProviderCatalogKey>,
|
||||
) -> DecisionTrace {
|
||||
let provider_map = providers
|
||||
.into_iter()
|
||||
.map(|item| (item.id.clone(), item))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let endpoint_map = endpoints
|
||||
.into_iter()
|
||||
.map(|item| (item.id.clone(), item))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let key_map = keys
|
||||
.into_iter()
|
||||
.map(|item| (item.id.clone(), item))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
DecisionTrace {
|
||||
request_id: trace.request_id,
|
||||
total_candidates: trace.total_candidates,
|
||||
final_status: trace.final_status,
|
||||
total_latency_ms: trace.total_latency_ms,
|
||||
candidates: trace
|
||||
.candidates
|
||||
.into_iter()
|
||||
.map(|candidate| {
|
||||
enrich_decision_trace_candidate(candidate, &provider_map, &endpoint_map, &key_map)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn enrich_decision_trace_candidate(
|
||||
candidate: StoredRequestCandidate,
|
||||
provider_map: &BTreeMap<String, StoredProviderCatalogProvider>,
|
||||
endpoint_map: &BTreeMap<String, StoredProviderCatalogEndpoint>,
|
||||
key_map: &BTreeMap<String, StoredProviderCatalogKey>,
|
||||
) -> DecisionTraceCandidate {
|
||||
let provider = candidate
|
||||
.provider_id
|
||||
.as_ref()
|
||||
.and_then(|provider_id| provider_map.get(provider_id));
|
||||
let endpoint = candidate
|
||||
.endpoint_id
|
||||
.as_ref()
|
||||
.and_then(|endpoint_id| endpoint_map.get(endpoint_id));
|
||||
let provider_key = candidate
|
||||
.key_id
|
||||
.as_ref()
|
||||
.and_then(|key_id| key_map.get(key_id));
|
||||
|
||||
DecisionTraceCandidate {
|
||||
provider_name: provider.map(|item| item.name.clone()),
|
||||
provider_website: provider.and_then(|item| item.website.clone()),
|
||||
provider_type: provider.map(|item| item.provider_type.clone()),
|
||||
endpoint_api_format: endpoint.map(|item| item.api_format.clone()),
|
||||
endpoint_api_family: endpoint.and_then(|item| item.api_family.clone()),
|
||||
endpoint_kind: endpoint.and_then(|item| item.endpoint_kind.clone()),
|
||||
provider_key_name: provider_key
|
||||
.map(|item| item.name.clone())
|
||||
.or_else(|| candidate.api_key_name.clone()),
|
||||
provider_key_auth_type: provider_key.map(|item| item.auth_type.clone()),
|
||||
provider_key_capabilities: provider_key.and_then(|item| item.capabilities.clone()),
|
||||
provider_key_is_active: provider_key.map(|item| item.is_active),
|
||||
candidate,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PublicHealthStatusCount {
|
||||
pub endpoint_id: String,
|
||||
@@ -313,7 +523,14 @@ impl<T> RequestCandidateRepository for T where
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{RequestCandidateStatus, StoredRequestCandidate, UpsertRequestCandidateRecord};
|
||||
use super::{
|
||||
build_decision_trace, derive_request_candidate_final_status, DecisionTrace,
|
||||
DecisionTraceCandidate, RequestCandidateFinalStatus, RequestCandidateStatus,
|
||||
RequestCandidateTrace, StoredRequestCandidate, UpsertRequestCandidateRecord,
|
||||
};
|
||||
use crate::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn parses_status_from_database_text() {
|
||||
@@ -359,6 +576,184 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
fn sample_candidate(
|
||||
id: &str,
|
||||
request_id: &str,
|
||||
candidate_index: i32,
|
||||
status: RequestCandidateStatus,
|
||||
started_at_unix_secs: Option<i64>,
|
||||
latency_ms: Option<i32>,
|
||||
status_code: Option<i32>,
|
||||
) -> StoredRequestCandidate {
|
||||
StoredRequestCandidate::new(
|
||||
id.to_string(),
|
||||
request_id.to_string(),
|
||||
Some("user-1".to_string()),
|
||||
Some("api-key-1".to_string()),
|
||||
Some("alice".to_string()),
|
||||
Some("default".to_string()),
|
||||
candidate_index,
|
||||
0,
|
||||
Some("provider-1".to_string()),
|
||||
Some("endpoint-1".to_string()),
|
||||
Some("provider-key-1".to_string()),
|
||||
status,
|
||||
None,
|
||||
false,
|
||||
status_code,
|
||||
None,
|
||||
None,
|
||||
latency_ms,
|
||||
Some(1),
|
||||
None,
|
||||
None,
|
||||
100 + i64::from(candidate_index),
|
||||
started_at_unix_secs,
|
||||
started_at_unix_secs.map(|value| value + 1),
|
||||
)
|
||||
.expect("candidate should build")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_request_candidate_final_status_preferring_success() {
|
||||
let candidates = vec![sample_candidate(
|
||||
"cand-1",
|
||||
"req-1",
|
||||
0,
|
||||
RequestCandidateStatus::Success,
|
||||
Some(100),
|
||||
Some(25),
|
||||
Some(200),
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
derive_request_candidate_final_status(&candidates),
|
||||
RequestCandidateFinalStatus::Success
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_candidate_trace_filters_attempted_rows() {
|
||||
let trace = RequestCandidateTrace::from_candidates(
|
||||
"req-1",
|
||||
vec![
|
||||
sample_candidate(
|
||||
"cand-1",
|
||||
"req-1",
|
||||
0,
|
||||
RequestCandidateStatus::Pending,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
sample_candidate(
|
||||
"cand-2",
|
||||
"req-1",
|
||||
1,
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(101),
|
||||
Some(33),
|
||||
Some(502),
|
||||
),
|
||||
],
|
||||
true,
|
||||
)
|
||||
.expect("trace should exist");
|
||||
|
||||
assert_eq!(trace.total_candidates, 1);
|
||||
assert_eq!(trace.candidates[0].id, "cand-2");
|
||||
assert_eq!(trace.final_status, RequestCandidateFinalStatus::Failed);
|
||||
assert_eq!(trace.total_latency_ms, 33);
|
||||
}
|
||||
|
||||
fn sample_provider() -> StoredProviderCatalogProvider {
|
||||
StoredProviderCatalogProvider::new(
|
||||
"provider-1".to_string(),
|
||||
"OpenAI".to_string(),
|
||||
Some("https://openai.com".to_string()),
|
||||
"custom".to_string(),
|
||||
)
|
||||
.expect("provider should build")
|
||||
}
|
||||
|
||||
fn sample_endpoint() -> StoredProviderCatalogEndpoint {
|
||||
StoredProviderCatalogEndpoint::new(
|
||||
"endpoint-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"openai:chat".to_string(),
|
||||
Some("openai".to_string()),
|
||||
Some("chat".to_string()),
|
||||
true,
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
}
|
||||
|
||||
fn sample_key() -> StoredProviderCatalogKey {
|
||||
StoredProviderCatalogKey::new(
|
||||
"provider-key-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"prod-key".to_string(),
|
||||
"api_key".to_string(),
|
||||
Some(serde_json::json!({"cache_1h": true})),
|
||||
true,
|
||||
)
|
||||
.expect("key should build")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_decision_trace_enriches_candidate_with_provider_catalog_metadata() {
|
||||
let trace = RequestCandidateTrace::from_candidates(
|
||||
"req-1",
|
||||
vec![sample_candidate(
|
||||
"cand-1",
|
||||
"req-1",
|
||||
0,
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(101),
|
||||
Some(37),
|
||||
Some(502),
|
||||
)],
|
||||
true,
|
||||
)
|
||||
.expect("trace should exist");
|
||||
|
||||
assert_eq!(
|
||||
build_decision_trace(
|
||||
trace,
|
||||
vec![sample_provider()],
|
||||
vec![sample_endpoint()],
|
||||
vec![sample_key()],
|
||||
),
|
||||
DecisionTrace {
|
||||
request_id: "req-1".to_string(),
|
||||
total_candidates: 1,
|
||||
final_status: RequestCandidateFinalStatus::Failed,
|
||||
total_latency_ms: 37,
|
||||
candidates: vec![DecisionTraceCandidate {
|
||||
candidate: sample_candidate(
|
||||
"cand-1",
|
||||
"req-1",
|
||||
0,
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(101),
|
||||
Some(37),
|
||||
Some(502),
|
||||
),
|
||||
provider_name: Some("OpenAI".to_string()),
|
||||
provider_website: Some("https://openai.com".to_string()),
|
||||
provider_type: Some("custom".to_string()),
|
||||
endpoint_api_format: Some("openai:chat".to_string()),
|
||||
endpoint_api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
provider_key_name: Some("prod-key".to_string()),
|
||||
provider_key_auth_type: Some("api_key".to_string()),
|
||||
provider_key_capabilities: Some(serde_json::json!({"cache_1h": true})),
|
||||
provider_key_is_active: Some(true),
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_negative_created_at() {
|
||||
assert!(StoredRequestCandidate::new(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod announcements;
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod auth_modules;
|
||||
pub mod billing;
|
||||
@@ -9,9 +10,12 @@ pub mod global_models;
|
||||
pub mod management_tokens;
|
||||
pub mod oauth_providers;
|
||||
pub mod provider_catalog;
|
||||
pub mod provider_oauth;
|
||||
pub mod proxy_nodes;
|
||||
pub mod quota;
|
||||
pub mod settlement;
|
||||
pub mod shadow_results;
|
||||
pub mod system;
|
||||
pub mod usage;
|
||||
pub mod users;
|
||||
pub mod video_tasks;
|
||||
|
||||
191
crates/aether-data/src/repository/provider_oauth.rs
Normal file
191
crates/aether-data/src/repository/provider_oauth.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
const KIRO_DEVICE_AUTH_SESSION_PREFIX: &str = "device_auth_session:";
|
||||
const PROVIDER_OAUTH_BATCH_TASK_PREFIX: &str = "provider_oauth_batch_task:";
|
||||
const PROVIDER_OAUTH_STATE_PREFIX: &str = "provider_oauth_state:";
|
||||
|
||||
pub const KIRO_DEVICE_AUTH_SESSION_TTL_BUFFER_SECS: u64 = 60;
|
||||
pub const PROVIDER_OAUTH_BATCH_TASK_TTL_SECS: u64 = 24 * 60 * 60;
|
||||
pub const PROVIDER_OAUTH_STATE_TTL_SECS: u64 = 600;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminProviderOAuthDeviceSession {
|
||||
pub provider_id: String,
|
||||
pub region: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub device_code: String,
|
||||
pub interval: u64,
|
||||
pub expires_at_unix_secs: u64,
|
||||
pub status: String,
|
||||
pub proxy_node_id: Option<String>,
|
||||
pub created_at_unix_secs: u64,
|
||||
pub key_id: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub replaced: bool,
|
||||
pub error_msg: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminProviderOAuthState {
|
||||
pub key_id: String,
|
||||
pub provider_id: String,
|
||||
pub provider_type: String,
|
||||
pub pkce_verifier: Option<String>,
|
||||
}
|
||||
|
||||
pub fn provider_oauth_device_session_storage_key(session_id: &str) -> String {
|
||||
format!("{KIRO_DEVICE_AUTH_SESSION_PREFIX}{session_id}")
|
||||
}
|
||||
|
||||
pub fn provider_oauth_state_storage_key(nonce: &str) -> String {
|
||||
format!("{PROVIDER_OAUTH_STATE_PREFIX}{nonce}")
|
||||
}
|
||||
|
||||
pub fn provider_oauth_batch_task_storage_key(task_id: &str) -> String {
|
||||
format!("{PROVIDER_OAUTH_BATCH_TASK_PREFIX}{task_id}")
|
||||
}
|
||||
|
||||
pub fn build_provider_oauth_batch_task_status_payload(
|
||||
provider_id: &str,
|
||||
state: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> serde_json::Value {
|
||||
let now_unix_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
let raw_status = state
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("failed");
|
||||
let normalized_status = match raw_status {
|
||||
"submitted" | "processing" | "completed" | "failed" => raw_status,
|
||||
_ => "failed",
|
||||
};
|
||||
let error_samples = state
|
||||
.get("error_samples")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter(|item| item.is_object())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
serde_json::json!({
|
||||
"task_id": state
|
||||
.get("task_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default(),
|
||||
"provider_id": provider_id,
|
||||
"provider_type": state
|
||||
.get("provider_type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default(),
|
||||
"status": normalized_status,
|
||||
"total": state.get("total").and_then(serde_json::Value::as_i64).unwrap_or(0),
|
||||
"processed": state.get("processed").and_then(serde_json::Value::as_i64).unwrap_or(0),
|
||||
"success": state.get("success").and_then(serde_json::Value::as_i64).unwrap_or(0),
|
||||
"failed": state.get("failed").and_then(serde_json::Value::as_i64).unwrap_or(0),
|
||||
"progress_percent": state
|
||||
.get("progress_percent")
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
.clamp(0, 100),
|
||||
"message": state.get("message").cloned().unwrap_or(serde_json::Value::Null),
|
||||
"error": state.get("error").cloned().unwrap_or(serde_json::Value::Null),
|
||||
"error_samples": error_samples,
|
||||
"created_at": state
|
||||
.get("created_at")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(now_unix_secs),
|
||||
"started_at": state.get("started_at").cloned().unwrap_or(serde_json::Value::Null),
|
||||
"finished_at": state
|
||||
.get("finished_at")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"updated_at": state
|
||||
.get("updated_at")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(now_unix_secs),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_provider_oauth_batch_task_status_payload, provider_oauth_batch_task_storage_key,
|
||||
provider_oauth_device_session_storage_key, provider_oauth_state_storage_key,
|
||||
KIRO_DEVICE_AUTH_SESSION_TTL_BUFFER_SECS, PROVIDER_OAUTH_BATCH_TASK_TTL_SECS,
|
||||
PROVIDER_OAUTH_STATE_TTL_SECS,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn builds_provider_oauth_storage_keys_with_expected_prefixes() {
|
||||
assert_eq!(
|
||||
provider_oauth_device_session_storage_key("session-123"),
|
||||
"device_auth_session:session-123"
|
||||
);
|
||||
assert_eq!(
|
||||
provider_oauth_state_storage_key("nonce-123"),
|
||||
"provider_oauth_state:nonce-123"
|
||||
);
|
||||
assert_eq!(
|
||||
provider_oauth_batch_task_storage_key("task-123"),
|
||||
"provider_oauth_batch_task:task-123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_oauth_storage_ttls_match_gateway_expectations() {
|
||||
assert_eq!(KIRO_DEVICE_AUTH_SESSION_TTL_BUFFER_SECS, 60);
|
||||
assert_eq!(PROVIDER_OAUTH_BATCH_TASK_TTL_SECS, 24 * 60 * 60);
|
||||
assert_eq!(PROVIDER_OAUTH_STATE_TTL_SECS, 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_task_status_payload_normalizes_status_and_clamps_progress() {
|
||||
let input = json!({
|
||||
"task_id": "task-123",
|
||||
"provider_type": "codex",
|
||||
"status": "weird",
|
||||
"total": 4,
|
||||
"processed": 2,
|
||||
"success": 1,
|
||||
"failed": 1,
|
||||
"progress_percent": 999,
|
||||
"error_samples": [
|
||||
{"detail": "x"},
|
||||
"skip-me"
|
||||
],
|
||||
"created_at": 1u64,
|
||||
"updated_at": 2u64
|
||||
});
|
||||
|
||||
let payload = build_provider_oauth_batch_task_status_payload(
|
||||
"provider-123",
|
||||
input.as_object().expect("input should be object"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
payload.get("provider_id").and_then(|v| v.as_str()),
|
||||
Some("provider-123")
|
||||
);
|
||||
assert_eq!(
|
||||
payload.get("status").and_then(|v| v.as_str()),
|
||||
Some("failed")
|
||||
);
|
||||
assert_eq!(
|
||||
payload.get("progress_percent").and_then(|v| v.as_i64()),
|
||||
Some(100)
|
||||
);
|
||||
assert_eq!(
|
||||
payload
|
||||
.get("error_samples")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(Vec::len),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
}
|
||||
228
crates/aether-data/src/repository/settlement/memory.rs
Normal file
228
crates/aether-data/src/repository/settlement/memory.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::types::{SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput};
|
||||
use crate::repository::wallet::{InMemoryWalletRepository, StoredWalletSnapshot};
|
||||
use crate::DataLayerError;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum InMemorySettlementWalletStore {
|
||||
Owned(RwLock<BTreeMap<String, StoredWalletSnapshot>>),
|
||||
Shared(Arc<InMemoryWalletRepository>),
|
||||
}
|
||||
|
||||
impl Default for InMemorySettlementWalletStore {
|
||||
fn default() -> Self {
|
||||
Self::Owned(RwLock::new(BTreeMap::new()))
|
||||
}
|
||||
}
|
||||
|
||||
impl InMemorySettlementWalletStore {
|
||||
fn seeded<I>(items: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredWalletSnapshot>,
|
||||
{
|
||||
let mut wallets_by_id = BTreeMap::new();
|
||||
for item in items {
|
||||
wallets_by_id.insert(item.id.clone(), item);
|
||||
}
|
||||
Self::Owned(RwLock::new(wallets_by_id))
|
||||
}
|
||||
|
||||
fn with_mut<R>(&self, f: impl FnOnce(&mut BTreeMap<String, StoredWalletSnapshot>) -> R) -> R {
|
||||
match self {
|
||||
Self::Owned(wallets_by_id) => {
|
||||
let mut wallets = wallets_by_id.write().expect("settlement repo lock");
|
||||
f(&mut wallets)
|
||||
}
|
||||
Self::Shared(repository) => repository.with_wallets_mut(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemorySettlementRepository {
|
||||
wallets: InMemorySettlementWalletStore,
|
||||
provider_monthly_used: RwLock<BTreeMap<String, f64>>,
|
||||
}
|
||||
|
||||
impl InMemorySettlementRepository {
|
||||
pub fn seed<I>(items: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredWalletSnapshot>,
|
||||
{
|
||||
Self {
|
||||
wallets: InMemorySettlementWalletStore::seeded(items),
|
||||
provider_monthly_used: RwLock::new(BTreeMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_wallet_repository(wallet_repository: Arc<InMemoryWalletRepository>) -> Self {
|
||||
Self {
|
||||
wallets: InMemorySettlementWalletStore::Shared(wallet_repository),
|
||||
provider_monthly_used: RwLock::new(BTreeMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettlementWriteRepository for InMemorySettlementRepository {
|
||||
async fn settle_usage(
|
||||
&self,
|
||||
input: UsageSettlementInput,
|
||||
) -> Result<Option<StoredUsageSettlement>, DataLayerError> {
|
||||
input.validate()?;
|
||||
if input.billing_status != "pending" {
|
||||
return Ok(Some(StoredUsageSettlement {
|
||||
request_id: input.request_id,
|
||||
wallet_id: None,
|
||||
billing_status: input.billing_status,
|
||||
wallet_balance_before: None,
|
||||
wallet_balance_after: None,
|
||||
wallet_recharge_balance_before: None,
|
||||
wallet_recharge_balance_after: None,
|
||||
wallet_gift_balance_before: None,
|
||||
wallet_gift_balance_after: None,
|
||||
provider_monthly_used_usd: None,
|
||||
finalized_at_unix_secs: input.finalized_at_unix_secs,
|
||||
}));
|
||||
}
|
||||
|
||||
let final_billing_status = if input.status == "completed" {
|
||||
"settled"
|
||||
} else {
|
||||
"void"
|
||||
};
|
||||
let mut settlement = self.wallets.with_mut(|wallets| {
|
||||
let wallet_id = input
|
||||
.api_key_id
|
||||
.as_deref()
|
||||
.and_then(|api_key_id| {
|
||||
wallets
|
||||
.values()
|
||||
.find(|wallet| wallet.api_key_id.as_deref() == Some(api_key_id))
|
||||
.map(|wallet| wallet.id.clone())
|
||||
})
|
||||
.or_else(|| {
|
||||
input.user_id.as_deref().and_then(|user_id| {
|
||||
wallets
|
||||
.values()
|
||||
.find(|wallet| wallet.user_id.as_deref() == Some(user_id))
|
||||
.map(|wallet| wallet.id.clone())
|
||||
})
|
||||
});
|
||||
let wallet = wallet_id
|
||||
.as_deref()
|
||||
.and_then(|wallet_id| wallets.get_mut(wallet_id));
|
||||
|
||||
let mut settlement = StoredUsageSettlement {
|
||||
request_id: input.request_id.clone(),
|
||||
wallet_id: None,
|
||||
billing_status: final_billing_status.to_string(),
|
||||
wallet_balance_before: None,
|
||||
wallet_balance_after: None,
|
||||
wallet_recharge_balance_before: None,
|
||||
wallet_recharge_balance_after: None,
|
||||
wallet_gift_balance_before: None,
|
||||
wallet_gift_balance_after: None,
|
||||
provider_monthly_used_usd: None,
|
||||
finalized_at_unix_secs: input.finalized_at_unix_secs,
|
||||
};
|
||||
|
||||
if let Some(wallet) = wallet {
|
||||
let before_recharge = wallet.balance;
|
||||
let before_gift = wallet.gift_balance;
|
||||
let before_total = before_recharge + before_gift;
|
||||
settlement.wallet_id = Some(wallet.id.clone());
|
||||
settlement.wallet_balance_before = Some(before_total);
|
||||
settlement.wallet_recharge_balance_before = Some(before_recharge);
|
||||
settlement.wallet_gift_balance_before = Some(before_gift);
|
||||
|
||||
if final_billing_status == "settled" {
|
||||
if wallet.limit_mode.eq_ignore_ascii_case("unlimited") {
|
||||
wallet.total_consumed += input.total_cost_usd;
|
||||
} else {
|
||||
let gift_deduction = before_gift.max(0.0).min(input.total_cost_usd);
|
||||
let recharge_deduction = input.total_cost_usd - gift_deduction;
|
||||
wallet.gift_balance = before_gift - gift_deduction;
|
||||
wallet.balance = before_recharge - recharge_deduction;
|
||||
wallet.total_consumed += input.total_cost_usd;
|
||||
}
|
||||
}
|
||||
|
||||
settlement.wallet_recharge_balance_after = Some(wallet.balance);
|
||||
settlement.wallet_gift_balance_after = Some(wallet.gift_balance);
|
||||
settlement.wallet_balance_after = Some(wallet.balance + wallet.gift_balance);
|
||||
}
|
||||
|
||||
settlement
|
||||
});
|
||||
|
||||
if final_billing_status == "settled" {
|
||||
if let Some(provider_id) = input.provider_id {
|
||||
let mut quotas = self
|
||||
.provider_monthly_used
|
||||
.write()
|
||||
.expect("provider quota lock");
|
||||
let value = quotas.entry(provider_id).or_insert(0.0);
|
||||
*value += input.actual_total_cost_usd;
|
||||
settlement.provider_monthly_used_usd = Some(*value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(settlement))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::InMemorySettlementRepository;
|
||||
use crate::repository::settlement::{SettlementWriteRepository, UsageSettlementInput};
|
||||
use crate::repository::wallet::StoredWalletSnapshot;
|
||||
|
||||
fn sample_wallet() -> StoredWalletSnapshot {
|
||||
StoredWalletSnapshot::new(
|
||||
"wallet-1".to_string(),
|
||||
Some("user-1".to_string()),
|
||||
Some("key-1".to_string()),
|
||||
10.0,
|
||||
2.0,
|
||||
"finite".to_string(),
|
||||
"USD".to_string(),
|
||||
"active".to_string(),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
100,
|
||||
)
|
||||
.expect("wallet should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settles_usage_against_wallet_and_provider_quota() {
|
||||
let repository = InMemorySettlementRepository::seed(vec![sample_wallet()]);
|
||||
let settlement = repository
|
||||
.settle_usage(UsageSettlementInput {
|
||||
request_id: "req-1".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
api_key_id: Some("key-1".to_string()),
|
||||
provider_id: Some("provider-1".to_string()),
|
||||
status: "completed".to_string(),
|
||||
billing_status: "pending".to_string(),
|
||||
total_cost_usd: 3.0,
|
||||
actual_total_cost_usd: 1.5,
|
||||
finalized_at_unix_secs: Some(200),
|
||||
})
|
||||
.await
|
||||
.expect("settlement should succeed")
|
||||
.expect("settlement should exist");
|
||||
|
||||
assert_eq!(settlement.billing_status, "settled");
|
||||
assert_eq!(settlement.wallet_balance_before, Some(12.0));
|
||||
assert_eq!(settlement.wallet_balance_after, Some(9.0));
|
||||
assert_eq!(settlement.provider_monthly_used_usd, Some(1.5));
|
||||
}
|
||||
}
|
||||
9
crates/aether-data/src/repository/settlement/mod.rs
Normal file
9
crates/aether-data/src/repository/settlement/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod memory;
|
||||
mod sql;
|
||||
mod types;
|
||||
|
||||
pub use memory::InMemorySettlementRepository;
|
||||
pub use sql::SqlxSettlementRepository;
|
||||
pub use types::{
|
||||
SettlementRepository, SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput,
|
||||
};
|
||||
279
crates/aether-data/src/repository/settlement/sql.rs
Normal file
279
crates/aether-data/src/repository/settlement/sql.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
use super::types::{SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput};
|
||||
use crate::postgres::PostgresTransactionRunner;
|
||||
use crate::DataLayerError;
|
||||
|
||||
const FINALIZE_USAGE_BILLING_SQL: &str = r#"
|
||||
UPDATE "usage"
|
||||
SET
|
||||
billing_status = $2,
|
||||
finalized_at = COALESCE(finalized_at, to_timestamp($3))
|
||||
WHERE request_id = $1
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SqlxSettlementRepository {
|
||||
tx_runner: PostgresTransactionRunner,
|
||||
}
|
||||
|
||||
impl SqlxSettlementRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
let tx_runner = PostgresTransactionRunner::new(pool);
|
||||
Self { tx_runner }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettlementWriteRepository for SqlxSettlementRepository {
|
||||
async fn settle_usage(
|
||||
&self,
|
||||
input: UsageSettlementInput,
|
||||
) -> Result<Option<StoredUsageSettlement>, DataLayerError> {
|
||||
input.validate()?;
|
||||
self.tx_runner
|
||||
.run_read_write(|tx| {
|
||||
Box::pin(async move {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
request_id,
|
||||
wallet_id,
|
||||
billing_status,
|
||||
CAST(wallet_balance_before AS DOUBLE PRECISION) AS wallet_balance_before,
|
||||
CAST(wallet_balance_after AS DOUBLE PRECISION) AS wallet_balance_after,
|
||||
CAST(wallet_recharge_balance_before AS DOUBLE PRECISION) AS wallet_recharge_balance_before,
|
||||
CAST(wallet_recharge_balance_after AS DOUBLE PRECISION) AS wallet_recharge_balance_after,
|
||||
CAST(wallet_gift_balance_before AS DOUBLE PRECISION) AS wallet_gift_balance_before,
|
||||
CAST(wallet_gift_balance_after AS DOUBLE PRECISION) AS wallet_gift_balance_after,
|
||||
provider_id,
|
||||
CAST(EXTRACT(EPOCH FROM finalized_at) AS BIGINT) AS finalized_at_unix_secs
|
||||
FROM "usage"
|
||||
WHERE request_id = $1
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(&input.request_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let Some(usage_row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let current_billing_status: String = usage_row.try_get("billing_status")?;
|
||||
if current_billing_status == "settled" || current_billing_status == "void" {
|
||||
return Ok(Some(StoredUsageSettlement {
|
||||
request_id: usage_row.try_get("request_id")?,
|
||||
wallet_id: usage_row.try_get("wallet_id")?,
|
||||
billing_status: current_billing_status,
|
||||
wallet_balance_before: usage_row.try_get("wallet_balance_before")?,
|
||||
wallet_balance_after: usage_row.try_get("wallet_balance_after")?,
|
||||
wallet_recharge_balance_before: usage_row
|
||||
.try_get("wallet_recharge_balance_before")?,
|
||||
wallet_recharge_balance_after: usage_row
|
||||
.try_get("wallet_recharge_balance_after")?,
|
||||
wallet_gift_balance_before: usage_row
|
||||
.try_get("wallet_gift_balance_before")?,
|
||||
wallet_gift_balance_after: usage_row
|
||||
.try_get("wallet_gift_balance_after")?,
|
||||
provider_monthly_used_usd: None,
|
||||
finalized_at_unix_secs: usage_row
|
||||
.try_get::<Option<i64>, _>("finalized_at_unix_secs")?
|
||||
.map(|value| value as u64),
|
||||
}));
|
||||
}
|
||||
|
||||
let final_billing_status = if input.status == "completed" {
|
||||
"settled"
|
||||
} else {
|
||||
"void"
|
||||
};
|
||||
let finalized_at =
|
||||
i64::try_from(input.finalized_at_unix_secs.unwrap_or_else(|| {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}))
|
||||
.map_err(|_| {
|
||||
DataLayerError::InvalidInput("finalized_at overflow".to_string())
|
||||
})?;
|
||||
|
||||
let mut settlement = StoredUsageSettlement {
|
||||
request_id: input.request_id.clone(),
|
||||
wallet_id: None,
|
||||
billing_status: final_billing_status.to_string(),
|
||||
wallet_balance_before: None,
|
||||
wallet_balance_after: None,
|
||||
wallet_recharge_balance_before: None,
|
||||
wallet_recharge_balance_after: None,
|
||||
wallet_gift_balance_before: None,
|
||||
wallet_gift_balance_after: None,
|
||||
provider_monthly_used_usd: None,
|
||||
finalized_at_unix_secs: Some(finalized_at as u64),
|
||||
};
|
||||
|
||||
if final_billing_status == "settled" {
|
||||
let wallet_row = if let Some(api_key_id) = input
|
||||
.api_key_id
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
CAST(balance AS DOUBLE PRECISION) AS balance,
|
||||
CAST(gift_balance AS DOUBLE PRECISION) AS gift_balance,
|
||||
limit_mode
|
||||
FROM wallets
|
||||
WHERE api_key_id = $1
|
||||
FOR UPDATE
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(api_key_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let wallet_row = if wallet_row.is_some() {
|
||||
wallet_row
|
||||
} else if let Some(user_id) =
|
||||
input.user_id.as_deref().filter(|value| !value.is_empty())
|
||||
{
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
CAST(balance AS DOUBLE PRECISION) AS balance,
|
||||
CAST(gift_balance AS DOUBLE PRECISION) AS gift_balance,
|
||||
limit_mode
|
||||
FROM wallets
|
||||
WHERE user_id = $1
|
||||
FOR UPDATE
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(wallet_row) = wallet_row {
|
||||
let wallet_id: String = wallet_row.try_get("id")?;
|
||||
let before_recharge: f64 = wallet_row.try_get("balance")?;
|
||||
let before_gift: f64 = wallet_row.try_get("gift_balance")?;
|
||||
let limit_mode: String = wallet_row.try_get("limit_mode")?;
|
||||
let before_total = before_recharge + before_gift;
|
||||
let mut after_recharge = before_recharge;
|
||||
let mut after_gift = before_gift;
|
||||
if !limit_mode.eq_ignore_ascii_case("unlimited") {
|
||||
let gift_deduction = before_gift.max(0.0).min(input.total_cost_usd);
|
||||
let recharge_deduction = input.total_cost_usd - gift_deduction;
|
||||
after_gift = before_gift - gift_deduction;
|
||||
after_recharge = before_recharge - recharge_deduction;
|
||||
}
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE wallets
|
||||
SET
|
||||
balance = $2,
|
||||
gift_balance = $3,
|
||||
total_consumed = CAST(total_consumed AS DOUBLE PRECISION) + $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(&wallet_id)
|
||||
.bind(after_recharge)
|
||||
.bind(after_gift)
|
||||
.bind(input.total_cost_usd)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
settlement.wallet_id = Some(wallet_id.clone());
|
||||
settlement.wallet_balance_before = Some(before_total);
|
||||
settlement.wallet_balance_after = Some(after_recharge + after_gift);
|
||||
settlement.wallet_recharge_balance_before = Some(before_recharge);
|
||||
settlement.wallet_recharge_balance_after = Some(after_recharge);
|
||||
settlement.wallet_gift_balance_before = Some(before_gift);
|
||||
settlement.wallet_gift_balance_after = Some(after_gift);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE "usage"
|
||||
SET
|
||||
wallet_id = $2,
|
||||
wallet_balance_before = $3,
|
||||
wallet_balance_after = $4,
|
||||
wallet_recharge_balance_before = $5,
|
||||
wallet_recharge_balance_after = $6,
|
||||
wallet_gift_balance_before = $7,
|
||||
wallet_gift_balance_after = $8
|
||||
WHERE request_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(&input.request_id)
|
||||
.bind(&wallet_id)
|
||||
.bind(before_total)
|
||||
.bind(after_recharge + after_gift)
|
||||
.bind(before_recharge)
|
||||
.bind(after_recharge)
|
||||
.bind(before_gift)
|
||||
.bind(after_gift)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(provider_id) = input
|
||||
.provider_id
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let quota_row = sqlx::query(
|
||||
r#"
|
||||
UPDATE providers
|
||||
SET
|
||||
monthly_used_usd = COALESCE(monthly_used_usd, 0) + $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING CAST(monthly_used_usd AS DOUBLE PRECISION) AS monthly_used_usd
|
||||
"#,
|
||||
)
|
||||
.bind(provider_id)
|
||||
.bind(input.actual_total_cost_usd)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
settlement.provider_monthly_used_usd =
|
||||
quota_row.and_then(|row| row.try_get("monthly_used_usd").ok());
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
|
||||
.bind(&input.request_id)
|
||||
.bind(final_billing_status)
|
||||
.bind(finalized_at)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(Some(settlement))
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn finalize_usage_billing_sql_does_not_require_usage_updated_at_column() {
|
||||
assert!(!super::FINALIZE_USAGE_BILLING_SQL.contains("updated_at"));
|
||||
}
|
||||
}
|
||||
83
crates/aether-data/src/repository/settlement/types.rs
Normal file
83
crates/aether-data/src/repository/settlement/types.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UsageSettlementInput {
|
||||
pub request_id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub api_key_id: Option<String>,
|
||||
pub provider_id: Option<String>,
|
||||
pub status: String,
|
||||
pub billing_status: String,
|
||||
pub total_cost_usd: f64,
|
||||
pub actual_total_cost_usd: f64,
|
||||
pub finalized_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl UsageSettlementInput {
|
||||
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
|
||||
if self.request_id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"settlement request_id cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.status.trim().is_empty() || self.billing_status.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"settlement status cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if !self.total_cost_usd.is_finite() || !self.actual_total_cost_usd.is_finite() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"settlement cost must be finite".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredUsageSettlement {
|
||||
pub request_id: String,
|
||||
pub wallet_id: Option<String>,
|
||||
pub billing_status: String,
|
||||
pub wallet_balance_before: Option<f64>,
|
||||
pub wallet_balance_after: Option<f64>,
|
||||
pub wallet_recharge_balance_before: Option<f64>,
|
||||
pub wallet_recharge_balance_after: Option<f64>,
|
||||
pub wallet_gift_balance_before: Option<f64>,
|
||||
pub wallet_gift_balance_after: Option<f64>,
|
||||
pub provider_monthly_used_usd: Option<f64>,
|
||||
pub finalized_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SettlementWriteRepository: Send + Sync {
|
||||
async fn settle_usage(
|
||||
&self,
|
||||
input: UsageSettlementInput,
|
||||
) -> Result<Option<StoredUsageSettlement>, crate::DataLayerError>;
|
||||
}
|
||||
|
||||
pub trait SettlementRepository: SettlementWriteRepository + Send + Sync {}
|
||||
|
||||
impl<T> SettlementRepository for T where T: SettlementWriteRepository + Send + Sync {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::UsageSettlementInput;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_settlement_input() {
|
||||
let input = UsageSettlementInput {
|
||||
request_id: "".to_string(),
|
||||
user_id: None,
|
||||
api_key_id: None,
|
||||
provider_id: None,
|
||||
status: "completed".to_string(),
|
||||
billing_status: "pending".to_string(),
|
||||
total_cost_usd: 0.1,
|
||||
actual_total_cost_usd: 0.1,
|
||||
finalized_at_unix_secs: None,
|
||||
};
|
||||
assert!(input.validate().is_err());
|
||||
}
|
||||
}
|
||||
22
crates/aether-data/src/repository/system.rs
Normal file
22
crates/aether-data/src/repository/system.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredSystemConfigEntry {
|
||||
pub key: String,
|
||||
pub value: serde_json::Value,
|
||||
pub description: Option<String>,
|
||||
pub updated_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdminSecurityBlacklistEntry {
|
||||
pub ip_address: String,
|
||||
pub reason: String,
|
||||
pub ttl_seconds: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct AdminSystemStats {
|
||||
pub total_users: u64,
|
||||
pub active_users: u64,
|
||||
pub total_api_keys: u64,
|
||||
pub total_requests: u64,
|
||||
}
|
||||
@@ -5,6 +5,6 @@ mod types;
|
||||
pub use memory::InMemoryUserReadRepository;
|
||||
pub use sql::SqlxUserReadRepository;
|
||||
pub use types::{
|
||||
StoredUserAuthRecord, StoredUserExportRow, StoredUserSummary, UserExportListQuery,
|
||||
UserExportSummary, UserReadRepository,
|
||||
StoredUserAuthRecord, StoredUserExportRow, StoredUserPreferenceRecord, StoredUserSessionRecord,
|
||||
StoredUserSummary, UserExportListQuery, UserExportSummary, UserReadRepository,
|
||||
};
|
||||
|
||||
@@ -213,6 +213,165 @@ impl StoredUserExportRow {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredUserSessionRecord {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub client_device_id: String,
|
||||
pub device_label: Option<String>,
|
||||
pub refresh_token_hash: String,
|
||||
pub prev_refresh_token_hash: Option<String>,
|
||||
pub rotated_at: Option<DateTime<Utc>>,
|
||||
pub last_seen_at: Option<DateTime<Utc>>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
pub revoke_reason: Option<String>,
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl StoredUserSessionRecord {
|
||||
pub const REFRESH_GRACE_SECONDS: i64 = 10;
|
||||
pub const TOUCH_INTERVAL_SECONDS: i64 = 300;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: String,
|
||||
user_id: String,
|
||||
client_device_id: String,
|
||||
device_label: Option<String>,
|
||||
refresh_token_hash: String,
|
||||
prev_refresh_token_hash: Option<String>,
|
||||
rotated_at: Option<DateTime<Utc>>,
|
||||
last_seen_at: Option<DateTime<Utc>>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
revoked_at: Option<DateTime<Utc>>,
|
||||
revoke_reason: Option<String>,
|
||||
ip_address: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
created_at: Option<DateTime<Utc>>,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
) -> Result<Self, crate::DataLayerError> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"user_sessions.id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if user_id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"user_sessions.user_id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if client_device_id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"user_sessions.client_device_id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if refresh_token_hash.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"user_sessions.refresh_token_hash is empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
user_id,
|
||||
client_device_id,
|
||||
device_label,
|
||||
refresh_token_hash,
|
||||
prev_refresh_token_hash,
|
||||
rotated_at,
|
||||
last_seen_at,
|
||||
expires_at,
|
||||
revoked_at,
|
||||
revoke_reason,
|
||||
ip_address,
|
||||
user_agent,
|
||||
created_at,
|
||||
updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hash_refresh_token(token: &str) -> String {
|
||||
use sha2::Digest;
|
||||
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn verify_refresh_token(&self, token: &str, now: DateTime<Utc>) -> (bool, bool) {
|
||||
let token_hash = Self::hash_refresh_token(token);
|
||||
if self.refresh_token_hash == token_hash {
|
||||
return (true, false);
|
||||
}
|
||||
let Some(prev_hash) = self.prev_refresh_token_hash.as_ref() else {
|
||||
return (false, false);
|
||||
};
|
||||
let Some(rotated_at) = self.rotated_at else {
|
||||
return (false, false);
|
||||
};
|
||||
if prev_hash == &token_hash
|
||||
&& now.signed_duration_since(rotated_at).num_seconds() <= Self::REFRESH_GRACE_SECONDS
|
||||
{
|
||||
return (true, true);
|
||||
}
|
||||
(false, false)
|
||||
}
|
||||
|
||||
pub fn is_revoked(&self) -> bool {
|
||||
self.revoked_at.is_some()
|
||||
}
|
||||
|
||||
pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
|
||||
self.expires_at.is_none_or(|expires_at| expires_at <= now)
|
||||
}
|
||||
|
||||
pub fn should_touch(&self, now: DateTime<Utc>) -> bool {
|
||||
self.last_seen_at
|
||||
.map(|last_seen_at| {
|
||||
now.signed_duration_since(last_seen_at).num_seconds()
|
||||
>= Self::TOUCH_INTERVAL_SECONDS
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredUserPreferenceRecord {
|
||||
pub user_id: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub default_provider_id: Option<String>,
|
||||
pub default_provider_name: Option<String>,
|
||||
pub theme: String,
|
||||
pub language: String,
|
||||
pub timezone: String,
|
||||
pub email_notifications: bool,
|
||||
pub usage_alerts: bool,
|
||||
pub announcement_notifications: bool,
|
||||
}
|
||||
|
||||
impl StoredUserPreferenceRecord {
|
||||
pub fn default_for_user(user_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
user_id: user_id.into(),
|
||||
avatar_url: None,
|
||||
bio: None,
|
||||
default_provider_id: None,
|
||||
default_provider_name: None,
|
||||
theme: "light".to_string(),
|
||||
language: "zh-CN".to_string(),
|
||||
timezone: "Asia/Shanghai".to_string(),
|
||||
email_notifications: true,
|
||||
usage_alerts: true,
|
||||
announcement_notifications: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct UserExportListQuery {
|
||||
pub skip: usize,
|
||||
@@ -336,9 +495,13 @@ fn parse_string_list_array(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{Duration, Utc};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{StoredUserAuthRecord, StoredUserExportRow};
|
||||
use super::{
|
||||
StoredUserAuthRecord, StoredUserExportRow, StoredUserPreferenceRecord,
|
||||
StoredUserSessionRecord,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn builds_user_export_row_with_allowed_lists() {
|
||||
@@ -447,4 +610,49 @@ mod tests {
|
||||
);
|
||||
assert_eq!(row.allowed_models, Some(vec!["gpt-4.1".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_session_previous_refresh_token_has_grace_window() {
|
||||
let now = Utc::now();
|
||||
let session = StoredUserSessionRecord::new(
|
||||
"session-1".to_string(),
|
||||
"user-1".to_string(),
|
||||
"device-1".to_string(),
|
||||
None,
|
||||
StoredUserSessionRecord::hash_refresh_token("current-token"),
|
||||
Some(StoredUserSessionRecord::hash_refresh_token("prev-token")),
|
||||
Some(now - Duration::seconds(StoredUserSessionRecord::REFRESH_GRACE_SECONDS - 1)),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("session should build");
|
||||
|
||||
assert_eq!(
|
||||
session.verify_refresh_token("prev-token", now),
|
||||
(true, true)
|
||||
);
|
||||
assert_eq!(
|
||||
session.verify_refresh_token("current-token", now),
|
||||
(true, false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_preference_defaults_match_gateway_expectations() {
|
||||
let record = StoredUserPreferenceRecord::default_for_user("user-1");
|
||||
|
||||
assert_eq!(record.user_id, "user-1");
|
||||
assert_eq!(record.theme, "light");
|
||||
assert_eq!(record.language, "zh-CN");
|
||||
assert_eq!(record.timezone, "Asia/Shanghai");
|
||||
assert!(record.email_notifications);
|
||||
assert!(record.usage_alerts);
|
||||
assert!(record.announcement_notifications);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,17 @@ use std::sync::RwLock;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::types::{
|
||||
StoredUsageSettlement, StoredWalletSnapshot, UsageSettlementInput, WalletLookupKey,
|
||||
AdjustWalletBalanceInput, AdminPaymentOrderListQuery, AdminWalletLedgerQuery,
|
||||
AdminWalletListQuery, AdminWalletRefundRequestListQuery, CompleteAdminWalletRefundInput,
|
||||
CreateManualWalletRechargeInput, CreateWalletRechargeOrderInput,
|
||||
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
|
||||
CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput, FailAdminWalletRefundInput,
|
||||
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
|
||||
StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder, StoredAdminPaymentOrderPage,
|
||||
StoredAdminWalletLedgerPage, StoredAdminWalletListItem, StoredAdminWalletListPage,
|
||||
StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestPage,
|
||||
StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
||||
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, WalletLookupKey, WalletMutationOutcome,
|
||||
WalletReadRepository, WalletWriteRepository,
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
@@ -12,7 +22,6 @@ use crate::DataLayerError;
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryWalletRepository {
|
||||
wallets_by_id: RwLock<BTreeMap<String, StoredWalletSnapshot>>,
|
||||
provider_monthly_used: RwLock<BTreeMap<String, f64>>,
|
||||
}
|
||||
|
||||
impl InMemoryWalletRepository {
|
||||
@@ -26,9 +35,16 @@ impl InMemoryWalletRepository {
|
||||
}
|
||||
Self {
|
||||
wallets_by_id: RwLock::new(wallets_by_id),
|
||||
provider_monthly_used: RwLock::new(BTreeMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_wallets_mut<R>(
|
||||
&self,
|
||||
f: impl FnOnce(&mut BTreeMap<String, StoredWalletSnapshot>) -> R,
|
||||
) -> R {
|
||||
let mut wallets = self.wallets_by_id.write().expect("wallet repo lock");
|
||||
f(&mut wallets)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -96,112 +112,329 @@ impl WalletReadRepository for InMemoryWalletRepository {
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_admin_wallets(
|
||||
&self,
|
||||
query: &AdminWalletListQuery,
|
||||
) -> Result<StoredAdminWalletListPage, DataLayerError> {
|
||||
let wallets = self.wallets_by_id.read().expect("wallet repo lock");
|
||||
let mut items = wallets
|
||||
.values()
|
||||
.filter(|wallet| {
|
||||
query
|
||||
.status
|
||||
.as_deref()
|
||||
.is_none_or(|expected| wallet.status == expected)
|
||||
})
|
||||
.filter(|wallet| match query.owner_type.as_deref() {
|
||||
Some("user") => wallet.user_id.is_some(),
|
||||
Some("api_key") => wallet.api_key_id.is_some(),
|
||||
_ => true,
|
||||
})
|
||||
.map(|wallet| StoredAdminWalletListItem {
|
||||
id: wallet.id.clone(),
|
||||
user_id: wallet.user_id.clone(),
|
||||
api_key_id: wallet.api_key_id.clone(),
|
||||
balance: wallet.balance,
|
||||
gift_balance: wallet.gift_balance,
|
||||
limit_mode: wallet.limit_mode.clone(),
|
||||
currency: wallet.currency.clone(),
|
||||
status: wallet.status.clone(),
|
||||
total_recharged: wallet.total_recharged,
|
||||
total_consumed: wallet.total_consumed,
|
||||
total_refunded: wallet.total_refunded,
|
||||
total_adjusted: wallet.total_adjusted,
|
||||
user_name: None,
|
||||
api_key_name: None,
|
||||
created_at_unix_secs: None,
|
||||
updated_at_unix_secs: Some(wallet.updated_at_unix_secs),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by(|left, right| {
|
||||
right
|
||||
.updated_at_unix_secs
|
||||
.cmp(&left.updated_at_unix_secs)
|
||||
.then_with(|| right.id.cmp(&left.id))
|
||||
});
|
||||
let total = items.len() as u64;
|
||||
let items = items
|
||||
.into_iter()
|
||||
.skip(query.offset)
|
||||
.take(query.limit)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(StoredAdminWalletListPage { items, total })
|
||||
}
|
||||
|
||||
async fn list_admin_wallet_ledger(
|
||||
&self,
|
||||
_query: &AdminWalletLedgerQuery,
|
||||
) -> Result<StoredAdminWalletLedgerPage, DataLayerError> {
|
||||
Ok(StoredAdminWalletLedgerPage::default())
|
||||
}
|
||||
|
||||
async fn list_admin_wallet_refund_requests(
|
||||
&self,
|
||||
_query: &AdminWalletRefundRequestListQuery,
|
||||
) -> Result<StoredAdminWalletRefundRequestPage, DataLayerError> {
|
||||
Ok(StoredAdminWalletRefundRequestPage::default())
|
||||
}
|
||||
|
||||
async fn list_admin_wallet_transactions(
|
||||
&self,
|
||||
_wallet_id: &str,
|
||||
_limit: usize,
|
||||
_offset: usize,
|
||||
) -> Result<StoredAdminWalletTransactionPage, DataLayerError> {
|
||||
Ok(StoredAdminWalletTransactionPage::default())
|
||||
}
|
||||
|
||||
async fn find_wallet_today_usage(
|
||||
&self,
|
||||
_wallet_id: &str,
|
||||
_billing_timezone: &str,
|
||||
) -> Result<Option<StoredWalletDailyUsageLedger>, DataLayerError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn list_wallet_daily_usage_history(
|
||||
&self,
|
||||
_wallet_id: &str,
|
||||
_billing_timezone: &str,
|
||||
_limit: usize,
|
||||
) -> Result<StoredWalletDailyUsageLedgerPage, DataLayerError> {
|
||||
Ok(StoredWalletDailyUsageLedgerPage::default())
|
||||
}
|
||||
|
||||
async fn list_admin_wallet_refunds(
|
||||
&self,
|
||||
_wallet_id: &str,
|
||||
_limit: usize,
|
||||
_offset: usize,
|
||||
) -> Result<StoredAdminWalletRefundPage, DataLayerError> {
|
||||
Ok(StoredAdminWalletRefundPage::default())
|
||||
}
|
||||
|
||||
async fn list_admin_payment_orders(
|
||||
&self,
|
||||
_query: &AdminPaymentOrderListQuery,
|
||||
) -> Result<StoredAdminPaymentOrderPage, DataLayerError> {
|
||||
Ok(StoredAdminPaymentOrderPage::default())
|
||||
}
|
||||
|
||||
async fn find_admin_payment_order(
|
||||
&self,
|
||||
_order_id: &str,
|
||||
) -> Result<Option<StoredAdminPaymentOrder>, DataLayerError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn list_wallet_payment_orders_by_user_id(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
_limit: usize,
|
||||
_offset: usize,
|
||||
) -> Result<StoredAdminPaymentOrderPage, DataLayerError> {
|
||||
Ok(StoredAdminPaymentOrderPage::default())
|
||||
}
|
||||
|
||||
async fn find_wallet_payment_order_by_user_id(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
_order_id: &str,
|
||||
) -> Result<Option<StoredAdminPaymentOrder>, DataLayerError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn find_wallet_refund(
|
||||
&self,
|
||||
_wallet_id: &str,
|
||||
_refund_id: &str,
|
||||
) -> Result<Option<super::types::StoredAdminWalletRefund>, DataLayerError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn list_admin_payment_callbacks(
|
||||
&self,
|
||||
_payment_method: Option<&str>,
|
||||
_limit: usize,
|
||||
_offset: usize,
|
||||
) -> Result<StoredAdminPaymentCallbackPage, DataLayerError> {
|
||||
Ok(StoredAdminPaymentCallbackPage::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WalletWriteRepository for InMemoryWalletRepository {
|
||||
async fn settle_usage(
|
||||
async fn create_wallet_recharge_order(
|
||||
&self,
|
||||
input: UsageSettlementInput,
|
||||
) -> Result<Option<StoredUsageSettlement>, DataLayerError> {
|
||||
input.validate()?;
|
||||
if input.billing_status != "pending" {
|
||||
return Ok(Some(StoredUsageSettlement {
|
||||
request_id: input.request_id,
|
||||
wallet_id: None,
|
||||
billing_status: input.billing_status,
|
||||
wallet_balance_before: None,
|
||||
wallet_balance_after: None,
|
||||
wallet_recharge_balance_before: None,
|
||||
wallet_recharge_balance_after: None,
|
||||
wallet_gift_balance_before: None,
|
||||
wallet_gift_balance_after: None,
|
||||
provider_monthly_used_usd: None,
|
||||
finalized_at_unix_secs: input.finalized_at_unix_secs,
|
||||
}));
|
||||
}
|
||||
|
||||
input: CreateWalletRechargeOrderInput,
|
||||
) -> Result<CreateWalletRechargeOrderOutcome, DataLayerError> {
|
||||
let mut wallets = self.wallets_by_id.write().expect("wallet repo lock");
|
||||
let wallet_id = input
|
||||
.api_key_id
|
||||
.as_deref()
|
||||
.and_then(|api_key_id| {
|
||||
wallets
|
||||
.values()
|
||||
.find(|wallet| wallet.api_key_id.as_deref() == Some(api_key_id))
|
||||
.map(|wallet| wallet.id.clone())
|
||||
})
|
||||
.or_else(|| {
|
||||
input.user_id.as_deref().and_then(|user_id| {
|
||||
wallets
|
||||
.values()
|
||||
.find(|wallet| wallet.user_id.as_deref() == Some(user_id))
|
||||
.map(|wallet| wallet.id.clone())
|
||||
})
|
||||
});
|
||||
let wallet = wallet_id
|
||||
.as_deref()
|
||||
.and_then(|wallet_id| wallets.get_mut(wallet_id));
|
||||
|
||||
let final_billing_status = if input.status == "completed" {
|
||||
"settled"
|
||||
} else {
|
||||
"void"
|
||||
};
|
||||
|
||||
let mut settlement = StoredUsageSettlement {
|
||||
request_id: input.request_id,
|
||||
wallet_id: None,
|
||||
billing_status: final_billing_status.to_string(),
|
||||
wallet_balance_before: None,
|
||||
wallet_balance_after: None,
|
||||
wallet_recharge_balance_before: None,
|
||||
wallet_recharge_balance_after: None,
|
||||
wallet_gift_balance_before: None,
|
||||
wallet_gift_balance_after: None,
|
||||
provider_monthly_used_usd: None,
|
||||
finalized_at_unix_secs: input.finalized_at_unix_secs,
|
||||
};
|
||||
|
||||
if let Some(wallet) = wallet {
|
||||
let before_recharge = wallet.balance;
|
||||
let before_gift = wallet.gift_balance;
|
||||
let before_total = before_recharge + before_gift;
|
||||
settlement.wallet_id = Some(wallet.id.clone());
|
||||
settlement.wallet_balance_before = Some(before_total);
|
||||
settlement.wallet_recharge_balance_before = Some(before_recharge);
|
||||
settlement.wallet_gift_balance_before = Some(before_gift);
|
||||
|
||||
if final_billing_status == "settled" {
|
||||
if wallet.limit_mode.eq_ignore_ascii_case("unlimited") {
|
||||
wallet.total_consumed += input.total_cost_usd;
|
||||
} else {
|
||||
let gift_deduction = before_gift.max(0.0).min(input.total_cost_usd);
|
||||
let recharge_deduction = input.total_cost_usd - gift_deduction;
|
||||
wallet.gift_balance = before_gift - gift_deduction;
|
||||
wallet.balance = before_recharge - recharge_deduction;
|
||||
wallet.total_consumed += input.total_cost_usd;
|
||||
}
|
||||
}
|
||||
|
||||
settlement.wallet_recharge_balance_after = Some(wallet.balance);
|
||||
settlement.wallet_gift_balance_after = Some(wallet.gift_balance);
|
||||
settlement.wallet_balance_after = Some(wallet.balance + wallet.gift_balance);
|
||||
let wallet = wallets
|
||||
.values_mut()
|
||||
.find(|wallet| wallet.user_id.as_deref() == Some(input.user_id.as_str()));
|
||||
if wallet
|
||||
.as_ref()
|
||||
.is_some_and(|wallet| wallet.status != "active")
|
||||
{
|
||||
return Ok(CreateWalletRechargeOrderOutcome::WalletInactive);
|
||||
}
|
||||
let wallet_id = wallet
|
||||
.map(|wallet| wallet.id.clone())
|
||||
.or(input.preferred_wallet_id)
|
||||
.unwrap_or_else(|| "wallet-memory".to_string());
|
||||
Ok(CreateWalletRechargeOrderOutcome::Created(
|
||||
StoredAdminPaymentOrder {
|
||||
id: "payment-order-memory".to_string(),
|
||||
order_no: input.order_no,
|
||||
wallet_id,
|
||||
user_id: Some(input.user_id),
|
||||
amount_usd: input.amount_usd,
|
||||
pay_amount: input.pay_amount,
|
||||
pay_currency: input.pay_currency,
|
||||
exchange_rate: input.exchange_rate,
|
||||
refunded_amount_usd: 0.0,
|
||||
refundable_amount_usd: 0.0,
|
||||
payment_method: input.payment_method,
|
||||
gateway_order_id: Some(input.gateway_order_id),
|
||||
gateway_response: Some(input.gateway_response),
|
||||
status: "pending".to_string(),
|
||||
created_at_unix_secs: 0,
|
||||
paid_at_unix_secs: None,
|
||||
credited_at_unix_secs: None,
|
||||
expires_at_unix_secs: Some(input.expires_at_unix_secs),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
if final_billing_status == "settled" {
|
||||
if let Some(provider_id) = input.provider_id {
|
||||
let mut quotas = self
|
||||
.provider_monthly_used
|
||||
.write()
|
||||
.expect("provider quota lock");
|
||||
let value = quotas.entry(provider_id).or_insert(0.0);
|
||||
*value += input.actual_total_cost_usd;
|
||||
settlement.provider_monthly_used_usd = Some(*value);
|
||||
}
|
||||
async fn create_wallet_refund_request(
|
||||
&self,
|
||||
input: CreateWalletRefundRequestInput,
|
||||
) -> Result<CreateWalletRefundRequestOutcome, DataLayerError> {
|
||||
let wallets = self.wallets_by_id.read().expect("wallet repo lock");
|
||||
let Some(wallet) = wallets.get(&input.wallet_id) else {
|
||||
return Ok(CreateWalletRefundRequestOutcome::WalletMissing);
|
||||
};
|
||||
if input.amount_usd > wallet.balance {
|
||||
return Ok(CreateWalletRefundRequestOutcome::RefundAmountExceedsAvailableBalance);
|
||||
}
|
||||
Ok(CreateWalletRefundRequestOutcome::Created(
|
||||
super::types::StoredAdminWalletRefund {
|
||||
id: "refund-memory".to_string(),
|
||||
refund_no: input.refund_no,
|
||||
wallet_id: input.wallet_id,
|
||||
user_id: Some(input.user_id),
|
||||
payment_order_id: input.payment_order_id,
|
||||
source_type: input
|
||||
.source_type
|
||||
.unwrap_or_else(|| "wallet_balance".to_string()),
|
||||
source_id: input.source_id,
|
||||
refund_mode: input
|
||||
.refund_mode
|
||||
.unwrap_or_else(|| "offline_payout".to_string()),
|
||||
amount_usd: input.amount_usd,
|
||||
status: "pending_approval".to_string(),
|
||||
reason: input.reason,
|
||||
failure_reason: None,
|
||||
gateway_refund_id: None,
|
||||
payout_method: None,
|
||||
payout_reference: None,
|
||||
payout_proof: None,
|
||||
requested_by: None,
|
||||
approved_by: None,
|
||||
processed_by: None,
|
||||
created_at_unix_secs: 0,
|
||||
updated_at_unix_secs: 0,
|
||||
processed_at_unix_secs: None,
|
||||
completed_at_unix_secs: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
Ok(Some(settlement))
|
||||
async fn process_payment_callback(
|
||||
&self,
|
||||
_input: ProcessPaymentCallbackInput,
|
||||
) -> Result<ProcessPaymentCallbackOutcome, DataLayerError> {
|
||||
Ok(ProcessPaymentCallbackOutcome::Failed {
|
||||
duplicate: false,
|
||||
error: "payment callback is not supported in memory wallet repository".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn adjust_wallet_balance(
|
||||
&self,
|
||||
_input: AdjustWalletBalanceInput,
|
||||
) -> Result<
|
||||
Option<(
|
||||
StoredWalletSnapshot,
|
||||
super::types::StoredAdminWalletTransaction,
|
||||
)>,
|
||||
DataLayerError,
|
||||
> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn create_manual_wallet_recharge(
|
||||
&self,
|
||||
_input: CreateManualWalletRechargeInput,
|
||||
) -> Result<Option<(StoredWalletSnapshot, StoredAdminPaymentOrder)>, DataLayerError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn process_admin_wallet_refund(
|
||||
&self,
|
||||
_input: ProcessAdminWalletRefundInput,
|
||||
) -> Result<
|
||||
WalletMutationOutcome<(
|
||||
StoredWalletSnapshot,
|
||||
super::types::StoredAdminWalletRefund,
|
||||
super::types::StoredAdminWalletTransaction,
|
||||
)>,
|
||||
DataLayerError,
|
||||
> {
|
||||
Ok(WalletMutationOutcome::NotFound)
|
||||
}
|
||||
|
||||
async fn complete_admin_wallet_refund(
|
||||
&self,
|
||||
_input: CompleteAdminWalletRefundInput,
|
||||
) -> Result<WalletMutationOutcome<super::types::StoredAdminWalletRefund>, DataLayerError> {
|
||||
Ok(WalletMutationOutcome::NotFound)
|
||||
}
|
||||
|
||||
async fn fail_admin_wallet_refund(
|
||||
&self,
|
||||
_input: FailAdminWalletRefundInput,
|
||||
) -> Result<
|
||||
WalletMutationOutcome<(
|
||||
StoredWalletSnapshot,
|
||||
super::types::StoredAdminWalletRefund,
|
||||
Option<super::types::StoredAdminWalletTransaction>,
|
||||
)>,
|
||||
DataLayerError,
|
||||
> {
|
||||
Ok(WalletMutationOutcome::NotFound)
|
||||
}
|
||||
|
||||
async fn expire_admin_payment_order(
|
||||
&self,
|
||||
_order_id: &str,
|
||||
) -> Result<WalletMutationOutcome<(StoredAdminPaymentOrder, bool)>, DataLayerError> {
|
||||
Ok(WalletMutationOutcome::NotFound)
|
||||
}
|
||||
|
||||
async fn fail_admin_payment_order(
|
||||
&self,
|
||||
_order_id: &str,
|
||||
) -> Result<WalletMutationOutcome<StoredAdminPaymentOrder>, DataLayerError> {
|
||||
Ok(WalletMutationOutcome::NotFound)
|
||||
}
|
||||
|
||||
async fn credit_admin_payment_order(
|
||||
&self,
|
||||
_input: CreditAdminPaymentOrderInput,
|
||||
) -> Result<WalletMutationOutcome<(StoredAdminPaymentOrder, bool)>, DataLayerError> {
|
||||
Ok(WalletMutationOutcome::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,8 +442,7 @@ impl WalletWriteRepository for InMemoryWalletRepository {
|
||||
mod tests {
|
||||
use super::InMemoryWalletRepository;
|
||||
use crate::repository::wallet::{
|
||||
StoredWalletSnapshot, UsageSettlementInput, WalletLookupKey, WalletReadRepository,
|
||||
WalletWriteRepository,
|
||||
AdminWalletListQuery, StoredWalletSnapshot, WalletLookupKey, WalletReadRepository,
|
||||
};
|
||||
|
||||
fn sample_wallet() -> StoredWalletSnapshot {
|
||||
@@ -244,27 +476,73 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settles_usage_against_wallet_and_provider_quota() {
|
||||
let repository = InMemoryWalletRepository::seed(vec![sample_wallet()]);
|
||||
let settlement = repository
|
||||
.settle_usage(UsageSettlementInput {
|
||||
request_id: "req-1".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
api_key_id: Some("key-1".to_string()),
|
||||
provider_id: Some("provider-1".to_string()),
|
||||
status: "completed".to_string(),
|
||||
billing_status: "pending".to_string(),
|
||||
total_cost_usd: 3.0,
|
||||
actual_total_cost_usd: 1.5,
|
||||
finalized_at_unix_secs: Some(200),
|
||||
async fn lists_admin_wallets_with_filters_and_pagination() {
|
||||
let repository = InMemoryWalletRepository::seed(vec![
|
||||
sample_wallet(),
|
||||
StoredWalletSnapshot::new(
|
||||
"wallet-2".to_string(),
|
||||
Some("user-2".to_string()),
|
||||
None,
|
||||
3.0,
|
||||
1.0,
|
||||
"finite".to_string(),
|
||||
"USD".to_string(),
|
||||
"inactive".to_string(),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
90,
|
||||
)
|
||||
.expect("wallet should build"),
|
||||
StoredWalletSnapshot::new(
|
||||
"wallet-3".to_string(),
|
||||
None,
|
||||
Some("key-3".to_string()),
|
||||
5.0,
|
||||
0.0,
|
||||
"unlimited".to_string(),
|
||||
"USD".to_string(),
|
||||
"active".to_string(),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
110,
|
||||
)
|
||||
.expect("wallet should build"),
|
||||
]);
|
||||
|
||||
let page = repository
|
||||
.list_admin_wallets(&AdminWalletListQuery {
|
||||
status: Some("active".to_string()),
|
||||
owner_type: Some("api_key".to_string()),
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
})
|
||||
.await
|
||||
.expect("settlement should succeed")
|
||||
.expect("settlement should exist");
|
||||
.expect("list should succeed");
|
||||
|
||||
assert_eq!(settlement.billing_status, "settled");
|
||||
assert_eq!(settlement.wallet_balance_before, Some(12.0));
|
||||
assert_eq!(settlement.wallet_balance_after, Some(9.0));
|
||||
assert_eq!(settlement.provider_monthly_used_usd, Some(1.5));
|
||||
assert_eq!(page.total, 2);
|
||||
assert_eq!(page.items.len(), 1);
|
||||
assert_eq!(page.items[0].id, "wallet-3");
|
||||
assert_eq!(page.items[0].updated_at_unix_secs, Some(110));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn daily_usage_queries_default_to_empty_in_memory() {
|
||||
let repository = InMemoryWalletRepository::seed(vec![sample_wallet()]);
|
||||
let today = repository
|
||||
.find_wallet_today_usage("wallet-1", "Asia/Shanghai")
|
||||
.await
|
||||
.expect("lookup should succeed");
|
||||
let history = repository
|
||||
.list_wallet_daily_usage_history("wallet-1", "Asia/Shanghai", 20)
|
||||
.await
|
||||
.expect("history should succeed");
|
||||
|
||||
assert!(today.is_none());
|
||||
assert_eq!(history.total, 0);
|
||||
assert!(history.items.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,19 @@ mod types;
|
||||
pub use memory::InMemoryWalletRepository;
|
||||
pub use sql::SqlxWalletRepository;
|
||||
pub use types::{
|
||||
StoredUsageSettlement, StoredWalletSnapshot, UsageSettlementInput, WalletLookupKey,
|
||||
AdjustWalletBalanceInput, AdminPaymentCallbackRecord, AdminPaymentOrderListQuery,
|
||||
AdminWalletLedgerQuery, AdminWalletListQuery, AdminWalletPaymentOrderRecord,
|
||||
AdminWalletRefundRecord, AdminWalletRefundRequestListQuery, AdminWalletTransactionRecord,
|
||||
CompleteAdminWalletRefundInput, CreateManualWalletRechargeInput,
|
||||
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
|
||||
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
|
||||
FailAdminWalletRefundInput, ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput,
|
||||
ProcessPaymentCallbackOutcome, StoredAdminPaymentCallback, StoredAdminPaymentCallbackPage,
|
||||
StoredAdminPaymentOrder, StoredAdminPaymentOrderPage, StoredAdminWalletLedgerItem,
|
||||
StoredAdminWalletLedgerPage, StoredAdminWalletListItem, StoredAdminWalletListPage,
|
||||
StoredAdminWalletRefund, StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestItem,
|
||||
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
|
||||
StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
||||
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, WalletLookupKey, WalletMutationOutcome,
|
||||
WalletReadRepository, WalletRepository, WalletWriteRepository,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -94,53 +94,500 @@ impl StoredWalletSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdminWalletListQuery {
|
||||
pub status: Option<String>,
|
||||
pub owner_type: Option<String>,
|
||||
pub limit: usize,
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UsageSettlementInput {
|
||||
pub request_id: String,
|
||||
pub struct StoredAdminWalletListItem {
|
||||
pub id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub api_key_id: Option<String>,
|
||||
pub provider_id: Option<String>,
|
||||
pub balance: f64,
|
||||
pub gift_balance: f64,
|
||||
pub limit_mode: String,
|
||||
pub currency: String,
|
||||
pub status: String,
|
||||
pub billing_status: String,
|
||||
pub total_cost_usd: f64,
|
||||
pub actual_total_cost_usd: f64,
|
||||
pub finalized_at_unix_secs: Option<u64>,
|
||||
pub total_recharged: f64,
|
||||
pub total_consumed: f64,
|
||||
pub total_refunded: f64,
|
||||
pub total_adjusted: f64,
|
||||
pub user_name: Option<String>,
|
||||
pub api_key_name: Option<String>,
|
||||
pub created_at_unix_secs: Option<u64>,
|
||||
pub updated_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl UsageSettlementInput {
|
||||
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
|
||||
if self.request_id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"wallet settlement request_id cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.status.trim().is_empty() || self.billing_status.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"wallet settlement status cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if !self.total_cost_usd.is_finite() || !self.actual_total_cost_usd.is_finite() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"wallet settlement cost must be finite".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminWalletListPage {
|
||||
pub items: Vec<StoredAdminWalletListItem>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdminWalletLedgerQuery {
|
||||
pub category: Option<String>,
|
||||
pub reason_code: Option<String>,
|
||||
pub owner_type: Option<String>,
|
||||
pub limit: usize,
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredUsageSettlement {
|
||||
pub request_id: String,
|
||||
pub wallet_id: Option<String>,
|
||||
pub billing_status: String,
|
||||
pub wallet_balance_before: Option<f64>,
|
||||
pub wallet_balance_after: Option<f64>,
|
||||
pub wallet_recharge_balance_before: Option<f64>,
|
||||
pub wallet_recharge_balance_after: Option<f64>,
|
||||
pub wallet_gift_balance_before: Option<f64>,
|
||||
pub wallet_gift_balance_after: Option<f64>,
|
||||
pub provider_monthly_used_usd: Option<f64>,
|
||||
pub finalized_at_unix_secs: Option<u64>,
|
||||
pub struct StoredAdminWalletLedgerItem {
|
||||
pub id: String,
|
||||
pub wallet_id: String,
|
||||
pub category: String,
|
||||
pub reason_code: String,
|
||||
pub amount: f64,
|
||||
pub balance_before: f64,
|
||||
pub balance_after: f64,
|
||||
pub recharge_balance_before: f64,
|
||||
pub recharge_balance_after: f64,
|
||||
pub gift_balance_before: f64,
|
||||
pub gift_balance_after: f64,
|
||||
pub link_type: Option<String>,
|
||||
pub link_id: Option<String>,
|
||||
pub operator_id: Option<String>,
|
||||
pub operator_name: Option<String>,
|
||||
pub operator_email: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub wallet_user_id: Option<String>,
|
||||
pub wallet_user_name: Option<String>,
|
||||
pub wallet_api_key_id: Option<String>,
|
||||
pub api_key_name: Option<String>,
|
||||
pub wallet_status: String,
|
||||
pub created_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminWalletLedgerPage {
|
||||
pub items: Vec<StoredAdminWalletLedgerItem>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdminWalletRefundRequestListQuery {
|
||||
pub status: Option<String>,
|
||||
pub limit: usize,
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminWalletRefundRequestItem {
|
||||
pub id: String,
|
||||
pub refund_no: String,
|
||||
pub wallet_id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub payment_order_id: Option<String>,
|
||||
pub source_type: String,
|
||||
pub source_id: Option<String>,
|
||||
pub refund_mode: String,
|
||||
pub amount_usd: f64,
|
||||
pub status: String,
|
||||
pub reason: Option<String>,
|
||||
pub failure_reason: Option<String>,
|
||||
pub gateway_refund_id: Option<String>,
|
||||
pub payout_method: Option<String>,
|
||||
pub payout_reference: Option<String>,
|
||||
pub payout_proof: Option<serde_json::Value>,
|
||||
pub requested_by: Option<String>,
|
||||
pub approved_by: Option<String>,
|
||||
pub processed_by: Option<String>,
|
||||
pub wallet_user_id: Option<String>,
|
||||
pub wallet_user_name: Option<String>,
|
||||
pub wallet_api_key_id: Option<String>,
|
||||
pub api_key_name: Option<String>,
|
||||
pub wallet_status: String,
|
||||
pub created_at_unix_secs: Option<u64>,
|
||||
pub updated_at_unix_secs: Option<u64>,
|
||||
pub processed_at_unix_secs: Option<u64>,
|
||||
pub completed_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminWalletRefundRequestPage {
|
||||
pub items: Vec<StoredAdminWalletRefundRequestItem>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminWalletTransaction {
|
||||
pub id: String,
|
||||
pub wallet_id: String,
|
||||
pub category: String,
|
||||
pub reason_code: String,
|
||||
pub amount: f64,
|
||||
pub balance_before: f64,
|
||||
pub balance_after: f64,
|
||||
pub recharge_balance_before: f64,
|
||||
pub recharge_balance_after: f64,
|
||||
pub gift_balance_before: f64,
|
||||
pub gift_balance_after: f64,
|
||||
pub link_type: Option<String>,
|
||||
pub link_id: Option<String>,
|
||||
pub operator_id: Option<String>,
|
||||
pub operator_name: Option<String>,
|
||||
pub operator_email: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub created_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdminWalletTransactionRecord {
|
||||
pub id: String,
|
||||
pub wallet_id: String,
|
||||
pub category: String,
|
||||
pub reason_code: String,
|
||||
pub amount: f64,
|
||||
pub balance_before: f64,
|
||||
pub balance_after: f64,
|
||||
pub recharge_balance_before: f64,
|
||||
pub recharge_balance_after: f64,
|
||||
pub gift_balance_before: f64,
|
||||
pub gift_balance_after: f64,
|
||||
pub link_type: Option<String>,
|
||||
pub link_id: Option<String>,
|
||||
pub operator_id: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub created_at_unix_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminWalletTransactionPage {
|
||||
pub items: Vec<StoredAdminWalletTransaction>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredWalletDailyUsageLedger {
|
||||
pub id: Option<String>,
|
||||
pub billing_date: String,
|
||||
pub billing_timezone: String,
|
||||
pub total_cost_usd: f64,
|
||||
pub total_requests: u64,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_creation_tokens: u64,
|
||||
pub cache_read_tokens: u64,
|
||||
pub first_finalized_at_unix_secs: Option<u64>,
|
||||
pub last_finalized_at_unix_secs: Option<u64>,
|
||||
pub aggregated_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredWalletDailyUsageLedgerPage {
|
||||
pub items: Vec<StoredWalletDailyUsageLedger>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminWalletRefund {
|
||||
pub id: String,
|
||||
pub refund_no: String,
|
||||
pub wallet_id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub payment_order_id: Option<String>,
|
||||
pub source_type: String,
|
||||
pub source_id: Option<String>,
|
||||
pub refund_mode: String,
|
||||
pub amount_usd: f64,
|
||||
pub status: String,
|
||||
pub reason: Option<String>,
|
||||
pub failure_reason: Option<String>,
|
||||
pub gateway_refund_id: Option<String>,
|
||||
pub payout_method: Option<String>,
|
||||
pub payout_reference: Option<String>,
|
||||
pub payout_proof: Option<serde_json::Value>,
|
||||
pub requested_by: Option<String>,
|
||||
pub approved_by: Option<String>,
|
||||
pub processed_by: Option<String>,
|
||||
pub created_at_unix_secs: u64,
|
||||
pub updated_at_unix_secs: u64,
|
||||
pub processed_at_unix_secs: Option<u64>,
|
||||
pub completed_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdminWalletRefundRecord {
|
||||
pub id: String,
|
||||
pub refund_no: String,
|
||||
pub wallet_id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub payment_order_id: Option<String>,
|
||||
pub source_type: String,
|
||||
pub source_id: Option<String>,
|
||||
pub refund_mode: String,
|
||||
pub amount_usd: f64,
|
||||
pub status: String,
|
||||
pub reason: Option<String>,
|
||||
pub failure_reason: Option<String>,
|
||||
pub gateway_refund_id: Option<String>,
|
||||
pub payout_method: Option<String>,
|
||||
pub payout_reference: Option<String>,
|
||||
pub payout_proof: Option<serde_json::Value>,
|
||||
pub requested_by: Option<String>,
|
||||
pub approved_by: Option<String>,
|
||||
pub processed_by: Option<String>,
|
||||
pub created_at_unix_secs: u64,
|
||||
pub updated_at_unix_secs: u64,
|
||||
pub processed_at_unix_secs: Option<u64>,
|
||||
pub completed_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminWalletRefundPage {
|
||||
pub items: Vec<StoredAdminWalletRefund>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdminPaymentOrderListQuery {
|
||||
pub status: Option<String>,
|
||||
pub payment_method: Option<String>,
|
||||
pub limit: usize,
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminPaymentOrder {
|
||||
pub id: String,
|
||||
pub order_no: String,
|
||||
pub wallet_id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub amount_usd: f64,
|
||||
pub pay_amount: Option<f64>,
|
||||
pub pay_currency: Option<String>,
|
||||
pub exchange_rate: Option<f64>,
|
||||
pub refunded_amount_usd: f64,
|
||||
pub refundable_amount_usd: f64,
|
||||
pub payment_method: String,
|
||||
pub gateway_order_id: Option<String>,
|
||||
pub gateway_response: Option<serde_json::Value>,
|
||||
pub status: String,
|
||||
pub created_at_unix_secs: u64,
|
||||
pub paid_at_unix_secs: Option<u64>,
|
||||
pub credited_at_unix_secs: Option<u64>,
|
||||
pub expires_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdminWalletPaymentOrderRecord {
|
||||
pub id: String,
|
||||
pub order_no: String,
|
||||
pub wallet_id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub amount_usd: f64,
|
||||
pub pay_amount: Option<f64>,
|
||||
pub pay_currency: Option<String>,
|
||||
pub exchange_rate: Option<f64>,
|
||||
pub refunded_amount_usd: f64,
|
||||
pub refundable_amount_usd: f64,
|
||||
pub payment_method: String,
|
||||
pub gateway_order_id: Option<String>,
|
||||
pub status: String,
|
||||
pub gateway_response: Option<serde_json::Value>,
|
||||
pub created_at_unix_secs: u64,
|
||||
pub paid_at_unix_secs: Option<u64>,
|
||||
pub credited_at_unix_secs: Option<u64>,
|
||||
pub expires_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminPaymentOrderPage {
|
||||
pub items: Vec<StoredAdminPaymentOrder>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminPaymentCallback {
|
||||
pub id: String,
|
||||
pub payment_order_id: Option<String>,
|
||||
pub payment_method: String,
|
||||
pub callback_key: String,
|
||||
pub order_no: Option<String>,
|
||||
pub gateway_order_id: Option<String>,
|
||||
pub payload_hash: Option<String>,
|
||||
pub signature_valid: bool,
|
||||
pub status: String,
|
||||
pub payload: Option<serde_json::Value>,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at_unix_secs: u64,
|
||||
pub processed_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdminPaymentCallbackRecord {
|
||||
pub id: String,
|
||||
pub payment_order_id: Option<String>,
|
||||
pub payment_method: String,
|
||||
pub callback_key: String,
|
||||
pub order_no: Option<String>,
|
||||
pub gateway_order_id: Option<String>,
|
||||
pub payload_hash: Option<String>,
|
||||
pub signature_valid: bool,
|
||||
pub status: String,
|
||||
pub payload: Option<serde_json::Value>,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at_unix_secs: u64,
|
||||
pub processed_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminPaymentCallbackPage {
|
||||
pub items: Vec<StoredAdminPaymentCallback>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CreateWalletRechargeOrderInput {
|
||||
pub preferred_wallet_id: Option<String>,
|
||||
pub user_id: String,
|
||||
pub amount_usd: f64,
|
||||
pub pay_amount: Option<f64>,
|
||||
pub pay_currency: Option<String>,
|
||||
pub exchange_rate: Option<f64>,
|
||||
pub payment_method: String,
|
||||
pub gateway_order_id: String,
|
||||
pub gateway_response: serde_json::Value,
|
||||
pub order_no: String,
|
||||
pub expires_at_unix_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum CreateWalletRechargeOrderOutcome {
|
||||
Created(StoredAdminPaymentOrder),
|
||||
WalletInactive,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CreateWalletRefundRequestInput {
|
||||
pub wallet_id: String,
|
||||
pub user_id: String,
|
||||
pub amount_usd: f64,
|
||||
pub payment_order_id: Option<String>,
|
||||
pub source_type: Option<String>,
|
||||
pub source_id: Option<String>,
|
||||
pub refund_mode: Option<String>,
|
||||
pub reason: Option<String>,
|
||||
pub idempotency_key: Option<String>,
|
||||
pub refund_no: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum CreateWalletRefundRequestOutcome {
|
||||
Created(StoredAdminWalletRefund),
|
||||
Duplicate(StoredAdminWalletRefund),
|
||||
WalletMissing,
|
||||
RefundAmountExceedsAvailableBalance,
|
||||
PaymentOrderNotFound,
|
||||
PaymentOrderNotRefundable,
|
||||
RefundAmountExceedsAvailableOrderAmount,
|
||||
DuplicateRejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ProcessPaymentCallbackInput {
|
||||
pub payment_method: String,
|
||||
pub callback_key: String,
|
||||
pub order_no: Option<String>,
|
||||
pub gateway_order_id: Option<String>,
|
||||
pub amount_usd: f64,
|
||||
pub pay_amount: Option<f64>,
|
||||
pub pay_currency: Option<String>,
|
||||
pub exchange_rate: Option<f64>,
|
||||
pub payload_hash: String,
|
||||
pub payload: serde_json::Value,
|
||||
pub signature_valid: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ProcessPaymentCallbackOutcome {
|
||||
DuplicateProcessed {
|
||||
order_id: Option<String>,
|
||||
},
|
||||
Failed {
|
||||
duplicate: bool,
|
||||
error: String,
|
||||
},
|
||||
AlreadyCredited {
|
||||
duplicate: bool,
|
||||
order_id: String,
|
||||
order_no: String,
|
||||
wallet_id: String,
|
||||
},
|
||||
Applied {
|
||||
duplicate: bool,
|
||||
order_id: String,
|
||||
order_no: String,
|
||||
wallet_id: String,
|
||||
order: StoredAdminPaymentOrder,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum WalletMutationOutcome<T> {
|
||||
Applied(T),
|
||||
NotFound,
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdjustWalletBalanceInput {
|
||||
pub wallet_id: String,
|
||||
pub amount_usd: f64,
|
||||
pub balance_type: String,
|
||||
pub operator_id: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CreateManualWalletRechargeInput {
|
||||
pub wallet_id: String,
|
||||
pub amount_usd: f64,
|
||||
pub payment_method: String,
|
||||
pub operator_id: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub order_no: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ProcessAdminWalletRefundInput {
|
||||
pub wallet_id: String,
|
||||
pub refund_id: String,
|
||||
pub operator_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CompleteAdminWalletRefundInput {
|
||||
pub wallet_id: String,
|
||||
pub refund_id: String,
|
||||
pub gateway_refund_id: Option<String>,
|
||||
pub payout_reference: Option<String>,
|
||||
pub payout_proof: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FailAdminWalletRefundInput {
|
||||
pub wallet_id: String,
|
||||
pub refund_id: String,
|
||||
pub reason: String,
|
||||
pub operator_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CreditAdminPaymentOrderInput {
|
||||
pub order_id: String,
|
||||
pub gateway_order_id: Option<String>,
|
||||
pub pay_amount: Option<f64>,
|
||||
pub pay_currency: Option<String>,
|
||||
pub exchange_rate: Option<f64>,
|
||||
pub gateway_response_patch: Option<serde_json::Value>,
|
||||
pub operator_id: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -159,14 +606,156 @@ pub trait WalletReadRepository: Send + Sync {
|
||||
&self,
|
||||
api_key_ids: &[String],
|
||||
) -> Result<Vec<StoredWalletSnapshot>, crate::DataLayerError>;
|
||||
|
||||
async fn list_admin_wallets(
|
||||
&self,
|
||||
query: &AdminWalletListQuery,
|
||||
) -> Result<StoredAdminWalletListPage, crate::DataLayerError>;
|
||||
|
||||
async fn list_admin_wallet_ledger(
|
||||
&self,
|
||||
query: &AdminWalletLedgerQuery,
|
||||
) -> Result<StoredAdminWalletLedgerPage, crate::DataLayerError>;
|
||||
|
||||
async fn list_admin_wallet_refund_requests(
|
||||
&self,
|
||||
query: &AdminWalletRefundRequestListQuery,
|
||||
) -> Result<StoredAdminWalletRefundRequestPage, crate::DataLayerError>;
|
||||
|
||||
async fn list_admin_wallet_transactions(
|
||||
&self,
|
||||
wallet_id: &str,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<StoredAdminWalletTransactionPage, crate::DataLayerError>;
|
||||
|
||||
async fn find_wallet_today_usage(
|
||||
&self,
|
||||
wallet_id: &str,
|
||||
billing_timezone: &str,
|
||||
) -> Result<Option<StoredWalletDailyUsageLedger>, crate::DataLayerError>;
|
||||
|
||||
async fn list_wallet_daily_usage_history(
|
||||
&self,
|
||||
wallet_id: &str,
|
||||
billing_timezone: &str,
|
||||
limit: usize,
|
||||
) -> Result<StoredWalletDailyUsageLedgerPage, crate::DataLayerError>;
|
||||
|
||||
async fn list_admin_wallet_refunds(
|
||||
&self,
|
||||
wallet_id: &str,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<StoredAdminWalletRefundPage, crate::DataLayerError>;
|
||||
|
||||
async fn list_admin_payment_orders(
|
||||
&self,
|
||||
query: &AdminPaymentOrderListQuery,
|
||||
) -> Result<StoredAdminPaymentOrderPage, crate::DataLayerError>;
|
||||
|
||||
async fn find_admin_payment_order(
|
||||
&self,
|
||||
order_id: &str,
|
||||
) -> Result<Option<StoredAdminPaymentOrder>, crate::DataLayerError>;
|
||||
|
||||
async fn list_wallet_payment_orders_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<StoredAdminPaymentOrderPage, crate::DataLayerError>;
|
||||
|
||||
async fn find_wallet_payment_order_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
order_id: &str,
|
||||
) -> Result<Option<StoredAdminPaymentOrder>, crate::DataLayerError>;
|
||||
|
||||
async fn find_wallet_refund(
|
||||
&self,
|
||||
wallet_id: &str,
|
||||
refund_id: &str,
|
||||
) -> Result<Option<StoredAdminWalletRefund>, crate::DataLayerError>;
|
||||
|
||||
async fn list_admin_payment_callbacks(
|
||||
&self,
|
||||
payment_method: Option<&str>,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<StoredAdminPaymentCallbackPage, crate::DataLayerError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait WalletWriteRepository: Send + Sync {
|
||||
async fn settle_usage(
|
||||
async fn create_wallet_recharge_order(
|
||||
&self,
|
||||
input: UsageSettlementInput,
|
||||
) -> Result<Option<StoredUsageSettlement>, crate::DataLayerError>;
|
||||
input: CreateWalletRechargeOrderInput,
|
||||
) -> Result<CreateWalletRechargeOrderOutcome, crate::DataLayerError>;
|
||||
|
||||
async fn create_wallet_refund_request(
|
||||
&self,
|
||||
input: CreateWalletRefundRequestInput,
|
||||
) -> Result<CreateWalletRefundRequestOutcome, crate::DataLayerError>;
|
||||
|
||||
async fn process_payment_callback(
|
||||
&self,
|
||||
input: ProcessPaymentCallbackInput,
|
||||
) -> Result<ProcessPaymentCallbackOutcome, crate::DataLayerError>;
|
||||
|
||||
async fn adjust_wallet_balance(
|
||||
&self,
|
||||
input: AdjustWalletBalanceInput,
|
||||
) -> Result<Option<(StoredWalletSnapshot, StoredAdminWalletTransaction)>, crate::DataLayerError>;
|
||||
|
||||
async fn create_manual_wallet_recharge(
|
||||
&self,
|
||||
input: CreateManualWalletRechargeInput,
|
||||
) -> Result<Option<(StoredWalletSnapshot, StoredAdminPaymentOrder)>, crate::DataLayerError>;
|
||||
|
||||
async fn process_admin_wallet_refund(
|
||||
&self,
|
||||
input: ProcessAdminWalletRefundInput,
|
||||
) -> Result<
|
||||
WalletMutationOutcome<(
|
||||
StoredWalletSnapshot,
|
||||
StoredAdminWalletRefund,
|
||||
StoredAdminWalletTransaction,
|
||||
)>,
|
||||
crate::DataLayerError,
|
||||
>;
|
||||
|
||||
async fn complete_admin_wallet_refund(
|
||||
&self,
|
||||
input: CompleteAdminWalletRefundInput,
|
||||
) -> Result<WalletMutationOutcome<StoredAdminWalletRefund>, crate::DataLayerError>;
|
||||
|
||||
async fn fail_admin_wallet_refund(
|
||||
&self,
|
||||
input: FailAdminWalletRefundInput,
|
||||
) -> Result<
|
||||
WalletMutationOutcome<(
|
||||
StoredWalletSnapshot,
|
||||
StoredAdminWalletRefund,
|
||||
Option<StoredAdminWalletTransaction>,
|
||||
)>,
|
||||
crate::DataLayerError,
|
||||
>;
|
||||
|
||||
async fn expire_admin_payment_order(
|
||||
&self,
|
||||
order_id: &str,
|
||||
) -> Result<WalletMutationOutcome<(StoredAdminPaymentOrder, bool)>, crate::DataLayerError>;
|
||||
|
||||
async fn fail_admin_payment_order(
|
||||
&self,
|
||||
order_id: &str,
|
||||
) -> Result<WalletMutationOutcome<StoredAdminPaymentOrder>, crate::DataLayerError>;
|
||||
|
||||
async fn credit_admin_payment_order(
|
||||
&self,
|
||||
input: CreditAdminPaymentOrderInput,
|
||||
) -> Result<WalletMutationOutcome<(StoredAdminPaymentOrder, bool)>, crate::DataLayerError>;
|
||||
}
|
||||
|
||||
pub trait WalletRepository: WalletReadRepository + WalletWriteRepository + Send + Sync {}
|
||||
@@ -175,7 +764,8 @@ impl<T> WalletRepository for T where T: WalletReadRepository + WalletWriteReposi
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{StoredWalletSnapshot, UsageSettlementInput};
|
||||
use super::StoredWalletSnapshot;
|
||||
use crate::repository::settlement::UsageSettlementInput;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_wallet_snapshot() {
|
||||
|
||||
Reference in New Issue
Block a user