#accounting #sqlx #postgresql #ledger #event-streaming #double #template

sqlx-ledger

An embeddable double sided accounting ledger built on PG/SQLx

44 releases

0.11.5 Nov 26, 2024
0.11.4 Jun 21, 2024
0.11.3 Mar 6, 2024
0.11.2 Nov 10, 2023
0.5.8 Mar 31, 2023

#51 in Finance

Download history 8/week @ 2024-09-13 10/week @ 2024-09-20 16/week @ 2024-09-27 7/week @ 2024-10-04 18/week @ 2024-10-11 2/week @ 2024-10-18 153/week @ 2024-11-22 33/week @ 2024-11-29 27/week @ 2024-12-06

213 downloads per month

MIT license

125KB
3K SLoC

Sqlx-Ledger

This crate provides primitives for double sided accounting built on top of the sqlx Postgres integration.

It features:

  • Accounts can have balances on multiple journals
  • Multi currency / multi layer support
  • Template based transaction inputs for consistency
  • CEL based template interpolation
  • Event streaming to be notified of changes

The CEL interpreter is not complete but provides enough to support basic use cases. More will be added as the need arises.

To use it copy the migrations into your project and add the crate via cargo add sqlx-ledger.

Check out the docs for an example of how to use it


lib.rs:

sqlx-ledger

This crate builds on the sqlx crate to provide a set of primitives for implementing an SQL-compatible double-entry accounting system. This system is engineered specifically for dealing with money and building financial products.

Quick Start

Add and execute the migrations from the migrations directory before usage.

cp ./migrations/* <path/to/your/projects/migrations>
# in your project
cargo sqlx migrate

Here is how to initialize a ledger create a primitive template and post a transaction. This is a toy example that brings all pieces together end-to-end. Not recommended for real use.

use uuid::uuid;
use rust_decimal::Decimal;
use sqlx_ledger::{*, journal::*, account::*, tx_template::*};

async fn init_ledger(journal_id: JournalId) -> SqlxLedger {
    let pg_con =
        std::env::var("PG_CON").unwrap_or(format!("postgres://user:password@localhost:5432/pg"));
    let pool = sqlx::PgPool::connect(&pg_con).await.unwrap();
    let ledger = SqlxLedger::new(&pool);

    // Initialize the journal - all entities are constructed via builders
    let new_journal = NewJournal::builder()
        .id(journal_id)
        .description("General ledger".to_string())
        .name("Ledger")
        .build()
        .expect("Couldn't build NewJournal");

    let _ = ledger.journals().create(new_journal).await;

    // Initialize an income omnibus account
    let main_account_id = uuid!("00000000-0000-0000-0000-000000000001");
    let new_account = NewAccount::builder()
        .id(main_account_id)
        .name("Income")
        .code("Income")
        .build()
        .unwrap();

    let _ = ledger.accounts().create(new_account).await;

    // Create the trivial 'income' template
    //
    // Here are the 'parameters' that the template will require as inputs.
    let params = vec![
        ParamDefinition::builder()
            .name("sender_account_id")
            .r#type(ParamDataType::UUID)
            .build()
            .unwrap(),
        ParamDefinition::builder()
            .name("units")
            .r#type(ParamDataType::DECIMAL)
            .build()
            .unwrap()
    ];

    // The templates for the Entries that will be created as part of the transaction.
    let entries = vec![
        EntryInput::builder()
            .entry_type("'INCOME_DR'")
            // Reference the input parameters via CEL syntax
            .account_id("params.sender_account_id")
            .layer("SETTLED")
            .direction("DEBIT")
            .units("params.units")
            .currency("'BTC'")
            .build()
            .unwrap(),
        EntryInput::builder()
            .entry_type("'INCOME_CR'")
            .account_id(format!("uuid('{main_account_id}')"))
            .layer("SETTLED")
            .direction("CREDIT")
            .units("params.units")
            .currency("'BTC'")
            .build()
            .unwrap(),
    ];
    let tx_code = "GENERAL_INCOME";
    let new_template = NewTxTemplate::builder()
        .id(uuid::Uuid::new_v4())
        .code(tx_code)
        .params(params)
        .tx_input(
            // Template for the Transaction metadata.
            TxInput::builder()
                .effective("date()")
                .journal_id(format!("uuid('{journal_id}')"))
                .build()
                .unwrap(),
        )
        .entries(entries)
        .build()
        .unwrap();

    let _ = ledger.tx_templates().create(new_template).await;

    ledger
}

tokio_test::block_on(async {
    let journal_id = JournalId::from(uuid!("00000000-0000-0000-0000-000000000001"));
    let ledger = init_ledger(journal_id).await;

    // The account that is sending to the general income account
    let sender_account_id = AccountId::new();
    let sender_account = NewAccount::builder()
        .id(sender_account_id)
        .name(format!("Sender-{sender_account_id}"))
        .code(format!("Sender-{sender_account_id}"))
        .build()
        .unwrap();
    ledger.accounts().create(sender_account).await.unwrap();

    // Prepare the input parameters that the template requires
    let mut params = TxParams::new();
    params.insert("sender_account_id", sender_account_id);
    params.insert("units", Decimal::ONE);

    // Create the transaction via the template
    ledger
        .post_transaction(TransactionId::new(), "GENERAL_INCOME", Some(params))
        .await
        .unwrap();

    // Check the resulting balance
    let account_balance = ledger
        .balances()
        .find(journal_id, sender_account_id, "BTC".parse().unwrap())
        .await
        .unwrap();

    assert_eq!(account_balance.unwrap().settled(), Decimal::NEGATIVE_ONE);
});

Dependencies

~44–59MB
~1M SLoC