Skip to main content

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}