integration.rs 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991
  1. #![allow(missing_docs)]
  2. use std::sync::Arc;
  3. use kuatia::ledger::Ledger;
  4. use kuatia::mem_store::InMemoryStore;
  5. use kuatia_core::*;
  6. use std::collections::BTreeMap;
  7. fn usd() -> AssetId {
  8. AssetId::new(1)
  9. }
  10. fn eur() -> AssetId {
  11. AssetId::new(2)
  12. }
  13. fn account(id: i64) -> AccountId {
  14. AccountId::new(id)
  15. }
  16. fn external() -> AccountId {
  17. AccountId::new(99)
  18. }
  19. fn make_account(id: i64, flags: AccountFlags) -> Account {
  20. Account {
  21. id: AccountId::new(id),
  22. version: 1,
  23. flags,
  24. book: BookId(0),
  25. metadata: BTreeMap::new(),
  26. }
  27. }
  28. async fn setup_ledger() -> Arc<Ledger> {
  29. let store = InMemoryStore::new();
  30. let ledger = Arc::new(Ledger::new(store));
  31. ledger
  32. .store()
  33. .create_account(make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
  34. .await
  35. .unwrap();
  36. ledger
  37. .store()
  38. .create_account(make_account(2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
  39. .await
  40. .unwrap();
  41. ledger
  42. .store()
  43. .create_account(make_account(3, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
  44. .await
  45. .unwrap();
  46. ledger
  47. .store()
  48. .create_account(make_account(99, AccountFlags::empty()))
  49. .await
  50. .unwrap();
  51. ledger
  52. }
  53. /// Helper: deposit via commit()
  54. async fn deposit(
  55. ledger: &Arc<Ledger>,
  56. to: AccountId,
  57. asset: AssetId,
  58. amount: Cent,
  59. ext: AccountId,
  60. ) -> Receipt {
  61. let transfer = TransferBuilder::new()
  62. .deposit(to, asset, amount, ext)
  63. .unwrap()
  64. .build();
  65. ledger.commit(transfer).await.unwrap()
  66. }
  67. /// Helper: pay via commit()
  68. async fn pay(
  69. ledger: &Arc<Ledger>,
  70. from: AccountId,
  71. to: AccountId,
  72. asset: AssetId,
  73. amount: Cent,
  74. ) -> Receipt {
  75. let transfer = TransferBuilder::new().pay(from, to, asset, amount).build();
  76. ledger.commit(transfer).await.unwrap()
  77. }
  78. /// Helper: withdraw via commit()
  79. async fn withdraw(
  80. ledger: &Arc<Ledger>,
  81. from: AccountId,
  82. asset: AssetId,
  83. amount: Cent,
  84. ext: AccountId,
  85. ) -> Receipt {
  86. let transfer = TransferBuilder::new()
  87. .withdraw(from, asset, amount, ext)
  88. .build();
  89. ledger.commit(transfer).await.unwrap()
  90. }
  91. // ---------------------------------------------------------------------------
  92. // §4.1 Deposit
  93. // ---------------------------------------------------------------------------
  94. #[tokio::test]
  95. async fn deposit_creates_balanced_postings() {
  96. let ledger = setup_ledger().await;
  97. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  98. assert_eq!(
  99. ledger.balance(&account(1), &usd()).await.unwrap(),
  100. Cent::from(100)
  101. );
  102. assert_eq!(
  103. ledger.balance(&external(), &usd()).await.unwrap(),
  104. Cent::from(-100)
  105. );
  106. }
  107. // ---------------------------------------------------------------------------
  108. // §4.2 Internal transfer with change
  109. // ---------------------------------------------------------------------------
  110. #[tokio::test]
  111. async fn pay_with_change() {
  112. let ledger = setup_ledger().await;
  113. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  114. pay(&ledger, account(1), account(2), usd(), Cent::from(50)).await;
  115. assert_eq!(
  116. ledger.balance(&account(1), &usd()).await.unwrap(),
  117. Cent::from(50)
  118. );
  119. assert_eq!(
  120. ledger.balance(&account(2), &usd()).await.unwrap(),
  121. Cent::from(50)
  122. );
  123. assert_eq!(
  124. ledger.balance(&external(), &usd()).await.unwrap(),
  125. Cent::from(-100)
  126. );
  127. }
  128. // ---------------------------------------------------------------------------
  129. // §4.3 Multi-hop
  130. // ---------------------------------------------------------------------------
  131. #[tokio::test]
  132. async fn multi_hop_transfer() {
  133. let ledger = setup_ledger().await;
  134. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  135. pay(&ledger, account(1), account(2), usd(), Cent::from(50)).await;
  136. pay(&ledger, account(2), account(3), usd(), Cent::from(20)).await;
  137. assert_eq!(
  138. ledger.balance(&account(1), &usd()).await.unwrap(),
  139. Cent::from(50)
  140. );
  141. assert_eq!(
  142. ledger.balance(&account(2), &usd()).await.unwrap(),
  143. Cent::from(30)
  144. );
  145. assert_eq!(
  146. ledger.balance(&account(3), &usd()).await.unwrap(),
  147. Cent::from(20)
  148. );
  149. assert_eq!(
  150. ledger.balance(&external(), &usd()).await.unwrap(),
  151. Cent::from(-100)
  152. );
  153. }
  154. // ---------------------------------------------------------------------------
  155. // §4.5 Withdrawal
  156. // ---------------------------------------------------------------------------
  157. #[tokio::test]
  158. async fn withdrawal_reduces_external_liability() {
  159. let ledger = setup_ledger().await;
  160. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  161. withdraw(&ledger, account(1), usd(), Cent::from(50), external()).await;
  162. assert_eq!(
  163. ledger.balance(&account(1), &usd()).await.unwrap(),
  164. Cent::from(50)
  165. );
  166. assert_eq!(
  167. ledger.balance(&external(), &usd()).await.unwrap(),
  168. Cent::from(-50)
  169. );
  170. }
  171. // ---------------------------------------------------------------------------
  172. // Full round-trip: deposit -> pay -> withdraw -> verify total = 0
  173. // ---------------------------------------------------------------------------
  174. #[tokio::test]
  175. async fn full_round_trip() {
  176. let ledger = setup_ledger().await;
  177. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  178. pay(&ledger, account(1), account(2), usd(), Cent::from(60)).await;
  179. withdraw(&ledger, account(2), usd(), Cent::from(60), external()).await;
  180. withdraw(&ledger, account(1), usd(), Cent::from(40), external()).await;
  181. assert_eq!(
  182. ledger.balance(&account(1), &usd()).await.unwrap(),
  183. Cent::ZERO
  184. );
  185. assert_eq!(
  186. ledger.balance(&account(2), &usd()).await.unwrap(),
  187. Cent::ZERO
  188. );
  189. assert_eq!(
  190. ledger.balance(&external(), &usd()).await.unwrap(),
  191. Cent::ZERO
  192. );
  193. }
  194. // ---------------------------------------------------------------------------
  195. // Idempotency -- committing same envelope twice returns same receipt
  196. // ---------------------------------------------------------------------------
  197. #[tokio::test]
  198. async fn idempotent_commit() {
  199. let ledger = setup_ledger().await;
  200. let envelope = EnvelopeBuilder::new()
  201. .creates(vec![
  202. NewPosting {
  203. owner: account(1),
  204. asset: usd(),
  205. value: Cent::from(100),
  206. payer: None,
  207. },
  208. NewPosting {
  209. owner: external(),
  210. asset: usd(),
  211. value: Cent::from(-100),
  212. payer: None,
  213. },
  214. ])
  215. .build();
  216. let r1 = ledger.commit_envelope(envelope.clone()).await.unwrap();
  217. let r2 = ledger.commit_envelope(envelope).await.unwrap();
  218. assert_eq!(r1.transfer_id, r2.transfer_id);
  219. // Balance should only be 100, not 200 (second commit was a no-op)
  220. assert_eq!(
  221. ledger.balance(&account(1), &usd()).await.unwrap(),
  222. Cent::from(100)
  223. );
  224. }
  225. // ---------------------------------------------------------------------------
  226. // Overdraft prevention
  227. // ---------------------------------------------------------------------------
  228. #[tokio::test]
  229. async fn overdraft_rejected() {
  230. let ledger = setup_ledger().await;
  231. deposit(&ledger, account(1), usd(), Cent::from(50), external()).await;
  232. let transfer = TransferBuilder::new()
  233. .pay(account(1), account(2), usd(), Cent::from(100))
  234. .build();
  235. let result = ledger.commit(transfer).await;
  236. assert!(result.is_err());
  237. // Balance unchanged
  238. assert_eq!(
  239. ledger.balance(&account(1), &usd()).await.unwrap(),
  240. Cent::from(50)
  241. );
  242. }
  243. // ---------------------------------------------------------------------------
  244. // Reverse: forward compensating transfer
  245. // ---------------------------------------------------------------------------
  246. #[tokio::test]
  247. async fn reverse_restores_balances() {
  248. let ledger = setup_ledger().await;
  249. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  250. let pay_receipt = pay(&ledger, account(1), account(2), usd(), Cent::from(60)).await;
  251. assert_eq!(
  252. ledger.balance(&account(1), &usd()).await.unwrap(),
  253. Cent::from(40)
  254. );
  255. assert_eq!(
  256. ledger.balance(&account(2), &usd()).await.unwrap(),
  257. Cent::from(60)
  258. );
  259. // Reverse the payment
  260. ledger.reverse(&pay_receipt.transfer_id).await.unwrap();
  261. assert_eq!(
  262. ledger.balance(&account(1), &usd()).await.unwrap(),
  263. Cent::from(100)
  264. );
  265. assert_eq!(
  266. ledger.balance(&account(2), &usd()).await.unwrap(),
  267. Cent::ZERO
  268. );
  269. }
  270. // ---------------------------------------------------------------------------
  271. // Frozen account blocks transfers
  272. // ---------------------------------------------------------------------------
  273. #[tokio::test]
  274. async fn frozen_account_rejected() {
  275. let store = InMemoryStore::new();
  276. let ledger = Arc::new(Ledger::new(store));
  277. let mut frozen = make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
  278. frozen.flags = AccountFlags::FROZEN;
  279. ledger.store().create_account(frozen).await.unwrap();
  280. ledger
  281. .store()
  282. .create_account(make_account(99, AccountFlags::empty()))
  283. .await
  284. .unwrap();
  285. let transfer = TransferBuilder::new()
  286. .deposit(account(1), usd(), Cent::from(100), external())
  287. .unwrap()
  288. .build();
  289. let result = ledger.commit(transfer).await;
  290. assert!(result.is_err());
  291. }
  292. // ---------------------------------------------------------------------------
  293. // Multi-asset: each asset conserves independently
  294. // ---------------------------------------------------------------------------
  295. #[tokio::test]
  296. async fn multi_asset_independent_balances() {
  297. let ledger = setup_ledger().await;
  298. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  299. deposit(&ledger, account(1), eur(), Cent::from(200), external()).await;
  300. assert_eq!(
  301. ledger.balance(&account(1), &usd()).await.unwrap(),
  302. Cent::from(100)
  303. );
  304. assert_eq!(
  305. ledger.balance(&account(1), &eur()).await.unwrap(),
  306. Cent::from(200)
  307. );
  308. pay(&ledger, account(1), account(2), usd(), Cent::from(30)).await;
  309. assert_eq!(
  310. ledger.balance(&account(1), &usd()).await.unwrap(),
  311. Cent::from(70)
  312. );
  313. assert_eq!(
  314. ledger.balance(&account(1), &eur()).await.unwrap(),
  315. Cent::from(200)
  316. );
  317. assert_eq!(
  318. ledger.balance(&account(2), &usd()).await.unwrap(),
  319. Cent::from(30)
  320. );
  321. }
  322. // ---------------------------------------------------------------------------
  323. // §4.4 FX trade via market account
  324. // ---------------------------------------------------------------------------
  325. #[tokio::test]
  326. async fn fx_trade_via_market_account() {
  327. let store = InMemoryStore::new();
  328. let ledger = Arc::new(Ledger::new(store));
  329. // Setup accounts
  330. for (id, policy) in [
  331. (1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
  332. (50, AccountFlags::empty()), // FX market account
  333. (99, AccountFlags::empty()),
  334. ] {
  335. ledger
  336. .store()
  337. .create_account(make_account(id, policy))
  338. .await
  339. .unwrap();
  340. }
  341. // Seed: account1 has 100 USD, fx has 92 EUR
  342. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  343. deposit(&ledger, account(50), eur(), Cent::from(92), external()).await;
  344. // FX trade: account1 sells 100 USD, buys 92 EUR
  345. // Build the atomic envelope manually since it spans two assets
  346. let a1_usd_postings = ledger
  347. .store()
  348. .get_postings_by_account(1, None, Some(&usd()), PostingFilter::Active)
  349. .await
  350. .unwrap();
  351. let fx_eur_postings = ledger
  352. .store()
  353. .get_postings_by_account(50, None, Some(&eur()), PostingFilter::Active)
  354. .await
  355. .unwrap();
  356. let envelope = EnvelopeBuilder::new()
  357. .consumes(vec![a1_usd_postings[0].id, fx_eur_postings[0].id])
  358. .creates(vec![
  359. NewPosting {
  360. owner: account(50),
  361. asset: usd(),
  362. value: Cent::from(100),
  363. payer: Some(account(1)),
  364. },
  365. NewPosting {
  366. owner: account(1),
  367. asset: eur(),
  368. value: Cent::from(92),
  369. payer: Some(account(50)),
  370. },
  371. ])
  372. .build();
  373. ledger.commit_envelope(envelope).await.unwrap();
  374. // Verify
  375. assert_eq!(
  376. ledger.balance(&account(1), &usd()).await.unwrap(),
  377. Cent::ZERO
  378. );
  379. assert_eq!(
  380. ledger.balance(&account(1), &eur()).await.unwrap(),
  381. Cent::from(92)
  382. );
  383. assert_eq!(
  384. ledger.balance(&account(50), &usd()).await.unwrap(),
  385. Cent::from(100)
  386. );
  387. assert_eq!(
  388. ledger.balance(&account(50), &eur()).await.unwrap(),
  389. Cent::ZERO
  390. );
  391. }
  392. // ---------------------------------------------------------------------------
  393. // Account lifecycle: freeze / unfreeze / close
  394. // ---------------------------------------------------------------------------
  395. #[tokio::test]
  396. async fn freeze_blocks_transfers() {
  397. let ledger = setup_ledger().await;
  398. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  399. ledger.freeze(&account(1)).await.unwrap();
  400. // Paying from a frozen account should fail
  401. let transfer = TransferBuilder::new()
  402. .pay(account(1), account(2), usd(), Cent::from(50))
  403. .build();
  404. let result = ledger.commit(transfer).await;
  405. assert!(result.is_err());
  406. // Balance unchanged
  407. assert_eq!(
  408. ledger.balance(&account(1), &usd()).await.unwrap(),
  409. Cent::from(100)
  410. );
  411. }
  412. #[tokio::test]
  413. async fn unfreeze_re_enables_transfers() {
  414. let ledger = setup_ledger().await;
  415. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  416. ledger.freeze(&account(1)).await.unwrap();
  417. ledger.unfreeze(&account(1)).await.unwrap();
  418. // Should work again
  419. pay(&ledger, account(1), account(2), usd(), Cent::from(50)).await;
  420. assert_eq!(
  421. ledger.balance(&account(1), &usd()).await.unwrap(),
  422. Cent::from(50)
  423. );
  424. }
  425. #[tokio::test]
  426. async fn close_account_with_zero_balance() {
  427. let ledger = setup_ledger().await;
  428. // Account 3 has never transacted -- zero balance, no postings
  429. ledger.close(&account(3)).await.unwrap();
  430. // Closed account rejects deposits
  431. let transfer = TransferBuilder::new()
  432. .deposit(account(3), usd(), Cent::from(100), external())
  433. .unwrap()
  434. .build();
  435. let result = ledger.commit(transfer).await;
  436. assert!(result.is_err());
  437. }
  438. #[tokio::test]
  439. async fn close_account_with_balance_rejected() {
  440. let ledger = setup_ledger().await;
  441. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  442. // Should fail -- account still has active postings
  443. let result = ledger.close(&account(1)).await;
  444. assert!(result.is_err());
  445. // Balance unchanged
  446. assert_eq!(
  447. ledger.balance(&account(1), &usd()).await.unwrap(),
  448. Cent::from(100)
  449. );
  450. }
  451. #[tokio::test]
  452. async fn close_rejects_reserved_postings() {
  453. let ledger = setup_ledger().await;
  454. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  455. // Reserve the account's only posting (a transfer in flight): move it from
  456. // the active index into the reserved index.
  457. let postings = ledger
  458. .store()
  459. .get_postings_by_account(1, None, Some(&usd()), PostingFilter::Active)
  460. .await
  461. .unwrap();
  462. ledger
  463. .store()
  464. .reserve_postings(&[postings[0].id], ReservationId::new(1))
  465. .await
  466. .unwrap();
  467. // Close must reject: the posting is live (reserved), not spent.
  468. let result = ledger.close(&account(1)).await;
  469. assert!(result.is_err());
  470. }
  471. #[tokio::test]
  472. async fn freeze_closed_account_rejected() {
  473. let ledger = setup_ledger().await;
  474. ledger.close(&account(3)).await.unwrap();
  475. let result = ledger.freeze(&account(3)).await;
  476. assert!(result.is_err());
  477. }
  478. // ---------------------------------------------------------------------------
  479. // Query layer: history, postings, list_accounts, get_account
  480. // ---------------------------------------------------------------------------
  481. #[tokio::test]
  482. async fn history_returns_transfers_for_account() {
  483. let ledger = setup_ledger().await;
  484. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  485. pay(&ledger, account(1), account(2), usd(), Cent::from(40)).await;
  486. deposit(&ledger, account(2), usd(), Cent::from(50), external()).await;
  487. let h1 = ledger.history(&account(1)).await.unwrap();
  488. // account(1) was in the deposit and the pay
  489. assert_eq!(h1.len(), 2);
  490. let h2 = ledger.history(&account(2)).await.unwrap();
  491. // account(2) was in the pay and a second deposit
  492. assert_eq!(h2.len(), 2);
  493. }
  494. #[tokio::test]
  495. async fn postings_returns_all_postings() {
  496. let ledger = setup_ledger().await;
  497. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  498. pay(&ledger, account(1), account(2), usd(), Cent::from(60)).await;
  499. let posts = ledger.postings(&account(1)).await.unwrap();
  500. // Original 100 posting (now consumed) + 40 change posting (active)
  501. assert_eq!(posts.len(), 2);
  502. let with_state = ledger.postings_with_state(&account(1)).await.unwrap();
  503. let active: Vec<_> = with_state
  504. .iter()
  505. .filter(|(_, s)| *s == PostingState::Active)
  506. .collect();
  507. assert_eq!(active.len(), 1);
  508. assert_eq!(active[0].0.value, Cent::from(40));
  509. }
  510. #[tokio::test]
  511. async fn list_accounts_returns_all() {
  512. let ledger = setup_ledger().await;
  513. let accounts = ledger.list_accounts().await.unwrap();
  514. // setup_ledger creates accounts 1, 2, 3, 99
  515. assert_eq!(accounts.len(), 4);
  516. }
  517. #[tokio::test]
  518. async fn get_account_by_id() {
  519. let ledger = setup_ledger().await;
  520. let acc = ledger.get_account(&account(1)).await.unwrap();
  521. assert_eq!(acc.id, account(1));
  522. assert!(acc.forbids_overdraft());
  523. }
  524. #[tokio::test]
  525. async fn get_account_not_found() {
  526. let ledger = setup_ledger().await;
  527. let result = ledger.get_account(&account(999)).await;
  528. assert!(result.is_err());
  529. }
  530. // ---------------------------------------------------------------------------
  531. // Append-only accounts: version history, version conflict, account_versions
  532. // ---------------------------------------------------------------------------
  533. #[tokio::test]
  534. async fn account_history_tracks_versions() {
  535. let ledger = setup_ledger().await;
  536. // Version 1: created
  537. let history = ledger.account_history(&account(1)).await.unwrap();
  538. assert_eq!(history.len(), 1);
  539. assert_eq!(history[0].version, 1);
  540. // Version 2: frozen
  541. ledger.freeze(&account(1)).await.unwrap();
  542. let history = ledger.account_history(&account(1)).await.unwrap();
  543. assert_eq!(history.len(), 2);
  544. assert_eq!(history[1].version, 2);
  545. assert!(history[1].is_frozen());
  546. // Version 3: unfrozen
  547. ledger.unfreeze(&account(1)).await.unwrap();
  548. let history = ledger.account_history(&account(1)).await.unwrap();
  549. assert_eq!(history.len(), 3);
  550. assert_eq!(history[2].version, 3);
  551. assert!(!history[2].is_frozen());
  552. }
  553. #[tokio::test]
  554. async fn store_never_compacts() {
  555. let ledger = setup_ledger().await;
  556. // Freeze and unfreeze multiple times
  557. for _ in 0..5 {
  558. ledger.freeze(&account(1)).await.unwrap();
  559. ledger.unfreeze(&account(1)).await.unwrap();
  560. }
  561. // All 11 versions preserved (1 creation + 10 mutations)
  562. let history = ledger.account_history(&account(1)).await.unwrap();
  563. assert_eq!(history.len(), 11);
  564. // Versions are monotonically increasing
  565. for (i, acc) in history.iter().enumerate() {
  566. assert_eq!(acc.version, (i + 1) as u64);
  567. }
  568. }
  569. #[tokio::test]
  570. async fn transfer_records_account_snapshots() {
  571. let ledger = setup_ledger().await;
  572. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  573. // The envelope should have account_snapshots populated by the resolve step
  574. let transfers = ledger.history(&account(1)).await.unwrap();
  575. assert_eq!(transfers.len(), 1);
  576. assert!(!transfers[0].envelope.account_snapshots().is_empty());
  577. }
  578. #[tokio::test]
  579. async fn stale_snapshot_rejected() {
  580. let ledger = setup_ledger().await;
  581. // Get current snapshot for account(1)
  582. let acc1 = ledger.get_account(&account(1)).await.unwrap();
  583. let stale_snapshot = kuatia_core::account_snapshot_id(&acc1);
  584. // Freeze account(1) -- changes its snapshot hash
  585. ledger.freeze(&account(1)).await.unwrap();
  586. // Build an envelope with the stale snapshot
  587. let envelope = EnvelopeBuilder::new()
  588. .creates(vec![
  589. NewPosting {
  590. owner: account(1),
  591. asset: usd(),
  592. value: Cent::from(100),
  593. payer: None,
  594. },
  595. NewPosting {
  596. owner: external(),
  597. asset: usd(),
  598. value: Cent::from(-100),
  599. payer: None,
  600. },
  601. ])
  602. .account_snapshots(vec![stale_snapshot])
  603. .build();
  604. let result = ledger.commit_envelope(envelope).await;
  605. assert!(result.is_err());
  606. }
  607. #[tokio::test]
  608. async fn account_hash_deterministic() {
  609. let acc = make_account(42, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
  610. let h1 = kuatia_core::account_hash(&acc);
  611. let h2 = kuatia_core::account_hash(&acc);
  612. assert_eq!(h1, h2);
  613. }
  614. #[tokio::test]
  615. async fn account_hash_changes_with_version() {
  616. let mut acc = make_account(42, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
  617. let h1 = kuatia_core::account_hash(&acc);
  618. acc.version = 2;
  619. acc.flags |= AccountFlags::FROZEN;
  620. let h2 = kuatia_core::account_hash(&acc);
  621. assert_ne!(h1, h2);
  622. }
  623. // ---------------------------------------------------------------------------
  624. // Overdraft via negative postings
  625. // ---------------------------------------------------------------------------
  626. #[tokio::test]
  627. async fn overdraft_creates_negative_posting() {
  628. let store = InMemoryStore::new();
  629. let ledger = Arc::new(Ledger::new(store));
  630. for (id, flags) in [
  631. (10, AccountFlags::empty()),
  632. (2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
  633. (99, AccountFlags::empty()),
  634. ] {
  635. ledger
  636. .store()
  637. .create_account(make_account(id, flags))
  638. .await
  639. .unwrap();
  640. }
  641. // Fund account 10 with 50, then pay 100 — overdraft covers the 50 shortfall.
  642. deposit(&ledger, account(10), usd(), Cent::from(50), external()).await;
  643. pay(&ledger, account(10), account(2), usd(), Cent::from(100)).await;
  644. assert_eq!(
  645. ledger.balance(&account(10), &usd()).await.unwrap(),
  646. Cent::from(-50)
  647. );
  648. assert_eq!(
  649. ledger.balance(&account(2), &usd()).await.unwrap(),
  650. Cent::from(100)
  651. );
  652. // A negative posting now backs the overdraft.
  653. let postings = ledger
  654. .store()
  655. .get_postings_by_account(10, None, Some(&usd()), PostingFilter::Active)
  656. .await
  657. .unwrap();
  658. assert!(postings.iter().any(|p| p.value == Cent::from(-50)));
  659. }
  660. #[tokio::test]
  661. async fn debit_must_not_exceed_credit_rejects_overspend() {
  662. let store = InMemoryStore::new();
  663. let ledger = Arc::new(Ledger::new(store));
  664. for (id, flags) in [
  665. (10, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
  666. (2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
  667. (99, AccountFlags::empty()),
  668. ] {
  669. ledger
  670. .store()
  671. .create_account(make_account(id, flags))
  672. .await
  673. .unwrap();
  674. }
  675. // Account 10 forbids overdraft, so paying 100 from an empty balance fails.
  676. let transfer = TransferBuilder::new()
  677. .pay(account(10), account(2), usd(), Cent::from(100))
  678. .build();
  679. assert!(ledger.commit(transfer).await.is_err());
  680. assert_eq!(
  681. ledger.balance(&account(10), &usd()).await.unwrap(),
  682. Cent::ZERO
  683. );
  684. }
  685. #[tokio::test]
  686. async fn overdraft_allows_arbitrary_negative() {
  687. let store = InMemoryStore::new();
  688. let ledger = Arc::new(Ledger::new(store));
  689. for (id, flags) in [
  690. (10, AccountFlags::empty()),
  691. (2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
  692. (99, AccountFlags::empty()),
  693. ] {
  694. ledger
  695. .store()
  696. .create_account(make_account(id, flags))
  697. .await
  698. .unwrap();
  699. }
  700. pay(
  701. &ledger,
  702. account(10),
  703. account(2),
  704. usd(),
  705. Cent::from(1_000_000),
  706. )
  707. .await;
  708. assert_eq!(
  709. ledger.balance(&account(10), &usd()).await.unwrap(),
  710. Cent::from(-1_000_000)
  711. );
  712. }
  713. // ---------------------------------------------------------------------------
  714. // Book policy enforcement
  715. // ---------------------------------------------------------------------------
  716. #[tokio::test]
  717. async fn book_policy_rejects_disallowed_asset() {
  718. let ledger = setup_ledger().await;
  719. // Book 5 permits only EUR.
  720. let book = BookBuilder::new("eur-only")
  721. .id(BookId::new(5))
  722. .allow_asset(eur())
  723. .build();
  724. ledger.store().create_book(book).await.unwrap();
  725. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  726. // Paying USD under a EUR-only book is rejected, balance unchanged.
  727. let transfer = TransferBuilder::new()
  728. .book(BookId::new(5))
  729. .pay(account(1), account(2), usd(), Cent::from(50))
  730. .build();
  731. assert!(ledger.commit(transfer).await.is_err());
  732. assert_eq!(
  733. ledger.balance(&account(1), &usd()).await.unwrap(),
  734. Cent::from(100)
  735. );
  736. }
  737. #[tokio::test]
  738. async fn transfer_in_missing_named_book_is_rejected() {
  739. let ledger = setup_ledger().await;
  740. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  741. let transfer = TransferBuilder::new()
  742. .book(BookId::new(404))
  743. .pay(account(1), account(2), usd(), Cent::from(50))
  744. .build();
  745. assert!(ledger.commit(transfer).await.is_err());
  746. assert_eq!(
  747. ledger.balance(&account(1), &usd()).await.unwrap(),
  748. Cent::from(100)
  749. );
  750. }
  751. // ---------------------------------------------------------------------------
  752. // Content-addressed determinism
  753. // ---------------------------------------------------------------------------
  754. #[tokio::test]
  755. async fn identical_transfers_share_envelope_id() {
  756. // Two independently-built default-book transfers must hash identically.
  757. let a = TransferBuilder::new()
  758. .pay(account(1), account(2), usd(), Cent::from(10))
  759. .build();
  760. let b = TransferBuilder::new()
  761. .pay(account(1), account(2), usd(), Cent::from(10))
  762. .build();
  763. assert_eq!(a.book, b.book, "default book must be deterministic");
  764. assert_eq!(a.book, DEFAULT_BOOK);
  765. }
  766. // ---------------------------------------------------------------------------
  767. // Subaccounts (ADR-0012)
  768. // ---------------------------------------------------------------------------
  769. #[tokio::test]
  770. async fn subaccount_balances_are_segregated() {
  771. let ledger = setup_ledger().await;
  772. let sub = AccountId::with_sub(1, 7);
  773. // A subaccount is a full account record with its own policy.
  774. ledger
  775. .store()
  776. .create_account(Account::debit_must_not_exceed_credit(sub))
  777. .await
  778. .unwrap();
  779. // Fund the main account (1, 0) and the subaccount (1, 7) independently.
  780. deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
  781. deposit(&ledger, sub, usd(), Cent::from(40), external()).await;
  782. // balance() reads exactly one subaccount and never rolls up the other.
  783. assert_eq!(
  784. ledger.balance(&account(1), &usd()).await.unwrap(),
  785. Cent::from(100)
  786. );
  787. assert_eq!(ledger.balance(&sub, &usd()).await.unwrap(), Cent::from(40));
  788. // list_subaccounts spans the base id's subaccounts, sorted.
  789. let subs = ledger.list_subaccounts(&account(1)).await.unwrap();
  790. assert_eq!(subs, vec![account(1), sub]);
  791. // balances() reports one entry per subaccount, never summed.
  792. let all = ledger.balances(&account(1), &usd(), None).await.unwrap();
  793. assert_eq!(all.len(), 2);
  794. assert_eq!(
  795. all.iter().find(|b| b.account == account(1)).unwrap().value,
  796. Cent::from(100)
  797. );
  798. assert_eq!(
  799. all.iter().find(|b| b.account == sub).unwrap().value,
  800. Cent::from(40)
  801. );
  802. // A subaccount filter restricts to that one.
  803. let just_sub = ledger.balances(&account(1), &usd(), Some(7)).await.unwrap();
  804. assert_eq!(just_sub.len(), 1);
  805. assert_eq!(just_sub[0].account, sub);
  806. assert_eq!(just_sub[0].value, Cent::from(40));
  807. }
  808. #[tokio::test]
  809. async fn pay_moves_value_between_subaccounts() {
  810. let ledger = setup_ledger().await;
  811. let sub = AccountId::with_sub(1, 7);
  812. ledger
  813. .store()
  814. .create_account(Account::debit_must_not_exceed_credit(sub))
  815. .await
  816. .unwrap();
  817. deposit(&ledger, sub, usd(), Cent::from(40), external()).await;
  818. // Move 30 from subaccount (1, 7) to the main account (1, 0).
  819. pay(&ledger, sub, account(1), usd(), Cent::from(30)).await;
  820. assert_eq!(ledger.balance(&sub, &usd()).await.unwrap(), Cent::from(10));
  821. assert_eq!(
  822. ledger.balance(&account(1), &usd()).await.unwrap(),
  823. Cent::from(30)
  824. );
  825. }
  826. #[tokio::test]
  827. async fn closed_subaccounts_drop_out_of_aggregate_reads() {
  828. let ledger = setup_ledger().await;
  829. let sub = AccountId::with_sub(1, 7);
  830. ledger
  831. .store()
  832. .create_account(Account::debit_must_not_exceed_credit(sub))
  833. .await
  834. .unwrap();
  835. // The subaccount is created then closed while empty.
  836. ledger.close(&sub).await.unwrap();
  837. // list_subaccounts and balances exclude the closed subaccount.
  838. let subs = ledger.list_subaccounts(&account(1)).await.unwrap();
  839. assert_eq!(subs, vec![account(1)]);
  840. let all = ledger.balances(&account(1), &usd(), None).await.unwrap();
  841. assert!(all.iter().all(|b| b.account != sub));
  842. }