1use crate::account::{Account, Balance, SYSTEM_CONSUMPTION, SYSTEM_EXPIRY, SYSTEM_TOPUP};
4use crate::entry::{Entry, Transfer, TransferKind};
5use crate::hold::{Hold, HoldStatus};
6use crate::schema::{ACCOUNTS, ENTRIES, HOLDS, LOTS, TRANSFERS};
7use rustlavel_core::{Error, Json, Result};
8use rustlavel_db::{Database, Row, Transaction, Value};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::Duration;
11
12#[derive(Clone)]
14pub struct Ledger {
15 db: Database,
16 unit: String,
18}
19
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct Sweep {
23 pub holds_released: usize,
25 pub lots_expired: usize,
27 pub amount_expired: i64,
29}
30
31impl Ledger {
32 pub fn new(db: Database, unit: impl Into<String>) -> Ledger {
34 Ledger { db, unit: unit.into() }
35 }
36
37 pub fn unit(&self) -> &str {
38 &self.unit
39 }
40
41 pub async fn account(&self, owner: &str) -> Result<Account> {
47 self.ensure_account(owner).await?;
48 self.find_account(owner)
49 .await?
50 .ok_or_else(|| Error::msg(format!("could not create or find the account for {owner}")))
51 }
52
53 pub async fn balance(&self, owner: &str) -> Result<Balance> {
54 Ok(self.account(owner).await?.balance_view())
55 }
56
57 pub async fn history(&self, owner: &str, limit: usize) -> Result<Vec<Entry>> {
59 let account = self.account(owner).await?;
60 let rows = self
61 .db
62 .table(ENTRIES)
63 .filter("account_id", account.id.as_str())
64 .order_by("id", rustlavel_db::Direction::Desc)
65 .limit(limit.clamp(1, 1000) as i64)
66 .get(&self.db)
67 .await?;
68 rows.iter().map(entry_from).collect()
69 }
70
71 pub async fn transfer(&self, reference: &str) -> Result<Option<Transfer>> {
72 let row = self.db.table(TRANSFERS).filter("reference", reference).first(&self.db).await?;
73 row.as_ref().map(transfer_from).transpose()
74 }
75
76 pub async fn hold_by_reference(&self, reference: &str) -> Result<Option<Hold>> {
77 let row = self.db.table(HOLDS).filter("reference", reference).first(&self.db).await?;
78 row.as_ref().map(hold_from).transpose()
79 }
80
81 pub async fn top_up(
95 &self,
96 owner: &str,
97 amount: i64,
98 reference: &str,
99 expires_in: Option<Duration>,
100 ) -> Result<Transfer> {
101 positive(amount)?;
102 if let Some(existing) = self.transfer(reference).await? {
103 return Ok(existing);
104 }
105
106 self.ensure_account(owner).await?;
107 self.ensure_account(SYSTEM_TOPUP).await?;
108
109 let mut tx = self.db.begin().await?;
110 let to = self.account_in(&mut tx, owner).await?;
111 let from = self.account_in(&mut tx, SYSTEM_TOPUP).await?;
112 let now = now();
113
114 debit_unchecked(&mut tx, &from.id, amount).await?;
117 credit(&mut tx, &to.id, amount).await?;
118
119 let transfer = self
120 .record(&mut tx, TransferKind::TopUp, reference, &from.id, &to.id, amount, Json::Null, now)
121 .await?;
122 let Some(transfer) = transfer else {
123 tx.rollback().await?;
126 return self.transfer(reference).await?.ok_or_else(|| {
127 Error::msg("a top-up lost an idempotency race and the winner is not there")
128 });
129 };
130
131 self.db
132 .table(LOTS)
133 .insert_in(
134 &mut tx,
135 &[
136 ("lot_id", id("lot").as_str().into()),
137 ("account_id", to.id.as_str().into()),
138 ("transfer_id", transfer.id.as_str().into()),
139 ("initial", amount.into()),
140 ("remaining", amount.into()),
141 (
142 "expires_at",
143 expires_in.map_or(Value::Null, |d| Value::from((now + d.as_secs()) as i64)),
144 ),
145 ("created_at", (now as i64).into()),
146 ],
147 )
148 .await?;
149
150 tx.commit().await?;
151 Ok(transfer)
152 }
153
154 pub async fn consume(&self, owner: &str, amount: i64, reference: &str) -> Result<Transfer> {
167 positive(amount)?;
168 if let Some(existing) = self.transfer(reference).await? {
169 return Ok(existing);
170 }
171
172 self.ensure_account(owner).await?;
173 self.ensure_account(SYSTEM_CONSUMPTION).await?;
174
175 let mut tx = self.db.begin().await?;
176 let from = self.account_in(&mut tx, owner).await?;
177 let to = self.account_in(&mut tx, SYSTEM_CONSUMPTION).await?;
178
179 if !debit_available(&mut tx, &from.id, amount).await? {
180 tx.rollback().await?;
181 return Err(insufficient(owner, amount, from.available()));
182 }
183 credit(&mut tx, &to.id, amount).await?;
184 draw_lots(&mut tx, &from.id, amount).await?;
185
186 let transfer = self
187 .record(&mut tx, TransferKind::Consume, reference, &from.id, &to.id, amount, Json::Null, now())
188 .await?;
189 let Some(transfer) = transfer else {
190 tx.rollback().await?;
191 return self.transfer(reference).await?.ok_or_else(|| {
192 Error::msg("a consume lost an idempotency race and the winner is not there")
193 });
194 };
195
196 tx.commit().await?;
197 Ok(transfer)
198 }
199
200 pub async fn hold(&self, owner: &str, amount: i64, reference: &str, ttl: Duration) -> Result<Hold> {
207 positive(amount)?;
208 if let Some(existing) = self.hold_by_reference(reference).await? {
209 return Ok(existing);
210 }
211
212 self.ensure_account(owner).await?;
213
214 let mut tx = self.db.begin().await?;
215 let account = self.account_in(&mut tx, owner).await?;
216
217 let sql = format!(
219 "update {ACCOUNTS} set held = held + {amount} where account_id = {} and balance - held >= {amount}",
220 tx.dialect().placeholder(1)
221 );
222 let reserved = tx.execute(&sql, &[Value::from(account.id.as_str())]).await?;
223 if reserved == 0 {
224 tx.rollback().await?;
225 return Err(insufficient(owner, amount, account.available()));
226 }
227
228 let now = now();
229 let hold = Hold {
230 id: id("hold"),
231 account_id: account.id.clone(),
232 amount,
233 status: HoldStatus::Held,
234 reference: reference.to_string(),
235 created_at: now,
236 expires_at: now + ttl.as_secs(),
237 };
238 let inserted = self
239 .db
240 .table(HOLDS)
241 .insert_in(
242 &mut tx,
243 &[
244 ("hold_id", hold.id.as_str().into()),
245 ("account_id", hold.account_id.as_str().into()),
246 ("amount", amount.into()),
247 ("status", HoldStatus::Held.as_str().into()),
248 ("reference", reference.into()),
249 ("created_at", (now as i64).into()),
250 ("expires_at", (hold.expires_at as i64).into()),
251 ],
252 )
253 .await;
254 if inserted.is_err() {
255 tx.rollback().await?;
257 return self.hold_by_reference(reference).await?.ok_or_else(|| {
258 Error::msg("a hold lost an idempotency race and the winner is not there")
259 });
260 }
261
262 tx.commit().await?;
263 Ok(hold)
264 }
265
266 pub async fn capture(&self, hold_id: &str) -> Result<Transfer> {
269 self.ensure_account(SYSTEM_CONSUMPTION).await?;
270
271 let mut tx = self.db.begin().await?;
272
273 let won = self
276 .db
277 .table(HOLDS)
278 .filter("hold_id", hold_id)
279 .filter("status", HoldStatus::Held.as_str())
280 .update_in(&mut tx, &[("status", HoldStatus::Captured.as_str().into())])
281 .await?;
282 if won == 0 {
283 tx.rollback().await?;
284 return Err(hold_not_held(&self.db, hold_id).await);
285 }
286
287 let hold = self
288 .db
289 .table(HOLDS)
290 .filter("hold_id", hold_id)
291 .first_in(&mut tx)
292 .await?
293 .ok_or_else(|| Error::msg(format!("hold `{hold_id}` vanished mid-capture")))?;
294 let hold = hold_from(&hold)?;
295 let to = self.account_in(&mut tx, SYSTEM_CONSUMPTION).await?;
296
297 let sql = format!(
300 "update {ACCOUNTS} set balance = balance - {0}, held = held - {0} where account_id = {1}",
301 hold.amount,
302 tx.dialect().placeholder(1)
303 );
304 tx.execute(&sql, &[Value::from(hold.account_id.as_str())]).await?;
305 credit(&mut tx, &to.id, hold.amount).await?;
306 draw_lots(&mut tx, &hold.account_id, hold.amount).await?;
307
308 let transfer = self
309 .record(
310 &mut tx,
311 TransferKind::Capture,
312 &format!("capture:{}", hold.reference),
313 &hold.account_id,
314 &to.id,
315 hold.amount,
316 Json::object([("hold", Json::from(hold.id.as_str()))]),
317 now(),
318 )
319 .await?
320 .ok_or_else(|| Error::msg(format!("hold `{hold_id}` was already captured")))?;
321
322 tx.commit().await?;
323 Ok(transfer)
324 }
325
326 pub async fn release(&self, hold_id: &str) -> Result<()> {
329 let mut tx = self.db.begin().await?;
330 let released = release_in(&self.db, &mut tx, hold_id).await?;
331 if !released {
332 tx.rollback().await?;
333 return Err(hold_not_held(&self.db, hold_id).await);
334 }
335 tx.commit().await
336 }
337
338 pub async fn sweep(&self) -> Result<Sweep> {
349 self.ensure_account(SYSTEM_EXPIRY).await?;
350
351 let now = now();
352 let mut sweep = Sweep::default();
353
354 let stale_holds = self
355 .db
356 .table(HOLDS)
357 .filter("status", HoldStatus::Held.as_str())
358 .filter_op("expires_at", "<=", now as i64)
359 .get(&self.db)
360 .await?;
361 for row in &stale_holds {
362 let hold_id: String = row.get("hold_id")?;
363 let mut tx = self.db.begin().await?;
364 if release_in(&self.db, &mut tx, &hold_id).await? {
367 sweep.holds_released += 1;
368 }
369 tx.commit().await?;
370 }
371
372 let dead_lots = self
373 .db
374 .table(LOTS)
375 .filter_not_null("expires_at")
376 .filter_op("expires_at", "<=", now as i64)
377 .filter_op("remaining", ">", 0)
378 .get(&self.db)
379 .await?;
380 for row in &dead_lots {
381 let lot_id: String = row.get("lot_id")?;
382 let account_id: String = row.get("account_id")?;
383 let mut tx = self.db.begin().await?;
384
385 let claimed = self
388 .db
389 .table(LOTS)
390 .filter("lot_id", lot_id.as_str())
391 .filter_op("remaining", ">", 0)
392 .first_in(&mut tx)
393 .await?;
394 let Some(lot) = claimed else {
395 tx.rollback().await?;
396 continue;
397 };
398 let remaining: i64 = lot.get("remaining")?;
399
400 let account = self
403 .db
404 .table(ACCOUNTS)
405 .filter("account_id", account_id.as_str())
406 .first_in(&mut tx)
407 .await?
408 .map(|row| account_from(&row))
409 .transpose()?
410 .ok_or_else(|| Error::msg(format!("lot `{lot_id}` names an account that is not there")))?;
411 let amount = remaining.min(account.available()).max(0);
412
413 self.db
414 .table(LOTS)
415 .filter("lot_id", lot_id.as_str())
416 .update_in(&mut tx, &[("remaining", 0.into())])
417 .await?;
418
419 if amount > 0 {
420 let to = self.account_in(&mut tx, SYSTEM_EXPIRY).await?;
421 if debit_available(&mut tx, &account_id, amount).await? {
422 credit(&mut tx, &to.id, amount).await?;
423 self.record(
424 &mut tx,
425 TransferKind::Expire,
426 &format!("expire:{lot_id}"),
427 &account_id,
428 &to.id,
429 amount,
430 Json::object([("lot", Json::from(lot_id.as_str()))]),
431 now,
432 )
433 .await?;
434 sweep.amount_expired += amount;
435 }
436 }
437 sweep.lots_expired += 1;
438 tx.commit().await?;
439 }
440
441 Ok(sweep)
442 }
443
444 pub async fn audit(&self) -> Result<Vec<String>> {
448 let mut problems = Vec::new();
449 let accounts = self.db.table(ACCOUNTS).filter("unit", self.unit.as_str()).get(&self.db).await?;
450 let mut sum = 0i64;
451
452 for row in &accounts {
453 let account = account_from(row)?;
454 sum += account.balance;
455 let entries = self.db.table(ENTRIES).filter("account_id", account.id.as_str()).get(&self.db).await?;
456 let total: i64 = entries.iter().filter_map(|e| e.get::<i64>("amount").ok()).sum();
457 if total != account.balance {
458 problems.push(format!(
459 "{}: balance is {} but its entries sum to {total}",
460 account.owner, account.balance
461 ));
462 }
463 if account.held < 0 || account.held > account.balance.max(0) && !account.is_system() {
464 problems.push(format!("{}: held {} against balance {}", account.owner, account.held, account.balance));
465 }
466 }
467 if sum != 0 {
468 problems.push(format!("the book does not balance: all accounts sum to {sum}, not 0"));
469 }
470 Ok(problems)
471 }
472
473 async fn account_in(&self, tx: &mut Transaction, owner: &str) -> Result<Account> {
494 self.db
495 .table(ACCOUNTS)
496 .filter("owner", owner)
497 .filter("unit", self.unit.as_str())
498 .first_in(tx)
499 .await?
500 .map(|row| account_from(&row))
501 .transpose()?
502 .ok_or_else(|| {
503 Error::msg(format!(
504 "the account for {owner} does not exist inside the transaction; \
505 `ensure_account` must run before it begins"
506 ))
507 })
508 }
509
510 async fn ensure_account(&self, owner: &str) -> Result<()> {
524 let mut last_error = None;
525
526 for attempt in 0..5u64 {
527 if self.find_account(owner).await?.is_some() {
528 return Ok(());
529 }
530
531 let created = now();
532 let inserted = self
533 .db
534 .table(ACCOUNTS)
535 .insert(
536 &self.db,
537 &[
538 ("account_id", id("acc").as_str().into()),
539 ("owner", owner.into()),
540 ("unit", self.unit.as_str().into()),
541 ("balance", 0.into()),
542 ("held", 0.into()),
543 ("created_at", (created as i64).into()),
544 ],
545 )
546 .await;
547
548 match inserted {
549 Ok(_) => return Ok(()),
550 Err(error) => last_error = Some(error),
551 }
552 tokio::time::sleep(Duration::from_millis(5 * (attempt + 1))).await;
553 }
554
555 Err(last_error.unwrap_or_else(|| {
557 Error::msg(format!("could not create or find the account for {owner}"))
558 }))
559 }
560
561 async fn find_account(&self, owner: &str) -> Result<Option<Account>> {
562 self.db
563 .table(ACCOUNTS)
564 .filter("owner", owner)
565 .filter("unit", self.unit.as_str())
566 .first(&self.db)
567 .await?
568 .map(|row| account_from(&row))
569 .transpose()
570 }
571
572 #[allow(clippy::too_many_arguments)]
575 async fn record(
576 &self,
577 tx: &mut Transaction,
578 kind: TransferKind,
579 reference: &str,
580 from: &str,
581 to: &str,
582 amount: i64,
583 metadata: Json,
584 now: u64,
585 ) -> Result<Option<Transfer>> {
586 let transfer = Transfer {
587 id: id("tr"),
588 reference: reference.to_string(),
589 kind,
590 from_account: from.to_string(),
591 to_account: to.to_string(),
592 amount,
593 created_at: now,
594 metadata: metadata.clone(),
595 };
596
597 let inserted = self
598 .db
599 .table(TRANSFERS)
600 .insert_in(
601 tx,
602 &[
603 ("transfer_id", transfer.id.as_str().into()),
604 ("reference", reference.into()),
605 ("kind", kind.as_str().into()),
606 ("from_account", from.into()),
607 ("to_account", to.into()),
608 ("amount", amount.into()),
609 ("created_at", (now as i64).into()),
610 ("metadata", if metadata.is_null() { Value::Null } else { Value::from(metadata) }),
611 ],
612 )
613 .await;
614 if inserted.is_err() {
615 return Ok(None);
616 }
617
618 for (account_id, signed) in [(from, -amount), (to, amount)] {
619 let after = balance_of(tx, account_id).await?;
620 self.db
621 .table(ENTRIES)
622 .insert_in(
623 tx,
624 &[
625 ("transfer_id", transfer.id.as_str().into()),
626 ("account_id", account_id.into()),
627 ("amount", signed.into()),
628 ("balance_after", after.into()),
629 ("kind", kind.as_str().into()),
630 ("reference", reference.into()),
631 ("created_at", (now as i64).into()),
632 ],
633 )
634 .await?;
635 }
636 Ok(Some(transfer))
637 }
638}
639
640async fn debit_available(tx: &mut Transaction, account_id: &str, amount: i64) -> Result<bool> {
642 let sql = format!(
643 "update {ACCOUNTS} set balance = balance - {amount} where account_id = {} and balance - held >= {amount}",
644 tx.dialect().placeholder(1)
645 );
646 Ok(tx.execute(&sql, &[Value::from(account_id)]).await? == 1)
647}
648
649async fn debit_unchecked(tx: &mut Transaction, account_id: &str, amount: i64) -> Result<()> {
652 let sql = format!(
653 "update {ACCOUNTS} set balance = balance - {amount} where account_id = {}",
654 tx.dialect().placeholder(1)
655 );
656 tx.execute(&sql, &[Value::from(account_id)]).await?;
657 Ok(())
658}
659
660async fn credit(tx: &mut Transaction, account_id: &str, amount: i64) -> Result<()> {
661 let sql = format!(
662 "update {ACCOUNTS} set balance = balance + {amount} where account_id = {}",
663 tx.dialect().placeholder(1)
664 );
665 tx.execute(&sql, &[Value::from(account_id)]).await?;
666 Ok(())
667}
668
669async fn balance_of(tx: &mut Transaction, account_id: &str) -> Result<i64> {
670 let sql = format!("select balance from {ACCOUNTS} where account_id = {}", tx.dialect().placeholder(1));
671 tx.scalar::<i64>(&sql, &[Value::from(account_id)])
672 .await?
673 .ok_or_else(|| Error::msg(format!("account `{account_id}` is not there")))
674}
675
676async fn draw_lots(tx: &mut Transaction, account_id: &str, mut amount: i64) -> Result<()> {
682 let placeholder = tx.dialect().placeholder(1);
683 let sql = format!(
685 "select lot_id, remaining, expires_at from {LOTS} where account_id = {placeholder} and remaining > 0"
686 );
687 let mut lots = tx.select(&sql, &[Value::from(account_id)]).await?;
688 lots.sort_by_key(|row| (row.get::<i64>("expires_at").ok().is_none(), row.get::<i64>("expires_at").unwrap_or(i64::MAX)));
689
690 for lot in lots {
691 if amount <= 0 {
692 break;
693 }
694 let lot_id: String = lot.get("lot_id")?;
695 let remaining: i64 = lot.get("remaining")?;
696 let take = remaining.min(amount);
697 let sql = format!(
698 "update {LOTS} set remaining = remaining - {take} where lot_id = {} and remaining >= {take}",
699 tx.dialect().placeholder(1)
700 );
701 if tx.execute(&sql, &[Value::from(lot_id.as_str())]).await? == 1 {
702 amount -= take;
703 }
704 }
705 Ok(())
708}
709
710async fn release_in(db: &Database, tx: &mut Transaction, hold_id: &str) -> Result<bool> {
712 let won = db
713 .table(HOLDS)
714 .filter("hold_id", hold_id)
715 .filter("status", HoldStatus::Held.as_str())
716 .update_in(tx, &[("status", HoldStatus::Released.as_str().into())])
717 .await?;
718 if won == 0 {
719 return Ok(false);
720 }
721 let hold = db
722 .table(HOLDS)
723 .filter("hold_id", hold_id)
724 .first_in(tx)
725 .await?
726 .ok_or_else(|| Error::msg(format!("hold `{hold_id}` vanished mid-release")))?;
727 let hold = hold_from(&hold)?;
728 let sql = format!(
729 "update {ACCOUNTS} set held = held - {} where account_id = {}",
730 hold.amount,
731 tx.dialect().placeholder(1)
732 );
733 tx.execute(&sql, &[Value::from(hold.account_id.as_str())]).await?;
734 Ok(true)
735}
736
737async fn hold_not_held(db: &Database, hold_id: &str) -> Error {
738 match db.table(HOLDS).filter("hold_id", hold_id).first(db).await {
739 Ok(Some(row)) => {
740 let status = row.get::<String>("status").unwrap_or_default();
741 Error::msg(format!("hold `{hold_id}` is already {status}; a hold is captured or released once"))
742 }
743 _ => Error::msg(format!("no hold `{hold_id}`")),
744 }
745}
746
747fn insufficient(owner: &str, wanted: i64, available: i64) -> Error {
748 Error::msg(format!("{owner} has {available} available and {wanted} was asked for"))
749}
750
751fn positive(amount: i64) -> Result<()> {
752 if amount <= 0 {
753 return Err(Error::msg(format!("an amount must be above zero; {amount} is not")));
754 }
755 Ok(())
756}
757
758fn now() -> u64 {
759 std::time::SystemTime::now()
760 .duration_since(std::time::UNIX_EPOCH)
761 .map_or(0, |d| d.as_secs())
762}
763
764fn id(prefix: &str) -> String {
767 static COUNTER: AtomicU64 = AtomicU64::new(0);
768 let nanos = std::time::SystemTime::now()
769 .duration_since(std::time::UNIX_EPOCH)
770 .map_or(0, |d| d.as_nanos());
771 format!("{prefix}_{nanos:x}{:x}{:x}", COUNTER.fetch_add(1, Ordering::Relaxed), std::process::id())
772}
773
774fn account_from(row: &Row) -> Result<Account> {
775 Ok(Account {
776 id: row.get("account_id")?,
777 owner: row.get("owner")?,
778 unit: row.get("unit")?,
779 balance: row.get("balance")?,
780 held: row.get("held")?,
781 created_at: row.get::<i64>("created_at")?.max(0) as u64,
782 })
783}
784
785fn transfer_from(row: &Row) -> Result<Transfer> {
786 Ok(Transfer {
787 id: row.get("transfer_id")?,
788 reference: row.get("reference")?,
789 kind: TransferKind::parse(&row.get::<String>("kind")?)
790 .ok_or_else(|| Error::msg("a transfer row holds a kind this crate does not know"))?,
791 from_account: row.get("from_account")?,
792 to_account: row.get("to_account")?,
793 amount: row.get("amount")?,
794 created_at: row.get::<i64>("created_at")?.max(0) as u64,
795 metadata: row.get::<Json>("metadata").unwrap_or(Json::Null),
796 })
797}
798
799fn entry_from(row: &Row) -> Result<Entry> {
800 Ok(Entry {
801 id: row.get("id")?,
802 transfer_id: row.get("transfer_id")?,
803 account_id: row.get("account_id")?,
804 amount: row.get("amount")?,
805 balance_after: row.get("balance_after")?,
806 kind: TransferKind::parse(&row.get::<String>("kind")?)
807 .ok_or_else(|| Error::msg("an entry row holds a kind this crate does not know"))?,
808 reference: row.get("reference")?,
809 created_at: row.get::<i64>("created_at")?.max(0) as u64,
810 })
811}
812
813fn hold_from(row: &Row) -> Result<Hold> {
814 Ok(Hold {
815 id: row.get("hold_id")?,
816 account_id: row.get("account_id")?,
817 amount: row.get("amount")?,
818 status: HoldStatus::parse(&row.get::<String>("status")?)
819 .ok_or_else(|| Error::msg("a hold row holds a status this crate does not know"))?,
820 reference: row.get("reference")?,
821 created_at: row.get::<i64>("created_at")?.max(0) as u64,
822 expires_at: row.get::<i64>("expires_at")?.max(0) as u64,
823 })
824}