mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 18:59:50 +08:00
feat: revamp analytics dashboards and harden database migrations
Add dashboard and overview analytics, health monitoring, provider expense tracking, and announcement updates across the gateway and frontend. Keep schema migrations free of historical backfills while preserving automatic backfill execution. Bound migration deadlines, run schema preparation before Compose replacement, and anonymize deleted dashboard users. Include the current documentation cleanup and regression coverage.
This commit is contained in:
@@ -16,6 +16,7 @@ type BillingContextMap = BTreeMap<BillingContextKey, StoredBillingModelContext>;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryBillingReadRepository {
|
||||
provider_expenses: RwLock<BTreeMap<String, super::ProviderExpenseRecord>>,
|
||||
by_key: RwLock<BillingContextMap>,
|
||||
gateway_configs_by_provider: RwLock<BTreeMap<String, PaymentGatewayConfigRecord>>,
|
||||
billing_plans_by_id: RwLock<BTreeMap<String, BillingPlanRecord>>,
|
||||
@@ -23,6 +24,20 @@ pub struct InMemoryBillingReadRepository {
|
||||
}
|
||||
|
||||
impl InMemoryBillingReadRepository {
|
||||
pub fn seed_user_plan_entitlements(
|
||||
items: impl IntoIterator<Item = UserPlanEntitlementRecord>,
|
||||
) -> Self {
|
||||
Self {
|
||||
entitlements_by_id: RwLock::new(
|
||||
items
|
||||
.into_iter()
|
||||
.map(|item| (item.id.clone(), item))
|
||||
.collect(),
|
||||
),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn seed<I>(items: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredBillingModelContext>,
|
||||
@@ -39,6 +54,7 @@ impl InMemoryBillingReadRepository {
|
||||
);
|
||||
}
|
||||
Self {
|
||||
provider_expenses: RwLock::default(),
|
||||
by_key: RwLock::new(by_key),
|
||||
gateway_configs_by_provider: RwLock::new(BTreeMap::new()),
|
||||
billing_plans_by_id: RwLock::new(BTreeMap::new()),
|
||||
@@ -325,6 +341,68 @@ impl BillingReadRepository for InMemoryBillingReadRepository {
|
||||
Ok(AdminBillingMutationOutcome::Applied(record))
|
||||
}
|
||||
|
||||
async fn list_provider_expenses(
|
||||
&self,
|
||||
query: &super::ProviderExpenseQuery,
|
||||
) -> Result<Option<super::ProviderExpensePage>, DataLayerError> {
|
||||
let guard = self
|
||||
.provider_expenses
|
||||
.read()
|
||||
.expect("provider expense store should lock");
|
||||
super::provider_expense_memory_page(guard.values().cloned(), query).map(Some)
|
||||
}
|
||||
async fn create_provider_expense(
|
||||
&self,
|
||||
input: &super::ProviderExpenseInput,
|
||||
) -> Result<AdminBillingMutationOutcome<super::ProviderExpenseRecord>, DataLayerError> {
|
||||
if let Err(detail) = input.validate() {
|
||||
return Ok(AdminBillingMutationOutcome::Invalid(detail));
|
||||
}
|
||||
let mut guard = self
|
||||
.provider_expenses
|
||||
.write()
|
||||
.expect("provider expense store should lock");
|
||||
if let Some(existing) = guard
|
||||
.values()
|
||||
.find(|r| r.entry.client_request_id == input.client_request_id)
|
||||
{
|
||||
return Ok(if existing.entry.same_request_as(input) {
|
||||
AdminBillingMutationOutcome::Applied(existing.clone())
|
||||
} else {
|
||||
AdminBillingMutationOutcome::Invalid(
|
||||
"client_request_id was already used for another expense".into(),
|
||||
)
|
||||
});
|
||||
}
|
||||
let record = super::ProviderExpenseRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
entry: input.clone(),
|
||||
created_at_unix_ms: chrono::Utc::now().timestamp_millis().max(0) as u64,
|
||||
voided_at_unix_ms: None,
|
||||
voided_by: None,
|
||||
};
|
||||
guard.insert(record.id.clone(), record.clone());
|
||||
Ok(AdminBillingMutationOutcome::Applied(record))
|
||||
}
|
||||
async fn void_provider_expense(
|
||||
&self,
|
||||
id: &str,
|
||||
operator: Option<&str>,
|
||||
) -> Result<AdminBillingMutationOutcome<super::ProviderExpenseRecord>, DataLayerError> {
|
||||
let mut guard = self
|
||||
.provider_expenses
|
||||
.write()
|
||||
.expect("provider expense store should lock");
|
||||
let Some(record) = guard.get_mut(id) else {
|
||||
return Ok(AdminBillingMutationOutcome::NotFound);
|
||||
};
|
||||
if record.voided_at_unix_ms.is_none() {
|
||||
record.voided_at_unix_ms = Some(chrono::Utc::now().timestamp_millis().max(0) as u64);
|
||||
record.voided_by = operator.map(str::to_owned);
|
||||
}
|
||||
Ok(AdminBillingMutationOutcome::Applied(record.clone()))
|
||||
}
|
||||
|
||||
async fn list_billing_plans(
|
||||
&self,
|
||||
include_disabled: bool,
|
||||
@@ -435,6 +513,15 @@ impl BillingReadRepository for InMemoryBillingReadRepository {
|
||||
async fn list_user_plan_entitlements(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
|
||||
self.list_user_plan_entitlements_with_history(user_id, false)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_user_plan_entitlements_with_history(
|
||||
&self,
|
||||
user_id: &str,
|
||||
include_inactive: bool,
|
||||
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
|
||||
let now = current_unix_secs();
|
||||
let mut items = self
|
||||
@@ -444,12 +531,12 @@ impl BillingReadRepository for InMemoryBillingReadRepository {
|
||||
.values()
|
||||
.filter(|item| {
|
||||
item.user_id == user_id
|
||||
&& item.status == "active"
|
||||
&& item.expires_at_unix_secs > now
|
||||
&& (include_inactive
|
||||
|| (item.status == "active" && item.expires_at_unix_secs > now))
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by_key(|item| item.expires_at_unix_secs);
|
||||
items.sort_by_key(|item| (item.expires_at_unix_secs, item.created_at_unix_secs));
|
||||
Ok(Some(items))
|
||||
}
|
||||
|
||||
@@ -592,6 +679,59 @@ mod tests {
|
||||
.expect("billing context should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_user_plan_entitlements_history_includes_inactive_only_for_selected_user() {
|
||||
let repository = InMemoryBillingReadRepository::default();
|
||||
let now = super::current_unix_secs();
|
||||
for (id, user_id, status, expires_at) in [
|
||||
("active", "user-1", "active", now + 3600),
|
||||
("expired", "user-1", "active", now - 60),
|
||||
("revoked", "user-1", "revoked", now + 3600),
|
||||
("replaced", "user-1", "replaced", now + 3600),
|
||||
("another-user", "user-2", "revoked", now + 3600),
|
||||
] {
|
||||
repository.entitlements_by_id.write().unwrap().insert(
|
||||
id.to_string(),
|
||||
super::UserPlanEntitlementRecord {
|
||||
id: id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
plan_id: "plan-1".to_string(),
|
||||
payment_order_id: format!("order-{id}"),
|
||||
status: status.to_string(),
|
||||
starts_at_unix_secs: now - 120,
|
||||
expires_at_unix_secs: expires_at,
|
||||
entitlements_snapshot: json!([]),
|
||||
created_at_unix_secs: now - 120,
|
||||
updated_at_unix_secs: now - 60,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let active = repository
|
||||
.list_user_plan_entitlements("user-1")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(active.len(), 1);
|
||||
assert_eq!(active[0].id, "active");
|
||||
let explicit_active = repository
|
||||
.list_user_plan_entitlements_with_history("user-1", false)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(active, explicit_active);
|
||||
let history = repository
|
||||
.list_user_plan_entitlements_with_history("user-1", true)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(history.len(), 4);
|
||||
assert!(history.iter().all(|item| item.user_id == "user-1"));
|
||||
for expected in ["active", "expired", "revoked", "replaced"] {
|
||||
assert!(history.iter().any(|item| item.id == expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_provider_without_key_scope() {
|
||||
let repository = InMemoryBillingReadRepository::seed(vec![sample_context()]);
|
||||
@@ -779,3 +919,108 @@ mod tests {
|
||||
assert_eq!(after, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod provider_expense_tests {
|
||||
use super::*;
|
||||
use crate::repository::billing::{ProviderExpenseInput, ProviderExpenseQuery};
|
||||
fn input() -> ProviderExpenseInput {
|
||||
ProviderExpenseInput {
|
||||
client_request_id: uuid::Uuid::new_v4().to_string(),
|
||||
provider_id: "provider-1".into(),
|
||||
provider_name: "Supplier".into(),
|
||||
kind: "recharge".into(),
|
||||
amount: "12.34000000".into(),
|
||||
currency: "USD".into(),
|
||||
paid_at_unix_ms: 1000,
|
||||
period_start_unix_ms: None,
|
||||
period_end_unix_ms: None,
|
||||
note: Some("test".into()),
|
||||
external_reference: None,
|
||||
created_by: Some("admin-1".into()),
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn provider_expense_retries_and_void_are_idempotent_without_erasing_audit() {
|
||||
let repo = InMemoryBillingReadRepository::default();
|
||||
let input = input();
|
||||
let AdminBillingMutationOutcome::Applied(first) =
|
||||
repo.create_provider_expense(&input).await.unwrap()
|
||||
else {
|
||||
panic!("expected record")
|
||||
};
|
||||
let mut retried = input.clone();
|
||||
retried.provider_name = "Renamed supplier".into();
|
||||
let AdminBillingMutationOutcome::Applied(second) =
|
||||
repo.create_provider_expense(&retried).await.unwrap()
|
||||
else {
|
||||
panic!("expected retry")
|
||||
};
|
||||
assert_eq!(first, second);
|
||||
retried.amount = "99".into();
|
||||
assert!(matches!(
|
||||
repo.create_provider_expense(&retried).await.unwrap(),
|
||||
AdminBillingMutationOutcome::Invalid(_)
|
||||
));
|
||||
let query = ProviderExpenseQuery {
|
||||
from_unix_ms: 0,
|
||||
to_unix_ms: 2000,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
repo.list_provider_expenses(&query)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.total,
|
||||
1
|
||||
);
|
||||
let AdminBillingMutationOutcome::Applied(voided) = repo
|
||||
.void_provider_expense(&first.id, Some("admin-2"))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected void")
|
||||
};
|
||||
let AdminBillingMutationOutcome::Applied(again) = repo
|
||||
.void_provider_expense(&first.id, Some("admin-3"))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected retry void")
|
||||
};
|
||||
assert_eq!(voided, again);
|
||||
assert_eq!(again.voided_by.as_deref(), Some("admin-2"));
|
||||
let page = repo.list_provider_expenses(&query).await.unwrap().unwrap();
|
||||
assert_eq!(page.total, 0);
|
||||
assert!(page.totals.is_empty());
|
||||
let AdminBillingMutationOutcome::Applied(after_void) =
|
||||
repo.create_provider_expense(&input).await.unwrap()
|
||||
else {
|
||||
panic!("expected original tombstone")
|
||||
};
|
||||
assert_eq!(after_void, again);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn provider_expense_duplicate_submissions_record_once() {
|
||||
let repo = InMemoryBillingReadRepository::default();
|
||||
let input = input();
|
||||
let (a, b) = tokio::join!(
|
||||
repo.create_provider_expense(&input),
|
||||
repo.create_provider_expense(&input)
|
||||
);
|
||||
let (AdminBillingMutationOutcome::Applied(a), AdminBillingMutationOutcome::Applied(b)) =
|
||||
(a.unwrap(), b.unwrap())
|
||||
else {
|
||||
panic!("expected records")
|
||||
};
|
||||
assert_eq!(a.id, b.id);
|
||||
let mut invalid = input.clone();
|
||||
invalid.period_start_unix_ms = Some(100);
|
||||
assert!(matches!(
|
||||
repo.create_provider_expense(&invalid).await.unwrap(),
|
||||
AdminBillingMutationOutcome::Invalid(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user