integration.rs 27 KB

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