Skip to main content

rustlavel_ledger/
ledger.rs

1//! The operations. Every one is a single transaction.
2
3use 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/// The book.
13#[derive(Clone)]
14pub struct Ledger {
15    db: Database,
16    /// What the accounts count. One ledger per unit; two units are two books.
17    unit: String,
18}
19
20/// What a sweep did.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct Sweep {
23    /// Holds past their lifetime that were released.
24    pub holds_released: usize,
25    /// Lots past their expiry whose remainder was moved to `system:expiry`.
26    pub lots_expired: usize,
27    /// The total moved to expiry.
28    pub amount_expired: i64,
29}
30
31impl Ledger {
32    /// A ledger counting `unit` — `"credits"`, `"IDR"`.
33    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    // ------------------------------------------------------------------
42    // Reading
43    // ------------------------------------------------------------------
44
45    /// The owner's account, created if this is the first time it is asked for.
46    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    /// The most recent movements on an account, newest first.
58    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    // ------------------------------------------------------------------
82    // Money in
83    // ------------------------------------------------------------------
84
85    /// Credit an owner, from `system:topup`.
86    ///
87    /// **Idempotent on `reference`.** Call it twice with the same reference —
88    /// a payment webhook that was retried — and the second call returns the
89    /// first transfer and moves nothing. The unique index on the reference is
90    /// what enforces this, so it holds under concurrency too.
91    ///
92    /// `expires_in` gives the credits a lifetime. Consumption draws from the
93    /// batch that expires first.
94    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        // System accounts may go negative — that is what they are for. Every
115        // credit in the book is a debit here, and the sum is zero.
116        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            // Lost the race to another call with the same reference. Undo ours
124            // and hand back theirs.
125            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    // ------------------------------------------------------------------
155    // Money out
156    // ------------------------------------------------------------------
157
158    /// Spend, immediately. Fails — and moves nothing — when `available` is
159    /// short.
160    ///
161    /// The debit's `WHERE balance - held >= amount` is both the check and the
162    /// lock: two calls racing for the last credit reach the database, the row
163    /// lock serialises them, and the second sees the balance the first left.
164    /// There is no read before the write, because the gap between them is
165    /// where a double-spend lives.
166    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    /// Reserve, for a job that will cost this much and take a while.
201    ///
202    /// `available` drops by the amount; `balance` does not. The hold lasts
203    /// `ttl`; after that [`Ledger::sweep`] releases it, so a job that died
204    /// without saying so does not keep credits reserved forever. Idempotent
205    /// on `reference`.
206    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        // The same guard as a debit, on `held` rather than `balance`.
218        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            // Somebody else held under this reference first. Theirs stands.
256            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    /// Spend a hold. Exactly once: a second capture of the same hold is an
267    /// error, not a second charge.
268    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        // The compare-and-set. One row means this call moved it from `held`;
274        // zero means it was already captured or released, or never existed.
275        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        // The hold covered the amount, so the balance is there by
298        // construction; this moves it and lets the reservation go together.
299        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    /// Give a hold back. Exactly once, like capture; nothing is written to the
327    /// history because nothing moved.
328    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    // ------------------------------------------------------------------
339    // Housekeeping
340    // ------------------------------------------------------------------
341
342    /// Release holds past their lifetime and expire lots past theirs.
343    ///
344    /// Run it on a schedule. An expired lot's remainder goes to
345    /// `system:expiry` as an `Expire` transfer, so the history says where the
346    /// credits went; an expired hold is released and nothing is written, as
347    /// with any release.
348    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            // Somebody may have captured it between the select and here; the
365            // compare-and-set inside says so and this moves on.
366            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            // Claim the lot first — the same compare-and-set shape. Re-read the
386            // remainder inside the claim so a consume that raced us is seen.
387            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            // The owner may have spent more than the lot since; expire only
401            // what is genuinely still there.
402            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    /// Whether the book balances: every account's `balance` equals the sum of
445    /// its entries, and the sum of every balance is zero. Returns what is
446    /// wrong, or nothing.
447    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    // ------------------------------------------------------------------
474    // Internals
475    // ------------------------------------------------------------------
476
477    /// The account, read inside the caller's transaction.
478    ///
479    /// **It does not create it, and that is the point.** Creating an account
480    /// inside the transaction that is about to use it is a create-or-find under
481    /// concurrency, and the two databases that disagree about it disagree in
482    /// opposite directions. PostgreSQL aborts the whole transaction on the
483    /// duplicate-key error, which a savepoint papered over. MySQL does something
484    /// worse: a transaction's first read fixes its snapshot, so after losing the
485    /// insert the re-read cannot see the row the winner just committed, however
486    /// plainly the unique index says it exists — and InnoDB can deadlock the
487    /// competing inserts, which rolls the *entire* transaction back and takes
488    /// the savepoint with it.
489    ///
490    /// So every operation calls [`Ledger::ensure_account`] *before* it begins,
491    /// where a failure ends one statement and nothing else, and by the time the
492    /// transaction takes its snapshot the row is committed and visible.
493    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    /// Make sure the owner has an account, committed, before any transaction
511    /// that will use it begins.
512    ///
513    /// Not inside a transaction on purpose — see [`Ledger::account_in`]. Each
514    /// statement here stands alone, so losing the race to insert (a duplicate
515    /// key, or a deadlock between racing inserts on MySQL) ends that statement
516    /// and nothing else; the winner's row is then a committed fact that a fresh
517    /// statement can read.
518    ///
519    /// A few attempts, not one: the loser of a deadlock can read before the
520    /// winner has committed, and the honest answer to "not there yet" is to ask
521    /// again in a moment rather than to report a failure that the next read
522    /// would have disproved.
523    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        // Five reads and five inserts, and still no row: this is not a race.
556        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    /// Write the transfer and its two entries. `None` if the reference is
573    /// taken — the caller rolls back and returns the winner.
574    #[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
640/// Debit an account only if `available` covers it. `true` when it did.
641async 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
649/// Debit with no floor. For system accounts only, which are allowed to go
650/// negative: that is where the other side of every credit lives.
651async 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
676/// Take `amount` out of the account's lots, earliest expiry first.
677///
678/// Runs after the account row has been debited with its guard, which is also
679/// the row lock — so two consumes on one account do this one at a time and
680/// the second sees the first's decrements.
681async fn draw_lots(tx: &mut Transaction, account_id: &str, mut amount: i64) -> Result<()> {
682    let placeholder = tx.dialect().placeholder(1);
683    // Nulls (never expires) last: spend the credits that will die first.
684    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    // Credits with no lot — adjustments, or a book that predates lots — are
706    // simply consumed; a shortfall here is not a shortfall of balance.
707    Ok(())
708}
709
710/// Release a hold if it is still held. `true` when this call did it.
711async 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
764/// Ids that sort by time and never collide within a process: time, a
765/// counter, the pid. Not secrets, so not from the CSPRNG.
766fn 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}