data.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. //! Shared data layer. Reads the ledger and builds the DTOs consumed by both the
  2. //! JSON API ([`crate::api`]) and the server-rendered HTML views ([`crate::ui`]).
  3. //! Everything here is read-only. Monetary values stay as raw [`Cent`] (minor
  4. //! units); presentation formats them.
  5. use std::sync::Arc;
  6. use axum::{
  7. Json,
  8. http::StatusCode,
  9. response::{IntoResponse, Response},
  10. };
  11. use kuatia::ledger::Ledger;
  12. use kuatia_core::{Account, AccountId, AccountPolicy, AssetId, Cent, PostingId, PostingState};
  13. use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
  14. use kuatia_storage::store::{EnvelopeRecord, TransferQuery};
  15. use serde::Serialize;
  16. use tera::Tera;
  17. use crate::assets::AssetMeta;
  18. use crate::seed::account_label;
  19. /// Shared handler state.
  20. #[derive(Clone)]
  21. pub struct AppState {
  22. pub ledger: Arc<Ledger>,
  23. pub assets: Arc<Vec<AssetMeta>>,
  24. pub tera: Arc<Tera>,
  25. }
  26. // ---------------------------------------------------------------------------
  27. // DTOs
  28. // ---------------------------------------------------------------------------
  29. #[derive(Serialize)]
  30. pub struct BalanceDto {
  31. pub asset: AssetId,
  32. pub value: Cent,
  33. }
  34. #[derive(Serialize)]
  35. pub struct AccountDto {
  36. pub id: AccountId,
  37. /// IBAN-style account code (machine format, checksum-valid) for the full id.
  38. pub code: String,
  39. /// Subaccount id (`0` is the main account). Mirrors `id.sub` for templates.
  40. pub sub: i64,
  41. pub label: Option<&'static str>,
  42. pub version: u64,
  43. pub policy: PolicyDto,
  44. pub frozen: bool,
  45. pub closed: bool,
  46. pub balances: Vec<BalanceDto>,
  47. }
  48. #[derive(Serialize)]
  49. pub struct PolicyDto {
  50. pub kind: &'static str,
  51. pub floor: Option<Cent>,
  52. }
  53. #[derive(Serialize)]
  54. pub struct PostingDto {
  55. pub id: String,
  56. pub owner: AccountId,
  57. pub asset: AssetId,
  58. pub value: Cent,
  59. pub status: String,
  60. }
  61. #[derive(Serialize)]
  62. pub struct TransferLegDto {
  63. pub owner: AccountId,
  64. pub label: Option<&'static str>,
  65. pub asset: AssetId,
  66. pub value: Cent,
  67. pub payer: Option<AccountId>,
  68. pub payer_label: Option<&'static str>,
  69. }
  70. #[derive(Serialize)]
  71. pub struct TransferDto {
  72. pub id: String,
  73. pub created_at: i64,
  74. pub consumes: usize,
  75. pub legs: Vec<TransferLegDto>,
  76. }
  77. #[derive(Serialize)]
  78. pub struct EventDto {
  79. pub seq: u64,
  80. pub timestamp: i64,
  81. pub kind: &'static str,
  82. pub account: Option<AccountId>,
  83. pub transfer: Option<String>,
  84. }
  85. #[derive(Serialize)]
  86. pub struct IssuedDto {
  87. pub asset: AssetId,
  88. pub issued: Cent,
  89. }
  90. #[derive(Serialize)]
  91. pub struct OverviewDto {
  92. pub accounts: usize,
  93. pub transfers: u64,
  94. pub assets: usize,
  95. pub issued: Vec<IssuedDto>,
  96. }
  97. #[derive(Serialize)]
  98. pub struct AccountDetailDto {
  99. pub account: AccountDto,
  100. /// The non-closed subaccounts sharing this account's base id (the viewed
  101. /// account included), so a base account and its subaccounts are navigable
  102. /// together while never summed.
  103. pub subaccounts: Vec<AccountDto>,
  104. pub postings: Vec<PostingDto>,
  105. pub transfers: Vec<TransferDto>,
  106. }
  107. // ---------------------------------------------------------------------------
  108. // Conversions
  109. // ---------------------------------------------------------------------------
  110. fn hex32(bytes: &[u8; 32]) -> String {
  111. bytes.iter().map(|b| format!("{b:02x}")).collect()
  112. }
  113. fn posting_id(id: &PostingId) -> String {
  114. format!("{}:{}", hex32(&id.transfer.0), id.index)
  115. }
  116. fn policy_dto(policy: &AccountPolicy) -> PolicyDto {
  117. match policy {
  118. AccountPolicy::NoOverdraft => PolicyDto {
  119. kind: "NoOverdraft",
  120. floor: None,
  121. },
  122. AccountPolicy::CappedOverdraft { floor } => PolicyDto {
  123. kind: "CappedOverdraft",
  124. floor: Some(*floor),
  125. },
  126. AccountPolicy::UncappedOverdraft => PolicyDto {
  127. kind: "UncappedOverdraft",
  128. floor: None,
  129. },
  130. AccountPolicy::SystemAccount => PolicyDto {
  131. kind: "SystemAccount",
  132. floor: None,
  133. },
  134. AccountPolicy::ExternalAccount => PolicyDto {
  135. kind: "ExternalAccount",
  136. floor: None,
  137. },
  138. }
  139. }
  140. async fn account_dto(state: &AppState, account: &Account) -> Result<AccountDto, ApiError> {
  141. let mut balances = Vec::new();
  142. for asset in state.assets.iter() {
  143. let value = state.ledger.balance(&account.id, &asset.id).await?;
  144. // Emit only non-zero balances; a zero renders as the string "0".
  145. if value.to_string() != "0" {
  146. balances.push(BalanceDto {
  147. asset: asset.id,
  148. value,
  149. });
  150. }
  151. }
  152. Ok(AccountDto {
  153. id: account.id,
  154. code: account.id.to_string(),
  155. sub: account.id.sub,
  156. label: account_label(account.id),
  157. version: account.version,
  158. policy: policy_dto(&account.policy),
  159. frozen: account.is_frozen(),
  160. closed: account.is_closed(),
  161. balances,
  162. })
  163. }
  164. fn transfer_dto(record: &EnvelopeRecord) -> TransferDto {
  165. let legs = record
  166. .envelope
  167. .creates
  168. .iter()
  169. .map(|p| TransferLegDto {
  170. owner: p.owner,
  171. label: account_label(p.owner),
  172. asset: p.asset,
  173. value: p.value,
  174. payer: p.payer,
  175. payer_label: p.payer.and_then(account_label),
  176. })
  177. .collect();
  178. TransferDto {
  179. id: hex32(&record.receipt.transfer_id.0),
  180. created_at: record.created_at,
  181. consumes: record.envelope.consumes.len(),
  182. legs,
  183. }
  184. }
  185. fn event_dto(event: &LedgerEvent) -> EventDto {
  186. let (kind, account, transfer) = match &event.kind {
  187. LedgerEventKind::TransferCommitted { transfer_id } => {
  188. ("TransferCommitted", None, Some(hex32(&transfer_id.0)))
  189. }
  190. LedgerEventKind::AccountCreated { account_id } => {
  191. ("AccountCreated", Some(*account_id), None)
  192. }
  193. LedgerEventKind::AccountFrozen { account_id } => ("AccountFrozen", Some(*account_id), None),
  194. LedgerEventKind::AccountUnfrozen { account_id } => {
  195. ("AccountUnfrozen", Some(*account_id), None)
  196. }
  197. LedgerEventKind::AccountClosed { account_id } => ("AccountClosed", Some(*account_id), None),
  198. };
  199. EventDto {
  200. seq: event.seq,
  201. timestamp: event.timestamp,
  202. kind,
  203. account,
  204. transfer,
  205. }
  206. }
  207. // ---------------------------------------------------------------------------
  208. // Builders — read the ledger and assemble DTOs.
  209. // ---------------------------------------------------------------------------
  210. /// Ledger-wide summary: counts and total issued per asset.
  211. pub async fn overview(state: &AppState) -> Result<OverviewDto, ApiError> {
  212. let accounts = state.ledger.list_accounts().await?;
  213. let page = state
  214. .ledger
  215. .query_transfers(&TransferQuery::default())
  216. .await?;
  217. // Total issued per asset = the negative of the external account's balance;
  218. // deposits push the offset (negative) side onto External, so its balance
  219. // mirrors everything in circulation.
  220. let mut issued = Vec::new();
  221. for asset in state.assets.iter() {
  222. let external = state
  223. .ledger
  224. .balance(&crate::seed::EXTERNAL, &asset.id)
  225. .await?;
  226. let issued_value = external
  227. .checked_neg()
  228. .map_err(|_| ApiError::internal("overflow"))?;
  229. if issued_value.to_string() != "0" {
  230. issued.push(IssuedDto {
  231. asset: asset.id,
  232. issued: issued_value,
  233. });
  234. }
  235. }
  236. Ok(OverviewDto {
  237. accounts: accounts.len(),
  238. transfers: page.total,
  239. assets: state.assets.len(),
  240. issued,
  241. })
  242. }
  243. /// Every account (sorted by id) with its balances.
  244. pub async fn accounts(state: &AppState) -> Result<Vec<AccountDto>, ApiError> {
  245. let mut accounts = state.ledger.list_accounts().await?;
  246. accounts.sort_by_key(|a| (a.id.id, a.id.sub));
  247. let mut out = Vec::with_capacity(accounts.len());
  248. for account in &accounts {
  249. out.push(account_dto(state, account).await?);
  250. }
  251. Ok(out)
  252. }
  253. /// Human-readable label for a posting's derived lifecycle state.
  254. fn posting_state_label(state: &PostingState) -> &'static str {
  255. match state {
  256. PostingState::Active => "Active",
  257. PostingState::Reserved(_) => "Reserved",
  258. PostingState::Spent => "Spent",
  259. PostingState::Missing => "Missing",
  260. }
  261. }
  262. /// One account with its postings (largest first) and the transfers it took part
  263. /// in.
  264. pub async fn account_detail(state: &AppState, id: AccountId) -> Result<AccountDetailDto, ApiError> {
  265. let account = state.ledger.get_account(&id).await?;
  266. let mut postings: Vec<PostingDto> = state
  267. .ledger
  268. .postings_with_state(&id)
  269. .await?
  270. .iter()
  271. .map(|(p, state)| PostingDto {
  272. id: posting_id(&p.id),
  273. owner: p.owner,
  274. asset: p.asset,
  275. value: p.value,
  276. status: posting_state_label(state).to_string(),
  277. })
  278. .collect();
  279. postings.sort_by_key(|p| std::cmp::Reverse(p.value));
  280. let transfers = state
  281. .ledger
  282. .history(&id)
  283. .await?
  284. .iter()
  285. .map(transfer_dto)
  286. .collect();
  287. // The base account and its non-closed subaccounts, so the detail page can
  288. // list every partition with its own (segregated) balances.
  289. let mut subaccounts = Vec::new();
  290. for sub_id in state.ledger.list_subaccounts(&id).await? {
  291. let sub_account = state.ledger.get_account(&sub_id).await?;
  292. subaccounts.push(account_dto(state, &sub_account).await?);
  293. }
  294. Ok(AccountDetailDto {
  295. account: account_dto(state, &account).await?,
  296. subaccounts,
  297. postings,
  298. transfers,
  299. })
  300. }
  301. /// Recent transfers, newest first.
  302. pub async fn transfers(state: &AppState, limit: Option<u32>) -> Result<Vec<TransferDto>, ApiError> {
  303. let query = TransferQuery {
  304. limit: limit.or(Some(100)),
  305. ..Default::default()
  306. };
  307. let page = state.ledger.query_transfers(&query).await?;
  308. let mut out: Vec<TransferDto> = page.items.iter().map(transfer_dto).collect();
  309. out.sort_by_key(|t| std::cmp::Reverse(t.created_at));
  310. Ok(out)
  311. }
  312. /// Ledger events after `after`, oldest first (as stored).
  313. pub async fn events(state: &AppState, after: u64, limit: u32) -> Result<Vec<EventDto>, ApiError> {
  314. let events = state.ledger.get_events_since(after, limit).await?;
  315. Ok(events.iter().map(event_dto).collect())
  316. }
  317. // ---------------------------------------------------------------------------
  318. // Error handling
  319. // ---------------------------------------------------------------------------
  320. /// Any handler failure. Rendered as a JSON error body with a 500 (or 404 for a
  321. /// missing account). Shared by the JSON and HTML handlers.
  322. pub struct ApiError {
  323. status: StatusCode,
  324. message: String,
  325. }
  326. impl ApiError {
  327. fn internal(message: impl Into<String>) -> Self {
  328. Self {
  329. status: StatusCode::INTERNAL_SERVER_ERROR,
  330. message: message.into(),
  331. }
  332. }
  333. /// Build a 500 from any displayable error (used by the HTML render path).
  334. pub fn from_display(err: impl std::fmt::Display) -> Self {
  335. Self::internal(err.to_string())
  336. }
  337. /// Build a 400 from a displayable error (used for a malformed account id in
  338. /// the URL).
  339. pub fn bad_request(err: impl std::fmt::Display) -> Self {
  340. Self {
  341. status: StatusCode::BAD_REQUEST,
  342. message: err.to_string(),
  343. }
  344. }
  345. }
  346. impl From<kuatia::error::LedgerError> for ApiError {
  347. fn from(err: kuatia::error::LedgerError) -> Self {
  348. use kuatia::error::LedgerError;
  349. let status = match err {
  350. LedgerError::AccountNotFound(_) => StatusCode::NOT_FOUND,
  351. _ => StatusCode::INTERNAL_SERVER_ERROR,
  352. };
  353. Self {
  354. status,
  355. message: err.to_string(),
  356. }
  357. }
  358. }
  359. impl IntoResponse for ApiError {
  360. fn into_response(self) -> Response {
  361. (
  362. self.status,
  363. Json(serde_json::json!({ "error": self.message })),
  364. )
  365. .into_response()
  366. }
  367. }