| name | rust-fintech |
| description | | Use when this capability is needed. |
Quick Navigation
Rust Financial & FinTech Systems Guide
Best practices for building accurate, compliant, and deterministic financial systems in Rust.
Core Rules & Constraints
1. Zero Floating-Point Tolerance
Never use floats (f32, f64) to represent monetary values due to rounding errors and precision loss. Instead, use rust_decimal::Decimal.
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
let price: f64 = 19.99;
let price: Decimal = dec!(19.99);
2. Money Value Object
Encapsulate currency and amount into a single type-safe domain boundary. Prevent addition or arithmetic operations between mismatched currencies.
use rust_decimal::Decimal;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Currency {
USD,
EUR,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Money {
amount: Decimal,
currency: Currency,
}
impl Money {
pub fn new(amount: Decimal, currency: Currency) -> Self {
Self { amount, currency }
}
pub fn add(&self, other: &Money) -> Result<Money, &'static str> {
if self.currency != other.currency {
return Err("Currency mismatch");
}
let new_amount = self.amount.checked_add(other.amount)
.ok_or("Arithmetic overflow")?;
Ok(Money::new(new_amount, self.currency.clone()))
}
}
3. Double-Entry Integrity
Ensure that entries balance exactly: the sum of debits must equal the sum of credits.
pub struct LedgerEntry {
pub account_id: String,
pub amount: Decimal,
}
pub struct Transaction {
pub id: uuid::Uuid,
pub entries: Vec<LedgerEntry>,
}
impl Transaction {
pub fn validate(&self) -> Result<(), &'static str> {
let sum: Decimal = self.entries.iter().map(|e| e.amount).sum();
if sum.is_zero() {
Ok(())
} else {
Err("Transaction is unbalanced: total sum of entries must equal zero")
}
}
}
4. Immutable Event Sourcing
Ledger entries must be append-only and immutable. Rather than modifying states in-place, record discrete event entries over time.
pub enum AccountEvent {
Deposited { amount: Decimal, timestamp: chrono::DateTime<chrono::Utc> },
Withdrawn { amount: Decimal, timestamp: chrono::DateTime<chrono::Utc> },
}
pub struct Account {
id: String,
balance: Decimal,
}
impl Account {
pub fn apply(&mut self, event: &AccountEvent) {
match event {
AccountEvent::Deposited { amount, .. } => self.balance += amount,
AccountEvent::Withdrawn { amount, .. } => self.balance -= amount,
}
}
}
5. Safe Math
Always use checked, wrapping, or saturating arithmetic methods rather than standard operators if there's any chance of input overflow.
checked_add / checked_sub
saturating_add / saturating_sub
Financial Correctness Rules
Money Representation
Prefer integer minor units for ledgers and settlement. Use decimal types for pricing, rates, tax, and calculations where scale must be preserved.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AmountMinor(i64);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Currency { Usd, Eur, Gbp }
Ledger Invariants
Every posted transaction must be:
- balanced per currency;
- immutable after posting;
- idempotent at external boundaries;
- traceable to actor/request/source;
- reversible through compensating entries, not mutation.
Idempotency Pattern
pub struct IdempotencyKey(String);
pub async fn post_payment(req: PaymentRequest, key: IdempotencyKey) -> Result<Receipt, Error> {
todo_domain_example()
}
Decimal/Rounding Rules
- Never use floats for money.
- Round exactly once at the domain-approved boundary.
- Store the rounding policy with calculated results when audit matters.
- Keep FX rates with source, timestamp, precision, and effective interval.
FX Conversion Pattern
pub struct FxRate {
pub base: Currency,
pub quote: Currency,
pub rate: Decimal,
pub source: &'static str,
pub fetched_at: chrono::DateTime<chrono::Utc>,
}
impl FxRate {
pub fn convert(&self, amount: Decimal) -> Result<Decimal, &'static str> {
(amount * self.rate).round_dp(4).checked_sub(Decimal::ZERO)
.ok_or("FX conversion overflow")
}
}
pub struct FxProvider {
rates: HashMap<(Currency, Currency), FxRate>,
}
impl FxProvider {
pub fn convert(&self, from: Currency, to: Currency, amount: Decimal) -> Result<Money, FxError> {
if from == to {
return Ok(Money::new(amount, from));
}
let rate = self.rates.get(&(from, to))
.ok_or(FxError::RateNotFound { from, to })?;
if rate.fetched_at + chrono::Duration::() < chrono::Utc::() {
(FxError::StaleRate);
}
= amount * rate.rate;
(Money::(converted.(), to))
}
}
Fee Calculation
pub enum FeeStructure {
Flat { amount: Decimal },
Percentage { rate: Decimal, min: Option<Decimal>, max: Option<Decimal> },
Tiered { tiers: Vec<(Decimal, Decimal)> },
}
impl FeeStructure {
pub fn calculate(&self, amount: Decimal) -> Result<Decimal, FeeError> {
match self {
FeeStructure::Flat { amount: fee } => Ok(*fee),
FeeStructure::Percentage { rate, min, max } => {
let raw = (amount * rate).round_dp(2);
let bounded = match (min, max) {
(Some(lo), _) if raw < *lo => *lo,
(_, Some(hi)) if raw > *hi => *hi,
_ => raw,
};
Ok(bounded)
}
FeeStructure::Tiered { tiers } => {
for (threshold, rate) in tiers.iter().rev() {
if amount >= *threshold {
return Ok((amount * rate).round_dp(2));
}
}
Ok(Decimal::ZERO)
}
}
}
}
Interest Calculation
pub fn compound_interest(
principal: Decimal,
annual_rate: Decimal,
periods: u32,
compounds_per_year: u32,
) -> Result<Decimal, &'static str> {
let rate_per_period = annual_rate / Decimal::from(compounds_per_year);
let total_periods = periods * compounds_per_year;
let mut balance = principal;
for _ in 0..total_periods {
let interest = (balance * rate_per_period).round_dp(2);
balance = balance.checked_add(interest)
.ok_or("Interest calculation overflow")?;
}
Ok(balance - principal)
}
Webhook Signature Verification
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
pub fn verify_webhook_signature(
payload: &[u8],
signature: &str,
secret: &[u8],
) -> Result<(), WebhookError> {
let expected = hex::decode(signature)
.map_err(|_| WebhookError::InvalidSignature)?;
let mut mac = HmacSha256::new_from_slice(secret)
.map_err(|_| WebhookError::InvalidSecret)?;
mac.update(payload);
mac.verify_slice(&expected)
.map_err(|_| WebhookError::SignatureMismatch)
}
Testing Strategy
- Property test ledger balance invariants.
- Test duplicate idempotency keys.
- Test reversal entries sum to zero.
- Test currency mismatch errors.
- Snapshot audit records for required fields.
- Integration test database constraints.
- Test rounding at domain boundaries with known trap values.
- Test FX rate staleness detection.
Anti-Patterns
- Updating account balances without immutable entries.
- Storing money as JSON floats.
- Retrying non-idempotent payment operations.
- Combining currencies in one integer total.
- Hiding rounding in display formatting.
- Using local time for settlement boundaries.
- Expiring idempotency keys too soon (before settlement completes).
- Trusting client-provided FX rates without validation.
Review Prompt
For fintech Rust, verify numeric representation, ledger immutability, idempotency, audit trail completeness, database constraints, and property tests for invariants.
Production Checklist
- Money never uses binary floating point.
- Ledger updates are append-only and transactionally balanced.
- Idempotency keys protect retries at every external boundary.
- Audit records include actor, request/source, timestamp, and correlation ID.
- Rounding policy is explicit and tested at domain boundaries.
- Reversals use compensating entries instead of mutation.
- FX rates have source, timestamp, and staleness checks.
- Webhook signatures are verified with timing-safe comparison.
- All arithmetic uses checked operations.
- Test suite covers currency mismatch, overflow, and rounding edge cases.
References
Source: adxptived/Rust-Skills — distributed by TomeVault.