integration.rs 30 KB

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