data.rs 11 KB

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