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:
fawney19
2026-04-05 20:23:16 +08:00
parent cbc811f6ce
commit 763ff03a7b
777 changed files with 42659 additions and 21469 deletions

View 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)
}
}

View File

@@ -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,
};

View File

@@ -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")
}
}

View File

@@ -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,
};

View File

@@ -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(

View File

@@ -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,
};

View File

@@ -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(

View File

@@ -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;

View 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)
);
}
}

View 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));
}
}

View 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,
};

View 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"));
}
}

View 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());
}
}

View 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,
}

View File

@@ -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,
};

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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

View File

@@ -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() {