integration.rs 30 KB

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