talea_core/api/mod.rs
1use async_trait::async_trait;
2use chrono::{DateTime, Utc};
3use futures::stream::BoxStream;
4
5mod error;
6mod requests;
7mod responses;
8
9pub use error::*;
10pub use requests::*;
11pub use responses::*;
12
13use crate::types::Seq;
14
15pub type ApiResult<T> = Result<T, ApiError>;
16pub type EventStream = BoxStream<'static, ApiResult<EventEnvelope>>;
17
18/// Render minor units as a decimal string using the asset's precision.
19/// Pure string arithmetic — safe for any precision, no 10^p overflow.
20pub fn format_minor(minor: i64, precision: u8) -> String {
21 let precision = precision as usize;
22 if precision == 0 {
23 return minor.to_string();
24 }
25 let sign = if minor < 0 { "-" } else { "" };
26 let digits = minor.unsigned_abs().to_string();
27 if digits.len() > precision {
28 let (whole, frac) = digits.split_at(digits.len() - precision);
29 format!("{sign}{whole}.{frac}")
30 } else {
31 format!("{sign}0.{digits:0>precision$}")
32 }
33}
34
35/// The full ledger contract. Each transport adapter is a thin translation
36/// onto it: the server implements it over a `Store`, the client over HTTP —
37/// code written against this trait runs unchanged against either.
38#[async_trait]
39pub trait LedgerApi: Send + Sync {
40 /// Register an asset. Idempotent on id: an identical re-registration
41 /// succeeds, the same id with a different definition is `AlreadyExists`.
42 /// Crypto assets require a network; precision is immutable forever.
43 async fn register_asset(&self, draft: AssetDraft) -> ApiResult<()>;
44
45 /// Open an account in a book. Idempotent on book+path with the same
46 /// rule as assets. Book names starting with '_' are reserved.
47 async fn open_account(&self, draft: AccountDraft) -> ApiResult<()>;
48
49 /// Post a balanced transaction (per-asset debits == credits, all
50 /// amounts positive). Idempotent on the caller-supplied idempotency key
51 /// (unique per book): a replay returns the original `Posted` with
52 /// `deduplicated: true` and never double-posts — which is what makes
53 /// retrying on failure unconditionally safe.
54 async fn post(&self, draft: TransactionDraft) -> ApiResult<Posted>;
55
56 /// Post multiple drafts and return one result per input, preserving
57 /// input order (`out[i]` corresponds to `drafts[i]`).
58 ///
59 /// **Positional contract** — every draft is attempted independently.
60 /// A failure (validation error, unknown account, unbalanced, …) in one
61 /// slot sets that slot's `Err`; it has no effect on any other slot.
62 ///
63 /// **Idempotency deduplication** — two drafts with the same idempotency
64 /// key, whether within this batch or against historical commits, both
65 /// resolve to the original `Posted` (with `deduplicated: true`) exactly
66 /// as concurrent single `post` calls would. This is a property of the
67 /// per-book write router, not special batch logic.
68 ///
69 /// **Empty input** returns an empty `Vec` immediately.
70 ///
71 /// Implementations must preserve input order in the returned `Vec`.
72 async fn post_batch(&self, drafts: Vec<TransactionDraft>) -> Vec<ApiResult<Posted>>;
73
74 /// Effective (normal-side-adjusted) balance, rendered as a decimal
75 /// string using the asset's precision. `as_of` replays by commit time;
76 /// `None` reads the live projection.
77 async fn balance(
78 &self,
79 book: &str,
80 path: &str,
81 as_of: Option<DateTime<Utc>>,
82 ) -> ApiResult<BalanceView>;
83
84 /// Postings for one account, seq-ascending. `page.after_seq` is
85 /// exclusive (resume with the last seen seq); `limit` counts
86 /// transactions, so one transaction's postings never split across
87 /// pages. `Paged::next` is `None` once exhausted.
88 async fn account_history(
89 &self,
90 book: &str,
91 path: &str,
92 page: Page,
93 ) -> ApiResult<Paged<PostingView>>;
94
95 /// A committed transaction by its id (UUID assigned at post time).
96 /// Unknown ids are `NotFound`.
97 async fn transaction(&self, tx_id: &str) -> ApiResult<TransactionView>;
98
99 /// Per-asset debit/credit sums for a book, optionally as of commit
100 /// time. Every line balances when the ledger does.
101 async fn trial_balance(
102 &self,
103 book: &str,
104 as_of: Option<DateTime<Utc>>,
105 ) -> ApiResult<TrialBalance>;
106
107 /// Live event stream for a book, starting at seq `from` (inclusive:
108 /// catch-up first, then tail). Delivery is at-least-once; consumers
109 /// resume after a disconnect from their last seen `EventEnvelope::seq`.
110 /// The HTTP client implementation does that resumption automatically.
111 async fn subscribe(&self, book: &str, from: Seq) -> ApiResult<EventStream>;
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn format_minor_renders_decimal_strings() {
120 assert_eq!(format_minor(150000, 2), "1500.00");
121 assert_eq!(format_minor(150000, 8), "0.00150000");
122 assert_eq!(format_minor(-1500, 2), "-15.00");
123 assert_eq!(format_minor(5, 2), "0.05");
124 assert_eq!(format_minor(100, 3), "0.100"); // digits.len() == precision boundary
125 assert_eq!(format_minor(0, 2), "0.00");
126 assert_eq!(format_minor(42, 0), "42");
127 assert_eq!(format_minor(-42, 0), "-42");
128 assert_eq!(format_minor(i64::MIN, 2), "-92233720368547758.08");
129 }
130
131 #[test]
132 fn api_error_new_variants_serialize_tagged() {
133 let e = ApiError::InvalidDraft {
134 field: "class".into(),
135 reason: "unknown asset class".into(),
136 };
137 let json = serde_json::to_string(&e).unwrap();
138 assert!(json.contains("\"error\":\"invalid_draft\""), "got: {json}");
139
140 let e = ApiError::NotFound {
141 what: "transaction x".into(),
142 };
143 let json = serde_json::to_string(&e).unwrap();
144 assert!(json.contains("\"error\":\"not_found\""), "got: {json}");
145
146 let e = ApiError::AssetMismatch {
147 account: "onramp:cash".into(),
148 account_asset: "USD".into(),
149 asset: "EUR".into(),
150 };
151 let json = serde_json::to_string(&e).unwrap();
152 assert!(json.contains("\"asset\":\"EUR\""), "got: {json}");
153
154 let e = ApiError::Transport {
155 message: "connection refused".into(),
156 };
157 let json = serde_json::to_string(&e).unwrap();
158 assert!(json.contains("\"error\":\"transport\""), "got: {json}");
159 }
160
161 #[test]
162 fn transaction_draft_occurred_at_defaults_to_none() {
163 let draft: TransactionDraft =
164 serde_json::from_str(r#"{"book":"b","idempotency_key":"k","postings":[]}"#).unwrap();
165 assert!(draft.occurred_at.is_none());
166 }
167}