├── .envrc ├── ledger ├── .gitignore ├── src │ ├── account │ │ ├── new_account.rs │ │ ├── mod.rs │ │ ├── sql │ │ │ └── update-account.sql │ │ ├── entity.rs │ │ └── repo.rs │ ├── balance │ │ ├── mod.rs │ │ └── entity.rs │ ├── journal │ │ ├── mod.rs │ │ ├── repo.rs │ │ └── entity.rs │ ├── transaction │ │ ├── mod.rs │ │ ├── entity.rs │ │ └── repo.rs │ ├── entry │ │ ├── mod.rs │ │ ├── entity.rs │ │ └── repo.rs │ ├── tx_template │ │ ├── mod.rs │ │ ├── cel_context.rs │ │ ├── tx_params.rs │ │ ├── repo.rs │ │ ├── param_definition.rs │ │ ├── core.rs │ │ └── entity.rs │ ├── macros.rs │ ├── error.rs │ ├── primitives.rs │ ├── ledger │ │ └── mod.rs │ └── lib.rs ├── tests │ ├── helpers.rs │ ├── account.rs │ ├── tx_template.rs │ └── post_transactions.rs ├── .sqlx │ ├── query-9e79709362bef4af7392f7cd241ba25755874dcd529542e64ddf7ff141c38b58.json │ ├── query-19ff1c916c204f57f6bfcf0f72570ca6d41602ead734b4a65e11de95b9ab7c7f.json │ ├── query-66eeb7290707e1e3e39c38e6ffd2d45b5baf6162aa2dccd203de6f8d31cbd259.json │ ├── query-63b1b637705d0ab703a902f275e3c01adc9b097941da3f02d0ba077c8401658b.json │ ├── query-77599d77a146babd748cd8ebbd10c89fbb9a5aa3109b6fd0d326d685aecad587.json │ ├── query-6e82fe7ca0798715e4316b5acf6a3ccfbe0326ce3f5f3cd238ccad0f641cbe4e.json │ ├── query-d7e7415d2c9b59e503b1ac93de8bdfbe9083e1cf4a69d2ebe8636c92b882176a.json │ ├── query-c7f4751f63df559566362c0d251f9833a1ce2fd44bad7e61ba04f8e4526e1e73.json │ ├── query-28b2079d3422953e6dc7cf7c3a1ab1e304568264e004dec9b8b8cc306dfaee66.json │ ├── query-97e0b4c922ce1927e58ed4836ee5f403d6ca6274a0d7816a8a2979943159c062.json │ ├── query-d55823be440da6ca9e60d94299dfa3a4f1df8e71d1459e7889c5cc2450cec803.json │ ├── query-b0c8cdc4866bee576bd760d04693a3e4cc83ddc151aab9f2eb1952bfa5e29cab.json │ ├── query-bf2e1148f97897ad3ec3ffc607d849a381c14f64e2e8fc7ce6396933f99ed980.json │ └── query-8362c8aebe79065e4f4559e8a8142e5653f3ddd3e98222a878b1dda2d3dd12a6.json └── Cargo.toml ├── typos.toml ├── .gitignore ├── cel-parser ├── build.rs ├── src │ ├── main.rs │ ├── lib.rs │ ├── ast.rs │ └── cel.lalrpop └── Cargo.toml ├── rust-toolchain.toml ├── .cargo └── audit.toml ├── Cargo.toml ├── docker-compose.override.yml ├── ci ├── tasks │ ├── github-release.sh │ ├── publish-to-crates.sh │ ├── set-dev-version.sh │ ├── update-repo.sh │ └── test-integration.sh ├── vendor │ ├── tasks │ │ ├── rust-check-code.sh │ │ ├── docker-prep-docker-build-env.sh │ │ ├── helpers.sh │ │ ├── docker-bump-image-digest.sh │ │ ├── test-on-docker-host.sh │ │ └── prep-release-src.sh │ └── config │ │ └── git-cliff.toml ├── vendir.lock.yml ├── repipe ├── values.yml ├── vendir.yml ├── pipeline.yml └── build │ └── pipeline.yml ├── cel-interpreter ├── src │ ├── cel_type.rs │ ├── lib.rs │ ├── error.rs │ ├── builtins.rs │ ├── context.rs │ ├── value.rs │ └── interpreter.rs └── Cargo.toml ├── migrations ├── 20221109221657_sqlx_ledger_setup.down.sql └── 20221109221657_sqlx_ledger_setup.up.sql ├── .github ├── workflows │ ├── test-integration.yml │ ├── vendor │ │ ├── rust-check-code.yml │ │ ├── rust-audit.yml │ │ └── spelling.yml │ ├── audit.yml │ ├── spelling.yml │ └── check-code.yml └── dependabot.yml ├── docker-compose.yml ├── README.md ├── Makefile ├── flake.nix ├── flake.lock └── CHANGELOG.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /ledger/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /ledger/src/account/new_account.rs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /typos.toml: -------------------------------------------------------------------------------- 1 | [files] 2 | extend-exclude = ["CHANGELOG.md"] 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .envrc 3 | 4 | Cargo.lock 5 | 6 | .direnv/ 7 | -------------------------------------------------------------------------------- /cel-parser/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | lalrpop::process_root().unwrap(); 3 | } 4 | -------------------------------------------------------------------------------- /cel-parser/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | println!("Hello, world!"); 3 | } 4 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | profile = "default" 4 | -------------------------------------------------------------------------------- /.cargo/audit.toml: -------------------------------------------------------------------------------- 1 | [advisories] 2 | ignore = ["RUSTSEC-2020-0071", "RUSTSEC-2023-0071"] 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "cel-parser", 4 | "cel-interpreter", 5 | "ledger" 6 | ] 7 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | postgres: 4 | ports: 5 | - "5432:5432" 6 | -------------------------------------------------------------------------------- /ledger/src/account/mod.rs: -------------------------------------------------------------------------------- 1 | //! [Account] holds a balance in a [Journal](crate::journal::Journal) 2 | mod entity; 3 | mod repo; 4 | 5 | pub use entity::*; 6 | pub use repo::*; 7 | -------------------------------------------------------------------------------- /ledger/src/balance/mod.rs: -------------------------------------------------------------------------------- 1 | //! [AccountBalance] and [BalanceDetails] are segregated per journal and currency. 2 | mod entity; 3 | mod repo; 4 | 5 | pub use entity::*; 6 | pub use repo::*; 7 | -------------------------------------------------------------------------------- /ci/tasks/github-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | mkdir artifacts/binaries 6 | mv x86_64-unknown-linux-musl/* artifacts/binaries 7 | mv x86_64-apple-darwin/* artifacts/binaries 8 | -------------------------------------------------------------------------------- /ledger/src/journal/mod.rs: -------------------------------------------------------------------------------- 1 | //! [Journal]s are ledgers holding transactions and entries that must always be balanced. 2 | mod entity; 3 | mod repo; 4 | 5 | pub use entity::*; 6 | pub use repo::*; 7 | -------------------------------------------------------------------------------- /ledger/src/transaction/mod.rs: -------------------------------------------------------------------------------- 1 | //! A [Transaction] holds metadata and is referenced by its [Entries](crate::entry::Entry). 2 | mod entity; 3 | mod repo; 4 | 5 | pub use entity::*; 6 | pub use repo::*; 7 | -------------------------------------------------------------------------------- /ledger/src/entry/mod.rs: -------------------------------------------------------------------------------- 1 | //! [Entries](Entry) represent discrete changes in the ledger. Grouped as [Transaction](crate::transaction::Transaction)s 2 | mod entity; 3 | mod repo; 4 | 5 | pub use entity::*; 6 | pub use repo::*; 7 | -------------------------------------------------------------------------------- /ci/vendor/tasks/rust-check-code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #! Auto synced from Shared CI Resources repository 4 | #! Don't change this file, instead change it in github.com/GaloyMoney/concourse-shared 5 | 6 | set -eu 7 | 8 | pushd repo 9 | 10 | make check-code 11 | -------------------------------------------------------------------------------- /cel-parser/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(feature = "fail-on-warnings", deny(warnings))] 2 | #![cfg_attr(feature = "fail-on-warnings", deny(clippy::all))] 3 | 4 | use lalrpop_util::lalrpop_mod; 5 | 6 | pub mod ast; 7 | 8 | pub use ast::*; 9 | 10 | lalrpop_mod!(#[allow(clippy::all)] pub parser, "/cel.rs"); 11 | -------------------------------------------------------------------------------- /ledger/tests/helpers.rs: -------------------------------------------------------------------------------- 1 | pub async fn init_pool() -> anyhow::Result { 2 | let pg_host = std::env::var("PG_HOST").unwrap_or("localhost".to_string()); 3 | let pg_con = format!("postgres://user:password@{pg_host}:5432/pg"); 4 | let pool = sqlx::PgPool::connect(&pg_con).await?; 5 | Ok(pool) 6 | } 7 | -------------------------------------------------------------------------------- /cel-interpreter/src/cel_type.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 2 | pub enum CelType { 3 | // Builtins 4 | Map, 5 | List, 6 | Int, 7 | UInt, 8 | Double, 9 | String, 10 | Bytes, 11 | Bool, 12 | Null, 13 | 14 | // Addons 15 | Date, 16 | Uuid, 17 | Decimal, 18 | } 19 | -------------------------------------------------------------------------------- /ci/tasks/publish-to-crates.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | pushd repo 6 | 7 | cat < CelContext { 4 | let mut ctx = CelContext::new(); 5 | ctx.add_variable("SETTLED", "SETTLED"); 6 | ctx.add_variable("PENDING", "PENDING"); 7 | ctx.add_variable("ENCUMBERED", "ENCUMBERED"); 8 | ctx.add_variable("DEBIT", "DEBIT"); 9 | ctx.add_variable("CREDIT", "CREDIT"); 10 | ctx 11 | } 12 | -------------------------------------------------------------------------------- /ci/vendor/tasks/docker-prep-docker-build-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #! Auto synced from Shared CI Resources repository 4 | #! Don't change this file, instead change it in github.com/GaloyMoney/concourse-shared 5 | 6 | if [[ -f version/version ]]; then 7 | echo "VERSION=$(cat version/version)" >> repo/.env 8 | fi 9 | 10 | echo "COMMITHASH=$(cat repo/.git/ref)" >> repo/.env 11 | echo "BUILDTIME=$(date -u '+%F-%T')" >> repo/.env 12 | -------------------------------------------------------------------------------- /ci/vendir.lock.yml: -------------------------------------------------------------------------------- 1 | apiVersion: vendir.k14s.io/v1alpha1 2 | directories: 3 | - contents: 4 | - git: 5 | commitTitle: 'chore: add webhook config' 6 | sha: 9d0f008e41df2f5d5e0461171c02fc0c4aee1d6f 7 | path: . 8 | path: ../.github/workflows/vendor 9 | - contents: 10 | - git: 11 | commitTitle: 'chore: add webhook config' 12 | sha: 9d0f008e41df2f5d5e0461171c02fc0c4aee1d6f 13 | path: . 14 | path: ./vendor 15 | kind: LockConfig 16 | -------------------------------------------------------------------------------- /.github/workflows/vendor/rust-check-code.yml: -------------------------------------------------------------------------------- 1 | #! Auto synced from Shared CI Resources repository 2 | #! Don't change this file, instead change it in github.com/GaloyMoney/concourse-shared 3 | 4 | name: Check Code 5 | 6 | on: 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | check-code: 12 | name: Check Code 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Run check code 17 | run: make check-code 18 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | #! Auto synced from Shared CI Resources repository 2 | #! Don't change this file, instead change it in github.com/GaloyMoney/concourse-shared 3 | 4 | name: Audit 5 | 6 | on: 7 | pull_request: 8 | branches: [ main ] 9 | 10 | jobs: 11 | security_audit: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions-rs/audit-check@v1 16 | with: 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/vendor/rust-audit.yml: -------------------------------------------------------------------------------- 1 | #! Auto synced from Shared CI Resources repository 2 | #! Don't change this file, instead change it in github.com/GaloyMoney/concourse-shared 3 | 4 | name: Audit 5 | 6 | on: 7 | pull_request: 8 | branches: [ main ] 9 | 10 | jobs: 11 | security_audit: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions-rs/audit-check@v1 16 | with: 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /ci/repipe: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ $(which ytt) == "" ]]; then 6 | echo "You will need to install ytt to repipe. https://carvel.dev/ytt/" 7 | exit 1 8 | fi 9 | 10 | target="${FLY_TARGET:-ciblink}" 11 | team=dev 12 | 13 | BUILDDIR="./ci/build" 14 | mkdir -p ${BUILDDIR} 15 | 16 | ytt -f ci > ${BUILDDIR}/pipeline.yml 17 | 18 | echo "Updating pipeline @ ${target}" 19 | 20 | fly -t ${target} set-pipeline --team=${team} -p sqlx-ledger -c ${BUILDDIR}/pipeline.yml 21 | fly -t ${target} unpause-pipeline --team=${team} -p sqlx-ledger 22 | -------------------------------------------------------------------------------- /cel-parser/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sqlx-ledger-cel-parser" 3 | description = "A parser for the Common Expression Language (CEL)" 4 | repository = "https://github.com/GaloyMoney/sqlx-ledger" 5 | version = "0.11.12-dev" 6 | authors = ["Justin Carter "] 7 | edition = "2021" 8 | license = "MIT" 9 | categories = ["parsing"] 10 | 11 | [features] 12 | 13 | fail-on-warnings = [] 14 | 15 | [dependencies] 16 | lalrpop-util = { version = "0.22", features = ["lexer"] } 17 | 18 | [build-dependencies] 19 | lalrpop = { version = "0.22", features = ["lexer"] } 20 | -------------------------------------------------------------------------------- /.github/workflows/spelling.yml: -------------------------------------------------------------------------------- 1 | #! Auto synced from Shared CI Resources repository 2 | #! Don't change this file, instead change it in github.com/GaloyMoney/concourse-shared 3 | 4 | name: Spelling 5 | 6 | on: 7 | pull_request: 8 | branches: [ main ] 9 | 10 | jobs: 11 | spelling: 12 | name: Spell Check with Typos 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Actions Repository 16 | uses: actions/checkout@v3 17 | - name: Spell Check Repo 18 | uses: crate-ci/typos@master 19 | with: 20 | config: typos.toml 21 | -------------------------------------------------------------------------------- /.github/workflows/vendor/spelling.yml: -------------------------------------------------------------------------------- 1 | #! Auto synced from Shared CI Resources repository 2 | #! Don't change this file, instead change it in github.com/GaloyMoney/concourse-shared 3 | 4 | name: Spelling 5 | 6 | on: 7 | pull_request: 8 | branches: [ main ] 9 | 10 | jobs: 11 | spelling: 12 | name: Spell Check with Typos 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Actions Repository 16 | uses: actions/checkout@v3 17 | - name: Spell Check Repo 18 | uses: crate-ci/typos@master 19 | with: 20 | config: typos.toml 21 | -------------------------------------------------------------------------------- /ledger/.sqlx/query-9e79709362bef4af7392f7cd241ba25755874dcd529542e64ddf7ff141c38b58.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT id FROM sqlx_ledger_accounts WHERE code = $1 LIMIT 1", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Text" 15 | ] 16 | }, 17 | "nullable": [ 18 | false 19 | ] 20 | }, 21 | "hash": "9e79709362bef4af7392f7cd241ba25755874dcd529542e64ddf7ff141c38b58" 22 | } 23 | -------------------------------------------------------------------------------- /ci/vendor/tasks/helpers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #! Auto synced from Shared CI Resources repository 4 | #! Don't change this file, instead change it in github.com/GaloyMoney/concourse-shared 5 | 6 | echo " --> git config" 7 | if [[ -z $(git config --global user.email) ]]; then 8 | git config --global user.email "202112752+blinkbitcoinbot@users.noreply.github.com" 9 | fi 10 | if [[ -z $(git config --global user.name) ]]; then 11 | git config --global user.name "CI blinkbitcoinbot" 12 | fi 13 | 14 | export CARGO_HOME="$(pwd)/cargo-home" 15 | export CARGO_TARGET_DIR="$(pwd)/cargo-target-dir" 16 | 17 | unpack_deps() { echo ""; } 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | integration-deps: 4 | image: busybox 5 | depends_on: 6 | - postgres 7 | postgres: 8 | image: postgres:14.1 9 | environment: 10 | - POSTGRES_USER=user 11 | - POSTGRES_PASSWORD=password 12 | - POSTGRES_DB=pg 13 | integration-tests: 14 | image: us.gcr.io/galoy-org/rust-concourse 15 | depends_on: 16 | - integration-deps 17 | command: ["make", "test-in-ci"] 18 | environment: 19 | - PG_HOST=postgres 20 | - PG_CON=postgres://user:password@postgres:5432/pg 21 | working_dir: /repo 22 | volumes: 23 | - ./:/repo 24 | -------------------------------------------------------------------------------- /cel-interpreter/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sqlx-ledger-cel-interpreter" 3 | description = "An interpreter for the Common Expression Language (CEL)" 4 | repository = "https://github.com/GaloyMoney/sqlx-ledger" 5 | version = "0.11.12-dev" 6 | edition = "2021" 7 | license = "MIT" 8 | categories = ["parsing"] 9 | 10 | [features] 11 | 12 | fail-on-warnings = [] 13 | 14 | [dependencies] 15 | cel-parser = { path = "../cel-parser", package = "sqlx-ledger-cel-parser", version = "0.11.12-dev" } 16 | 17 | chrono = "0.4" 18 | rust_decimal = "1.30" 19 | serde = "1.0" 20 | serde_json = "1.0" 21 | thiserror = "1.0" 22 | uuid = { version = "1.3", features = ["serde", "v4"] } 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | #! Auto synced from Shared CI Resources repository 2 | #! Don't change this file, instead change it in github.com/GaloyMoney/concourse-shared 3 | 4 | # To get started with Dependabot version updates, you'll need to specify which 5 | # package ecosystems to update and where the package manifests are located. 6 | # Please see the documentation for all configuration options: 7 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 8 | 9 | version: 2 10 | updates: 11 | - package-ecosystem: "cargo" # See documentation for possible values 12 | directory: "/" # Location of package manifests 13 | schedule: 14 | interval: "daily" 15 | -------------------------------------------------------------------------------- /ledger/.sqlx/query-19ff1c916c204f57f6bfcf0f72570ca6d41602ead734b4a65e11de95b9ab7c7f.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "INSERT INTO sqlx_ledger_accounts\n (id, version, code, name, normal_balance_type, description, status, metadata, created_at)\n(\n SELECT id, version + 1, code, name, normal_balance_type, COALESCE($2, description), status, COALESCE($3, metadata), created_at\n FROM sqlx_ledger_accounts WHERE id = $1 ORDER BY version DESC LIMIT 1\n)\n", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Uuid", 9 | "Varchar", 10 | "Jsonb" 11 | ] 12 | }, 13 | "nullable": [] 14 | }, 15 | "hash": "19ff1c916c204f57f6bfcf0f72570ca6d41602ead734b4a65e11de95b9ab7c7f" 16 | } 17 | -------------------------------------------------------------------------------- /ci/values.yml: -------------------------------------------------------------------------------- 1 | #@data/values 2 | --- 3 | git_uri: git@github.com:blinkbitcoin/sqlx-ledger.git 4 | git_branch: main 5 | github_private_key: ((github-blinkbitcoin.private_key)) 6 | github_token: ((github-blinkbitcoin.api_token)) 7 | 8 | docker_host_ip: ((staging-ssh.docker_host_ip)) 9 | artifacts_bucket_name: ((staging-gcp-creds.bucket_name)) 10 | staging_inception_creds: ((staging-gcp-creds.creds_json)) 11 | staging_ssh_private_key: ((staging-ssh.ssh_private_key)) 12 | staging_ssh_pub_key: ((staging-ssh.ssh_public_key)) 13 | git_version_branch: version 14 | gh_org: blinkbitcoin 15 | gh_repository: sqlx-ledger 16 | crates_api_token: ((crates.token)) 17 | 18 | slack_channel: sqlx-ledger-github 19 | slack_username: concourse 20 | slack_webhook_url: ((addons-slack.api_url)) 21 | -------------------------------------------------------------------------------- /.github/workflows/check-code.yml: -------------------------------------------------------------------------------- 1 | #! Auto synced from Shared CI Resources repository 2 | #! Don't change this file, instead change it in github.com/blinkbitcoin/concourse-shared 3 | # Manually edited according to github.com/blinkbitcoin/concourse-shared/shared/actions/rust-check-code.yml 4 | 5 | name: Check Code 6 | 7 | on: 8 | pull_request: 9 | branches: [main] 10 | 11 | jobs: 12 | check-code: 13 | name: Check Code 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Install Nix 17 | uses: DeterminateSystems/nix-installer-action@v4 18 | - name: Run the Magic Nix Cache 19 | uses: DeterminateSystems/magic-nix-cache-action@v8 20 | - uses: actions/checkout@v4 21 | - name: Run check code 22 | run: nix develop -c make check-code 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sqlx-Ledger 2 | 3 | This crate provides primitives for double sided accounting built on top of the [sqlx](https://github.com/launchbadge/sqlx) Postgres integration. 4 | 5 | It features: 6 | * Accounts can have balances on multiple journals 7 | * Multi currency / multi layer support 8 | * Template based transaction inputs for consistency 9 | * CEL based template interpolation 10 | * Event streaming to be notified of changes 11 | 12 | The CEL interpreter is not complete but provides enough to support basic use cases. 13 | More will be added as the need arises. 14 | 15 | To use it copy the [migrations](./migrations) into your project and add the crate via `cargo add sqlx-ledger`. 16 | 17 | Check out the [docs](https://docs.rs/sqlx-ledger/latest/sqlx_ledger/) for an example of how to use it 18 | -------------------------------------------------------------------------------- /ledger/.sqlx/query-66eeb7290707e1e3e39c38e6ffd2d45b5baf6162aa2dccd203de6f8d31cbd259.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT json_build_object(\n 'id', id,\n 'type', type,\n 'data', data,\n 'recorded_at', recorded_at\n ) AS \"payload!\" FROM sqlx_ledger_events WHERE id > $1 ORDER BY id", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "payload!", 9 | "type_info": "Json" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Int8" 15 | ] 16 | }, 17 | "nullable": [ 18 | null 19 | ] 20 | }, 21 | "hash": "66eeb7290707e1e3e39c38e6ffd2d45b5baf6162aa2dccd203de6f8d31cbd259" 22 | } 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | cargo build 3 | 4 | watch: 5 | RUST_BACKTRACE=full cargo watch -s 'cargo test -- --nocapture' 6 | 7 | next-watch: 8 | cargo watch -s 'cargo nextest run' 9 | 10 | check-code: 11 | SQLX_OFFLINE=true cargo fmt --check --all 12 | SQLX_OFFLINE=true cargo clippy --all-features 13 | SQLX_OFFLINE=true cargo audit 14 | 15 | test-in-ci: 16 | DATABASE_URL=postgres://user:password@postgres:5432/pg cargo sqlx migrate run 17 | SQLX_OFFLINE=true cargo nextest run --verbose 18 | SQLX_OFFLINE=true cargo doc --no-deps 19 | SQLX_OFFLINE=true cargo test --doc 20 | 21 | open-docs: 22 | SQLX_OFFLINE=true cargo doc --open --no-deps 23 | 24 | clean-deps: 25 | docker compose down 26 | 27 | start-deps: 28 | docker compose up -d integration-deps 29 | 30 | reset-deps: clean-deps start-deps setup-db 31 | 32 | setup-db: 33 | cargo sqlx migrate run 34 | -------------------------------------------------------------------------------- /ledger/tests/account.rs: -------------------------------------------------------------------------------- 1 | mod helpers; 2 | 3 | use rand::distributions::{Alphanumeric, DistString}; 4 | use sqlx_ledger::{account::NewAccount, *}; 5 | 6 | #[tokio::test] 7 | async fn test_account() -> anyhow::Result<()> { 8 | let pool = helpers::init_pool().await?; 9 | 10 | let code = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); 11 | 12 | let new_account = NewAccount::builder() 13 | .id(uuid::Uuid::new_v4()) 14 | .name(format!("Test Account {code}")) 15 | .code(code) 16 | .build() 17 | .unwrap(); 18 | let ledger = SqlxLedger::new(&pool); 19 | let id = ledger.accounts().create(new_account).await.unwrap(); 20 | ledger 21 | .accounts() 22 | .update::<()>(id, Some("new description".to_string()), None) 23 | .await 24 | .unwrap(); 25 | 26 | Ok(()) 27 | } 28 | -------------------------------------------------------------------------------- /ci/tasks/set-dev-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | VERSION="$(cat version/version)-dev" 4 | 5 | pushd repo 6 | 7 | for file in $(find . -mindepth 2 -name Cargo.toml); do 8 | sed -i'' "s/^version.*/version = \"${VERSION}\"/" ${file} 9 | done 10 | 11 | sed -i'' "s/cel-parser\", version = .*/cel-parser\", version = \"${VERSION}\" }/" cel-interpreter/Cargo.toml 12 | sed -i'' "s/cel-interpreter\", version = .*/cel-interpreter\", version = \"${VERSION}\" }/" ledger/Cargo.toml 13 | 14 | if [[ -z $(git config --global user.email) ]]; then 15 | git config --global user.email "bot@cepler.dev" 16 | fi 17 | if [[ -z $(git config --global user.name) ]]; then 18 | git config --global user.name "CI Bot" 19 | fi 20 | 21 | git status 22 | git add -A 23 | 24 | if [[ "$(git status -s -uno)" != "" ]]; then 25 | git commit -m "ci(dev): set version to ${VERSION}" 26 | fi 27 | -------------------------------------------------------------------------------- /ci/vendir.yml: -------------------------------------------------------------------------------- 1 | apiVersion: vendir.k14s.io/v1alpha1 2 | kind: Config 3 | 4 | # Relative to ci/ 5 | directories: 6 | - path: ../.github/workflows/vendor 7 | contents: 8 | - path: . # Copy this folder out to .. 9 | git: 10 | url: https://github.com/GaloyMoney/concourse-shared.git 11 | ref: 9d0f008e41df2f5d5e0461171c02fc0c4aee1d6f 12 | includePaths: 13 | - shared/actions/* 14 | excludePaths: 15 | - shared/actions/nodejs-* 16 | - shared/actions/chart-* 17 | newRootPath: shared/actions 18 | 19 | - path: ./vendor 20 | contents: 21 | - path: . 22 | git: 23 | url: https://github.com/GaloyMoney/concourse-shared.git 24 | ref: 9d0f008e41df2f5d5e0461171c02fc0c4aee1d6f 25 | includePaths: 26 | - shared/ci/**/* 27 | excludePaths: 28 | - shared/ci/**/nodejs-* 29 | - shared/ci/**/chart-* 30 | newRootPath: shared/ci 31 | -------------------------------------------------------------------------------- /ci/tasks/update-repo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | # ----------- UPDATE REPO ----------- 6 | git config --global user.email "bot@galoy.io" 7 | git config --global user.name "CI Bot" 8 | 9 | pushd repo 10 | 11 | VERSION="$(cat ../version/version)" 12 | 13 | cat <new_change_log.md 14 | # [sqlx-ledger release v${VERSION}](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v${VERSION}) 15 | 16 | $(cat ../artifacts/gh-release-notes.md) 17 | 18 | $(cat CHANGELOG.md) 19 | EOF 20 | mv new_change_log.md CHANGELOG.md 21 | 22 | for file in $(find . -mindepth 2 -name Cargo.toml); do 23 | sed -i'' "s/^version.*/version = \"${VERSION}\"/" ${file} 24 | done 25 | 26 | sed -i'' "s/cel-parser\", version = .*/cel-parser\", version = \"${VERSION}\" }/" cel-interpreter/Cargo.toml 27 | sed -i'' "s/cel-interpreter\", version = .*/cel-interpreter\", version = \"${VERSION}\" }/" ledger/Cargo.toml 28 | 29 | git status 30 | git add . 31 | 32 | if [[ "$(git status -s -uno)" != "" ]]; then 33 | git commit -m "ci(release): release version $(cat ../version/version)" 34 | fi 35 | -------------------------------------------------------------------------------- /ledger/.sqlx/query-63b1b637705d0ab703a902f275e3c01adc9b097941da3f02d0ba077c8401658b.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "INSERT INTO sqlx_ledger_tx_templates (id, code, description, params, tx_input, entries, metadata)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id, version, created_at", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "version", 14 | "type_info": "Int4" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "created_at", 19 | "type_info": "Timestamptz" 20 | } 21 | ], 22 | "parameters": { 23 | "Left": [ 24 | "Uuid", 25 | "Varchar", 26 | "Varchar", 27 | "Jsonb", 28 | "Jsonb", 29 | "Jsonb", 30 | "Jsonb" 31 | ] 32 | }, 33 | "nullable": [ 34 | false, 35 | false, 36 | false 37 | ] 38 | }, 39 | "hash": "63b1b637705d0ab703a902f275e3c01adc9b097941da3f02d0ba077c8401658b" 40 | } 41 | -------------------------------------------------------------------------------- /ledger/.sqlx/query-77599d77a146babd748cd8ebbd10c89fbb9a5aa3109b6fd0d326d685aecad587.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT id, code, params, tx_input, entries FROM sqlx_ledger_tx_templates WHERE code = $1 LIMIT 1", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "code", 14 | "type_info": "Varchar" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "params", 19 | "type_info": "Jsonb" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "tx_input", 24 | "type_info": "Jsonb" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "entries", 29 | "type_info": "Jsonb" 30 | } 31 | ], 32 | "parameters": { 33 | "Left": [ 34 | "Text" 35 | ] 36 | }, 37 | "nullable": [ 38 | false, 39 | false, 40 | true, 41 | false, 42 | false 43 | ] 44 | }, 45 | "hash": "77599d77a146babd748cd8ebbd10c89fbb9a5aa3109b6fd0d326d685aecad587" 46 | } 47 | -------------------------------------------------------------------------------- /ci/vendor/tasks/docker-bump-image-digest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #! Auto synced from Shared CI Resources repository 4 | #! Don't change this file, instead change it in github.com/GaloyMoney/concourse-shared 5 | 6 | set -eu 7 | 8 | export digest=$(cat ./edge-image/digest) 9 | export ref=$(cat ./repo/.git/short_ref) 10 | export app_version=$(cat version/version) 11 | 12 | pushd charts-repo 13 | 14 | yq -i e '.image.digest = strenv(digest)' ./charts/${CHARTS_SUBDIR}/values.yaml 15 | yq -i e '.image.git_ref = strenv(ref)' ./charts/${CHARTS_SUBDIR}/values.yaml 16 | yq -i e '.appVersion = strenv(app_version)' ./charts/${CHARTS_SUBDIR}/Chart.yaml 17 | 18 | echo " --> git config" 19 | if [[ -z $(git config --global user.email) ]]; then 20 | git config --global user.email "202112752+blinkbitcoinbot@users.noreply.github.com" 21 | fi 22 | if [[ -z $(git config --global user.name) ]]; then 23 | git config --global user.name "CI blinkbitcoinbot" 24 | fi 25 | 26 | ( 27 | cd $(git rev-parse --show-toplevel) 28 | git merge --no-edit ${BRANCH} 29 | git add -A 30 | git status 31 | git commit -m "chore(${CHARTS_SUBDIR}): bump ${CHARTS_SUBDIR} image to '${digest}'" 32 | ) 33 | -------------------------------------------------------------------------------- /cel-interpreter/src/error.rs: -------------------------------------------------------------------------------- 1 | use chrono::ParseError; 2 | use thiserror::Error; 3 | 4 | use crate::cel_type::*; 5 | 6 | #[derive(Error, Debug)] 7 | pub enum CelError { 8 | #[error("CelError - CelParseError: {0}")] 9 | CelParseError(String), 10 | #[error("CelError - BadType: expected {0:?} found {1:?}")] 11 | BadType(CelType, CelType), 12 | #[error("CelError - UnknownIdentifier: {0}")] 13 | UnknownIdent(String), 14 | #[error("CelError - IllegalTarget")] 15 | IllegalTarget, 16 | #[error("CelError - MissingArgument")] 17 | MissingArgument, 18 | #[error("CelError - WrongArgumentType: {0:?} instead of {1:?}")] 19 | WrongArgumentType(CelType, CelType), 20 | #[error("CelError - ChronoParseError: {0}")] 21 | ChronoParseError(#[from] ParseError), 22 | #[error("CelError - UuidError: {0}")] 23 | UuidError(String), 24 | #[error("CelError - DecimalError: {0}")] 25 | DecimalError(String), 26 | #[error("CelError - NoMatchingOverload: {0}")] 27 | NoMatchingOverload(String), 28 | #[error("CelError - Unexpected: {0}")] 29 | Unexpected(String), 30 | 31 | #[error("Error evaluating cell expression '{0}' - {1}")] 32 | EvaluationError(String, Box), 33 | } 34 | -------------------------------------------------------------------------------- /ledger/.sqlx/query-6e82fe7ca0798715e4316b5acf6a3ccfbe0326ce3f5f3cd238ccad0f641cbe4e.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "INSERT INTO sqlx_ledger_journals (id, name, description, status)\n VALUES ($1, $2, $3, $4)\n RETURNING id, version, created_at", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "version", 14 | "type_info": "Int4" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "created_at", 19 | "type_info": "Timestamptz" 20 | } 21 | ], 22 | "parameters": { 23 | "Left": [ 24 | "Uuid", 25 | "Varchar", 26 | "Varchar", 27 | { 28 | "Custom": { 29 | "name": "status", 30 | "kind": { 31 | "Enum": [ 32 | "active" 33 | ] 34 | } 35 | } 36 | } 37 | ] 38 | }, 39 | "nullable": [ 40 | false, 41 | false, 42 | false 43 | ] 44 | }, 45 | "hash": "6e82fe7ca0798715e4316b5acf6a3ccfbe0326ce3f5f3cd238ccad0f641cbe4e" 46 | } 47 | -------------------------------------------------------------------------------- /ledger/src/entry/entity.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use derive_builder::Builder; 3 | use rust_decimal::Decimal; 4 | 5 | use crate::primitives::*; 6 | 7 | /// Representation of a ledger transaction entry entity. 8 | pub struct Entry { 9 | pub id: EntryId, 10 | pub version: u32, 11 | pub transaction_id: TransactionId, 12 | pub account_id: AccountId, 13 | pub journal_id: JournalId, 14 | pub entry_type: String, 15 | pub layer: Layer, 16 | pub units: Decimal, 17 | pub currency: Currency, 18 | pub direction: DebitOrCredit, 19 | pub sequence: u32, 20 | pub description: Option, 21 | pub created_at: DateTime, 22 | pub modified_at: DateTime, 23 | } 24 | 25 | #[derive(Debug, Builder)] 26 | pub(crate) struct NewEntry { 27 | pub(super) account_id: AccountId, 28 | pub(super) entry_type: String, 29 | pub(super) layer: Layer, 30 | pub(super) units: Decimal, 31 | pub(super) currency: Currency, 32 | pub(super) direction: DebitOrCredit, 33 | #[builder(setter(strip_option), default)] 34 | pub(super) description: Option, 35 | } 36 | 37 | impl NewEntry { 38 | pub fn builder() -> NewEntryBuilder { 39 | NewEntryBuilder::default() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /cel-interpreter/src/builtins.rs: -------------------------------------------------------------------------------- 1 | use chrono::{NaiveDate, Utc}; 2 | 3 | use std::sync::Arc; 4 | 5 | use super::value::*; 6 | use crate::error::*; 7 | 8 | pub(crate) fn date(args: Vec) -> Result { 9 | if args.is_empty() { 10 | return Ok(CelValue::Date(Utc::now().date_naive())); 11 | } 12 | 13 | let s: Arc = assert_arg(args.first())?; 14 | Ok(CelValue::Date(NaiveDate::parse_from_str(&s, "%Y-%m-%d")?)) 15 | } 16 | 17 | pub(crate) fn uuid(args: Vec) -> Result { 18 | let s: Arc = assert_arg(args.first())?; 19 | Ok(CelValue::Uuid( 20 | s.parse() 21 | .map_err(|e| CelError::UuidError(format!("{e:?}")))?, 22 | )) 23 | } 24 | 25 | pub(crate) fn decimal(args: Vec) -> Result { 26 | let s: Arc = assert_arg(args.first())?; 27 | Ok(CelValue::Decimal( 28 | s.parse() 29 | .map_err(|e| CelError::DecimalError(format!("{e:?}")))?, 30 | )) 31 | } 32 | 33 | fn assert_arg<'a, T: TryFrom<&'a CelValue, Error = CelError>>( 34 | arg: Option<&'a CelValue>, 35 | ) -> Result { 36 | if let Some(v) = arg { 37 | T::try_from(v) 38 | } else { 39 | Err(CelError::MissingArgument) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ledger/.sqlx/query-d7e7415d2c9b59e503b1ac93de8bdfbe9083e1cf4a69d2ebe8636c92b882176a.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "INSERT INTO sqlx_ledger_transactions (id, version, journal_id, tx_template_id, effective, correlation_id, external_id, description, metadata)\n VALUES ($1, 1, (SELECT id FROM sqlx_ledger_journals WHERE id = $2 LIMIT 1), (SELECT id FROM sqlx_ledger_tx_templates WHERE id = $3 LIMIT 1), $4, $5, $6, $7, $8)\n RETURNING id, version, created_at", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "version", 14 | "type_info": "Int4" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "created_at", 19 | "type_info": "Timestamptz" 20 | } 21 | ], 22 | "parameters": { 23 | "Left": [ 24 | "Uuid", 25 | "Uuid", 26 | "Uuid", 27 | "Date", 28 | "Uuid", 29 | "Varchar", 30 | "Varchar", 31 | "Jsonb" 32 | ] 33 | }, 34 | "nullable": [ 35 | false, 36 | false, 37 | false 38 | ] 39 | }, 40 | "hash": "d7e7415d2c9b59e503b1ac93de8bdfbe9083e1cf4a69d2ebe8636c92b882176a" 41 | } 42 | -------------------------------------------------------------------------------- /ledger/tests/tx_template.rs: -------------------------------------------------------------------------------- 1 | mod helpers; 2 | 3 | use rand::distributions::{Alphanumeric, DistString}; 4 | use sqlx_ledger::{tx_template::*, *}; 5 | 6 | #[tokio::test] 7 | async fn test_tx_template() -> anyhow::Result<()> { 8 | let pool = helpers::init_pool().await?; 9 | 10 | let code = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); 11 | 12 | let params = vec![ParamDefinition::builder() 13 | .name("input1") 14 | .r#type(ParamDataType::STRING) 15 | .default_expr("'input'") 16 | .build() 17 | .unwrap()]; 18 | let tx_input = TxInput::builder() 19 | .effective("1") 20 | .journal_id("1") 21 | .build() 22 | .unwrap(); 23 | let entries = vec![EntryInput::builder() 24 | .entry_type("'TEST_DR'") 25 | .account_id("param.recipient") 26 | .layer("'Settled'") 27 | .direction("'Settled'") 28 | .units("1290") 29 | .currency("'BTC'") 30 | .build() 31 | .unwrap()]; 32 | let new_template = NewTxTemplate::builder() 33 | .id(uuid::Uuid::new_v4()) 34 | .code(code) 35 | .params(params) 36 | .tx_input(tx_input) 37 | .entries(entries) 38 | .build() 39 | .unwrap(); 40 | SqlxLedger::new(&pool) 41 | .tx_templates() 42 | .create(new_template) 43 | .await 44 | .unwrap(); 45 | 46 | Ok(()) 47 | } 48 | -------------------------------------------------------------------------------- /ledger/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sqlx-ledger" 3 | description = "An embeddable double sided accounting ledger built on PG/SQLx" 4 | repository = "https://github.com/GaloyMoney/sqlx-ledger" 5 | documentation = "https://docs.rs/sqlx-ledger" 6 | readme = "../README.md" 7 | version = "0.11.12-dev" 8 | edition = "2021" 9 | license = "MIT" 10 | categories = ["finance"] 11 | 12 | [features] 13 | 14 | fail-on-warnings = [] 15 | otel = ["opentelemetry", "tracing-opentelemetry"] 16 | 17 | [dependencies] 18 | cel-interpreter = { path = "../cel-interpreter", package = "sqlx-ledger-cel-interpreter", version = "0.11.12-dev" } 19 | 20 | chrono = { version = "0.4", features = ["serde"] } 21 | rust_decimal = "1.30" 22 | derive_builder = "0.20" 23 | serde = "1.0" 24 | serde_json = "1.0" 25 | sqlx = { version = "0.8.2", features = [ 26 | "runtime-tokio-rustls", 27 | "postgres", 28 | "rust_decimal", 29 | "uuid", 30 | "chrono", 31 | "json", 32 | ] } 33 | thiserror = "1.0" 34 | tokio = { version = "1.28", features = ["macros"] } 35 | uuid = { version = "1.3", features = ["serde", "v4"] } 36 | rusty-money = { version = "0.4", features = ["iso", "crypto"] } 37 | tracing = "0.1" 38 | futures = "0.3" 39 | opentelemetry = { version = "0.27", optional = true } 40 | tracing-opentelemetry = { version = "0.28", optional = true } 41 | cached = { version = "0.54.0", features = ["async"] } 42 | 43 | 44 | [dev-dependencies] 45 | anyhow = "1.0" 46 | rand = "0.8" 47 | tokio-test = "0.4" 48 | -------------------------------------------------------------------------------- /ledger/.sqlx/query-c7f4751f63df559566362c0d251f9833a1ce2fd44bad7e61ba04f8e4526e1e73.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "INSERT INTO sqlx_ledger_accounts (id, code, name, normal_balance_type, description, status, metadata)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id, version, created_at", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "version", 14 | "type_info": "Int4" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "created_at", 19 | "type_info": "Timestamptz" 20 | } 21 | ], 22 | "parameters": { 23 | "Left": [ 24 | "Uuid", 25 | "Varchar", 26 | "Varchar", 27 | { 28 | "Custom": { 29 | "name": "debitorcredit", 30 | "kind": { 31 | "Enum": [ 32 | "debit", 33 | "credit" 34 | ] 35 | } 36 | } 37 | }, 38 | "Varchar", 39 | { 40 | "Custom": { 41 | "name": "status", 42 | "kind": { 43 | "Enum": [ 44 | "active" 45 | ] 46 | } 47 | } 48 | }, 49 | "Jsonb" 50 | ] 51 | }, 52 | "nullable": [ 53 | false, 54 | false, 55 | false 56 | ] 57 | }, 58 | "hash": "c7f4751f63df559566362c0d251f9833a1ce2fd44bad7e61ba04f8e4526e1e73" 59 | } 60 | -------------------------------------------------------------------------------- /ledger/src/journal/repo.rs: -------------------------------------------------------------------------------- 1 | use sqlx::{Pool, Postgres, Transaction}; 2 | use tracing::instrument; 3 | 4 | use super::entity::*; 5 | use crate::{error::*, primitives::*}; 6 | 7 | /// Repository for working with `Journal` entities. 8 | #[derive(Debug, Clone)] 9 | pub struct Journals { 10 | pool: Pool, 11 | } 12 | 13 | impl Journals { 14 | pub fn new(pool: &Pool) -> Self { 15 | Self { pool: pool.clone() } 16 | } 17 | 18 | pub async fn create(&self, new_journal: NewJournal) -> Result { 19 | let mut tx = self.pool.begin().await?; 20 | let res = self.create_in_tx(&mut tx, new_journal).await?; 21 | tx.commit().await?; 22 | Ok(res) 23 | } 24 | 25 | #[instrument(name = "sqlx_ledger.journals.create", skip(self, tx))] 26 | pub async fn create_in_tx<'a>( 27 | &self, 28 | tx: &mut Transaction<'a, Postgres>, 29 | new_journal: NewJournal, 30 | ) -> Result { 31 | let NewJournal { 32 | id, 33 | name, 34 | description, 35 | status, 36 | } = new_journal; 37 | let record = sqlx::query!( 38 | r#"INSERT INTO sqlx_ledger_journals (id, name, description, status) 39 | VALUES ($1, $2, $3, $4) 40 | RETURNING id, version, created_at"#, 41 | id as JournalId, 42 | name, 43 | description, 44 | status as Status, 45 | ) 46 | .fetch_one(&mut **tx) 47 | .await?; 48 | Ok(JournalId::from(record.id)) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /ledger/src/journal/entity.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use derive_builder::Builder; 3 | 4 | use crate::primitives::*; 5 | 6 | /// Representation of a ledger journal entity. 7 | pub struct Journal { 8 | pub id: JournalId, 9 | pub name: String, 10 | pub description: Option, 11 | pub status: Status, 12 | pub version: u32, 13 | pub modified_at: DateTime, 14 | pub created_at: DateTime, 15 | } 16 | 17 | /// Representation of a new ledger journal entity 18 | /// with required/optional properties and a builder. 19 | #[derive(Debug, Builder)] 20 | pub struct NewJournal { 21 | #[builder(setter(into))] 22 | pub id: JournalId, 23 | #[builder(setter(into))] 24 | pub(super) name: String, 25 | #[builder(setter(strip_option, into), default)] 26 | pub(super) description: Option, 27 | #[builder(default)] 28 | pub(super) status: Status, 29 | } 30 | 31 | impl NewJournal { 32 | pub fn builder() -> NewJournalBuilder { 33 | let mut builder = NewJournalBuilder::default(); 34 | builder.id(JournalId::new()); 35 | builder 36 | } 37 | } 38 | 39 | #[cfg(test)] 40 | mod tests { 41 | use super::*; 42 | 43 | #[test] 44 | fn it_builds() { 45 | let new_journal = NewJournal::builder().name("name").build().unwrap(); 46 | assert_eq!(new_journal.name, "name"); 47 | assert_eq!(new_journal.description, None); 48 | assert_eq!(new_journal.status, Status::Active); 49 | } 50 | 51 | #[test] 52 | fn fails_when_mandatory_fields_are_missing() { 53 | let new_journal = NewJournal::builder().build(); 54 | assert!(new_journal.is_err()); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /ci/tasks/test-integration.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | . pipeline-tasks/ci/vendor/tasks/helpers.sh 6 | 7 | CI_ROOT=$(pwd) 8 | 9 | unpack_deps 10 | 11 | cat < ${CI_ROOT}/gcloud-creds.json 12 | ${GOOGLE_CREDENTIALS} 13 | EOF 14 | cat < ${CI_ROOT}/login.ssh 15 | ${SSH_PRIVATE_KEY} 16 | EOF 17 | chmod 600 ${CI_ROOT}/login.ssh 18 | cat < ${CI_ROOT}/login.ssh.pub 19 | ${SSH_PUB_KEY} 20 | EOF 21 | gcloud auth activate-service-account --key-file ${CI_ROOT}/gcloud-creds.json 22 | gcloud compute os-login ssh-keys add --key-file=${CI_ROOT}/login.ssh.pub > /dev/null 23 | 24 | mkdir ~/.ssh 25 | cp ${CI_ROOT}/login.ssh ~/.ssh/id_rsa 26 | cp ${CI_ROOT}/login.ssh.pub ~/.ssh/id_rsa.pub 27 | 28 | export DOCKER_HOST_USER="sa_$(cat ${CI_ROOT}/gcloud-creds.json | jq -r '.client_id')" 29 | export DOCKER_HOST=ssh://${DOCKER_HOST_USER}@${DOCKER_HOST_IP} 30 | export ADDITIONAL_SSH_OPTS="-o StrictHostKeyChecking=no -i ${CI_ROOT}/login.ssh" 31 | 32 | pushd ${REPO_PATH} 33 | 34 | echo "Syncing repo to docker-host... " 35 | rsync --delete --exclude target -avr -e "ssh -l ${DOCKER_HOST_USER} ${ADDITIONAL_SSH_OPTS}" \ 36 | ./ ${DOCKER_HOST_IP}:${REPO_PATH} > /dev/null 37 | echo "Done!" 38 | 39 | docker compose down --volumes --remove-orphans --timeout 1 || true 40 | 41 | ssh ${ADDITIONAL_SSH_OPTS} ${DOCKER_HOST_USER}@${DOCKER_HOST_IP} \ 42 | "cd ${REPO_PATH}; docker compose -f docker-compose.yml up integration-tests" 43 | 44 | container_id=$(docker ps -q -f status=exited -f name="${PWD##*/}-integration-tests-") 45 | test_status=$(docker inspect $container_id --format='{{.State.ExitCode}}') 46 | 47 | set +e 48 | docker compose down --volumes --remove-orphans --timeout 1 || true 49 | 50 | exit $test_status 51 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Bria"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | rust-overlay = { 8 | url = "github:oxalica/rust-overlay"; 9 | inputs = { 10 | nixpkgs.follows = "nixpkgs"; 11 | flake-utils.follows = "flake-utils"; 12 | }; 13 | }; 14 | }; 15 | outputs = { 16 | self, 17 | nixpkgs, 18 | flake-utils, 19 | rust-overlay, 20 | }: 21 | flake-utils.lib.eachDefaultSystem 22 | (system: let 23 | overlays = [(import rust-overlay)]; 24 | pkgs = import nixpkgs { 25 | inherit system overlays; 26 | }; 27 | rustVersion = pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; 28 | rustToolchain = rustVersion.override { 29 | extensions = ["rust-analyzer" "rust-src"]; 30 | }; 31 | nativeBuildInputs = with pkgs; 32 | [ 33 | rustToolchain 34 | alejandra 35 | sqlx-cli 36 | cargo-nextest 37 | cargo-audit 38 | cargo-watch 39 | postgresql 40 | docker-compose 41 | bats 42 | jq 43 | ]; 44 | devEnvVars = rec { 45 | PGDATABASE = "pg"; 46 | PGUSER = "user"; 47 | PGPASSWORD = "password"; 48 | PGHOST = "127.0.0.1"; 49 | DATABASE_URL = "postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/pg"; 50 | PG_CON = "${DATABASE_URL}"; 51 | }; 52 | in 53 | with pkgs; { 54 | devShells.default = mkShell (devEnvVars 55 | // { 56 | inherit nativeBuildInputs; 57 | }); 58 | 59 | formatter = alejandra; 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /ci/vendor/tasks/test-on-docker-host.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | export CI_ROOT=$(pwd) 6 | 7 | host_name=$(cat docker-host/metadata | jq -r '.docker_host_name') 8 | echo "Running on host: ${host_name}" 9 | host_zone=$(cat docker-host/metadata | jq -r '.docker_host_zone') 10 | gcp_project=$(cat docker-host/metadata | jq -r '.docker_host_project') 11 | 12 | gcloud_ssh() { 13 | gcloud compute ssh ${host_name} \ 14 | --zone=${host_zone} \ 15 | --project=${gcp_project} \ 16 | --ssh-key-file=${CI_ROOT}/login.ssh \ 17 | --tunnel-through-iap \ 18 | --command "$@" 2> /dev/null 19 | } 20 | 21 | cat < ${CI_ROOT}/gcloud-creds.json 22 | ${GOOGLE_CREDENTIALS} 23 | EOF 24 | cat < ${CI_ROOT}/login.ssh 25 | ${SSH_PRIVATE_KEY} 26 | EOF 27 | chmod 600 ${CI_ROOT}/login.ssh 28 | cat < ${CI_ROOT}/login.ssh.pub 29 | ${SSH_PUB_KEY} 30 | EOF 31 | gcloud auth activate-service-account --key-file ${CI_ROOT}/gcloud-creds.json 2> /dev/null 32 | 33 | gcloud_ssh "docker ps -qa | xargs docker rm -fv || true; sudo rm -rf ${REPO_PATH}" 34 | 35 | pushd ${REPO_PATH} 36 | 37 | make create-tmp-env-ci || true 38 | 39 | gcloud compute scp --ssh-key-file=${CI_ROOT}/login.ssh \ 40 | --recurse $(pwd) ${host_name}:${REPO_PATH} \ 41 | --tunnel-through-iap \ 42 | --zone=${host_zone} \ 43 | --project=${gcp_project} > /dev/null 44 | 45 | gcloud_ssh "cd ${REPO_PATH}; export TMP_ENV_CI=tmp.env.ci; export COMPOSE_PROJECT_NAME=${REPO_PATH}; docker compose pull; docker compose -f docker-compose.yml up ${TEST_CONTAINER}" 46 | 47 | container_id=$(gcloud_ssh "docker ps -q -f status=exited -f name=${PWD##*/}-${TEST_CONTAINER}-") 48 | test_status=$(gcloud_ssh "docker inspect $container_id --format='{{.State.ExitCode}}'") 49 | 50 | gcloud_ssh "cd ${REPO_PATH}; docker compose down --remove-orphans --timeout 1" 51 | 52 | exit $test_status 53 | -------------------------------------------------------------------------------- /ledger/src/transaction/entity.rs: -------------------------------------------------------------------------------- 1 | use crate::primitives::*; 2 | use chrono::{DateTime, NaiveDate, Utc}; 3 | use derive_builder::Builder; 4 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; 5 | 6 | #[derive(Clone, Debug, Serialize, Deserialize)] 7 | pub struct Transaction { 8 | pub id: TransactionId, 9 | pub version: u32, 10 | pub journal_id: JournalId, 11 | pub tx_template_id: TxTemplateId, 12 | pub effective: NaiveDate, 13 | pub correlation_id: CorrelationId, 14 | pub external_id: String, 15 | pub description: Option, 16 | #[serde(rename = "metadata")] 17 | pub metadata_json: Option, 18 | pub created_at: DateTime, 19 | pub modified_at: DateTime, 20 | } 21 | 22 | impl Transaction { 23 | pub fn metadata(&self) -> Result, serde_json::Error> { 24 | match self.metadata_json.as_ref() { 25 | Some(json) => Ok(serde_json::from_value(json.clone())?), 26 | None => Ok(None), 27 | } 28 | } 29 | } 30 | 31 | #[derive(Builder)] 32 | pub(crate) struct NewTransaction { 33 | #[builder(setter(into))] 34 | pub(super) journal_id: JournalId, 35 | pub(super) tx_template_id: TxTemplateId, 36 | pub(super) effective: NaiveDate, 37 | #[builder(setter(strip_option), default)] 38 | pub(super) correlation_id: Option, 39 | #[builder(setter(strip_option), default)] 40 | pub(super) external_id: Option, 41 | #[builder(setter(strip_option), default)] 42 | pub(super) description: Option, 43 | #[builder(setter(strip_option), default)] 44 | pub(super) metadata: Option, 45 | } 46 | 47 | impl NewTransaction { 48 | pub fn builder() -> NewTransactionBuilder { 49 | NewTransactionBuilder::default() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /ledger/src/macros.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! entity_id { 3 | ($name:ident) => { 4 | #[derive( 5 | sqlx::Type, 6 | Debug, 7 | Clone, 8 | Copy, 9 | PartialEq, 10 | Eq, 11 | PartialOrd, 12 | Ord, 13 | Hash, 14 | serde::Deserialize, 15 | serde::Serialize, 16 | )] 17 | #[serde(transparent)] 18 | #[sqlx(transparent)] 19 | pub struct $name(uuid::Uuid); 20 | 21 | impl $name { 22 | #[allow(clippy::new_without_default)] 23 | pub fn new() -> Self { 24 | uuid::Uuid::new_v4().into() 25 | } 26 | } 27 | 28 | impl From for $name { 29 | fn from(uuid: uuid::Uuid) -> Self { 30 | Self(uuid) 31 | } 32 | } 33 | 34 | impl From<$name> for uuid::Uuid { 35 | fn from(id: $name) -> Self { 36 | id.0 37 | } 38 | } 39 | 40 | impl From<&$name> for uuid::Uuid { 41 | fn from(id: &$name) -> Self { 42 | id.0 43 | } 44 | } 45 | 46 | impl From<$name> for cel_interpreter::CelValue { 47 | fn from(id: $name) -> Self { 48 | cel_interpreter::CelValue::Uuid(id.0) 49 | } 50 | } 51 | 52 | impl std::fmt::Display for $name { 53 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 54 | write!(f, "{}", self.0) 55 | } 56 | } 57 | 58 | impl std::str::FromStr for $name { 59 | type Err = uuid::Error; 60 | 61 | fn from_str(s: &str) -> Result { 62 | Ok(Self(uuid::Uuid::parse_str(s)?)) 63 | } 64 | } 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /cel-interpreter/src/context.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::Arc}; 2 | 3 | use crate::{builtins, error::*, value::*}; 4 | 5 | type CelFunction = Box) -> Result>; 6 | #[derive(Debug)] 7 | pub struct CelContext { 8 | idents: HashMap, 9 | } 10 | 11 | impl CelContext { 12 | pub fn new() -> Self { 13 | let mut idents = HashMap::new(); 14 | idents.insert( 15 | "date".to_string(), 16 | ContextItem::Function(Box::new(builtins::date)), 17 | ); 18 | idents.insert( 19 | "uuid".to_string(), 20 | ContextItem::Function(Box::new(builtins::uuid)), 21 | ); 22 | idents.insert( 23 | "decimal".to_string(), 24 | ContextItem::Function(Box::new(builtins::decimal)), 25 | ); 26 | Self { idents } 27 | } 28 | } 29 | impl Default for CelContext { 30 | fn default() -> Self { 31 | Self::new() 32 | } 33 | } 34 | 35 | pub(crate) enum ContextItem { 36 | Value(CelValue), 37 | Function(CelFunction), 38 | } 39 | 40 | impl std::fmt::Debug for ContextItem { 41 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 42 | match self { 43 | ContextItem::Value(val) => write!(f, "Value({val:?})"), 44 | ContextItem::Function(_) => write!(f, "Function"), 45 | } 46 | } 47 | } 48 | 49 | impl CelContext { 50 | pub(crate) fn lookup(&self, name: Arc) -> Result<&ContextItem, CelError> { 51 | self.idents 52 | .get(name.as_ref()) 53 | .ok_or_else(|| CelError::UnknownIdent(name.to_string())) 54 | } 55 | 56 | pub fn add_variable(&mut self, name: impl Into, value: impl Into) { 57 | self.idents 58 | .insert(name.into(), ContextItem::Value(value.into())); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /ledger/src/tx_template/tx_params.rs: -------------------------------------------------------------------------------- 1 | use cel_interpreter::{CelContext, CelMap, CelValue}; 2 | use std::collections::HashMap; 3 | 4 | use super::param_definition::{ParamDataType, ParamDefinition}; 5 | use crate::error::SqlxLedgerError; 6 | 7 | #[derive(Debug)] 8 | pub struct TxParams { 9 | values: HashMap, 10 | } 11 | 12 | impl TxParams { 13 | pub fn new() -> Self { 14 | Self { 15 | values: HashMap::new(), 16 | } 17 | } 18 | 19 | pub fn insert(&mut self, k: impl Into, v: impl Into) { 20 | self.values.insert(k.into(), v.into()); 21 | } 22 | 23 | pub fn to_context( 24 | mut self, 25 | defs: Option<&Vec>, 26 | ) -> Result { 27 | let mut ctx = super::cel_context::initialize(); 28 | if let Some(defs) = defs { 29 | let mut cel_map = CelMap::new(); 30 | for d in defs { 31 | if let Some(v) = self.values.remove(&d.name) { 32 | match ParamDataType::try_from(&v) { 33 | Ok(t) if t == d.r#type => { 34 | cel_map.insert(d.name.clone(), v); 35 | continue; 36 | } 37 | _ => return Err(SqlxLedgerError::TxParamTypeMismatch(d.r#type.clone())), 38 | } 39 | } 40 | if let Some(expr) = d.default_expr() { 41 | cel_map.insert(d.name.clone(), expr.evaluate(&ctx)?); 42 | } 43 | } 44 | ctx.add_variable("params", cel_map); 45 | } 46 | 47 | if !self.values.is_empty() { 48 | return Err(SqlxLedgerError::TooManyParameters); 49 | } 50 | 51 | Ok(ctx) 52 | } 53 | } 54 | 55 | impl Default for TxParams { 56 | fn default() -> Self { 57 | Self::new() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ledger/src/error.rs: -------------------------------------------------------------------------------- 1 | use rust_decimal::Decimal; 2 | use sqlx::error::DatabaseError; 3 | use thiserror::Error; 4 | 5 | use cel_interpreter::CelError; 6 | 7 | use crate::{event::SqlxLedgerEvent, primitives::*, tx_template::ParamDataType}; 8 | 9 | #[derive(Error, Debug)] 10 | pub enum SqlxLedgerError { 11 | #[error("SqlxLedgerError - Sqlx: {0}")] 12 | Sqlx(sqlx::Error), 13 | #[error("SqlxLedgerError - DuplicateKey: {0}")] 14 | DuplicateKey(Box), 15 | #[error("SqlxLedgerError - SerdeJson: {0}")] 16 | SerdeJson(#[from] serde_json::Error), 17 | #[error("SqlxLedgerError - SendEvent: {0}")] 18 | SendEvent(#[from] tokio::sync::broadcast::error::SendError), 19 | #[error("SqlxLedgerError - CelError: {0}")] 20 | CelError(#[from] CelError), 21 | #[error("SqlxLedgerError - TxParamTypeMismatch: expected {0:?}")] 22 | TxParamTypeMismatch(ParamDataType), 23 | #[error("SqlxLedgerError - TooManyParameters")] 24 | TooManyParameters, 25 | #[error("SqlxLedgerError - UnknownLayer: {0:?}")] 26 | UnknownLayer(String), 27 | #[error("SqlxLedgerError - UnknownDebitOrCredit: {0:?}")] 28 | UnknownDebitOrCredit(String), 29 | #[error("SqlxLedgerError - UnknownCurrency: {0}")] 30 | UnknownCurrency(String), 31 | #[error("SqlxLedgerError - UnbalancedTransaction: currency {0} amount {1}")] 32 | UnbalancedTransaction(Currency, Decimal), 33 | #[error("SqlxLedgerError - OptimisticLockingError")] 34 | OptimisticLockingError, 35 | #[error("SqlxLedgerError - EventSubscriberClosed")] 36 | EventSubscriberClosed, 37 | } 38 | 39 | impl From for SqlxLedgerError { 40 | fn from(e: sqlx::Error) -> Self { 41 | match e { 42 | sqlx::Error::Database(err) if err.message().contains("duplicate key") => { 43 | SqlxLedgerError::DuplicateKey(err) 44 | } 45 | e => SqlxLedgerError::Sqlx(e), 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /ci/vendor/tasks/prep-release-src.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #! Auto synced from Shared CI Resources repository 4 | #! Don't change this file, instead change it in github.com/GaloyMoney/concourse-shared 5 | 6 | set -eu 7 | 8 | # ------------ CHANGELOG ------------ 9 | 10 | pushd repo 11 | 12 | # First time 13 | if [[ $(cat ../version/version) == "0.0.0" ]]; then 14 | git cliff --config ../pipeline-tasks/ci/vendor/config/git-cliff.toml > ../artifacts/gh-release-notes.md 15 | 16 | # Fetch changelog from last ref 17 | else 18 | export prev_ref=$(git rev-list -n 1 $(cat ../version/version)) 19 | export new_ref=$(git rev-parse HEAD) 20 | 21 | git cliff --config ../pipeline-tasks/ci/vendor/config/git-cliff.toml $prev_ref..$new_ref > ../artifacts/gh-release-notes.md 22 | fi 23 | 24 | popd 25 | 26 | # Generate Changelog 27 | echo "CHANGELOG:" 28 | echo "-------------------------------" 29 | cat artifacts/gh-release-notes.md 30 | echo "-------------------------------" 31 | 32 | # ------------ BUMP VERSION ------------ 33 | 34 | echo -n "Prev Version: " 35 | cat version/version 36 | echo "" 37 | 38 | # Initial Version 39 | if [[ $(cat version/version) == "0.0.0" ]]; then 40 | echo "0.1.0" > version/version 41 | # Figure out proper version to release 42 | elif [[ $(cat artifacts/gh-release-notes.md | grep breaking) != '' ]] || [[ $(cat artifacts/gh-release-notes.md | grep feature) != '' ]]; then 43 | echo "Breaking change / Feature Addition found, bumping minor version..." 44 | bump2version minor --current-version $(cat version/version) --allow-dirty version/version 45 | else 46 | echo "Only patches and fixes found - no breaking changes, bumping patch version..." 47 | bump2version patch --current-version $(cat version/version) --allow-dirty version/version 48 | fi 49 | 50 | echo -n "Release Version: " 51 | cat version/version 52 | echo "" 53 | 54 | # ------------ ARTIFACTS ------------ 55 | 56 | cat version/version > artifacts/gh-release-tag 57 | echo "v$(cat version/version) Release" > artifacts/gh-release-name 58 | -------------------------------------------------------------------------------- /ledger/.sqlx/query-28b2079d3422953e6dc7cf7c3a1ab1e304568264e004dec9b8b8cc306dfaee66.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT id, version, journal_id, tx_template_id, effective, correlation_id, external_id, description, metadata, created_at, modified_at\n FROM sqlx_ledger_transactions\n WHERE tx_template_id = $1", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "version", 14 | "type_info": "Int4" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "journal_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "tx_template_id", 24 | "type_info": "Uuid" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "effective", 29 | "type_info": "Date" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "correlation_id", 34 | "type_info": "Uuid" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "external_id", 39 | "type_info": "Varchar" 40 | }, 41 | { 42 | "ordinal": 7, 43 | "name": "description", 44 | "type_info": "Varchar" 45 | }, 46 | { 47 | "ordinal": 8, 48 | "name": "metadata", 49 | "type_info": "Jsonb" 50 | }, 51 | { 52 | "ordinal": 9, 53 | "name": "created_at", 54 | "type_info": "Timestamptz" 55 | }, 56 | { 57 | "ordinal": 10, 58 | "name": "modified_at", 59 | "type_info": "Timestamptz" 60 | } 61 | ], 62 | "parameters": { 63 | "Left": [ 64 | "Uuid" 65 | ] 66 | }, 67 | "nullable": [ 68 | false, 69 | false, 70 | false, 71 | false, 72 | false, 73 | false, 74 | false, 75 | true, 76 | true, 77 | false, 78 | false 79 | ] 80 | }, 81 | "hash": "28b2079d3422953e6dc7cf7c3a1ab1e304568264e004dec9b8b8cc306dfaee66" 82 | } 83 | -------------------------------------------------------------------------------- /ledger/.sqlx/query-97e0b4c922ce1927e58ed4836ee5f403d6ca6274a0d7816a8a2979943159c062.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT id, version, journal_id, tx_template_id, effective, correlation_id, external_id, description, metadata, created_at, modified_at\n FROM sqlx_ledger_transactions\n WHERE id = ANY($1)", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "version", 14 | "type_info": "Int4" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "journal_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "tx_template_id", 24 | "type_info": "Uuid" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "effective", 29 | "type_info": "Date" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "correlation_id", 34 | "type_info": "Uuid" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "external_id", 39 | "type_info": "Varchar" 40 | }, 41 | { 42 | "ordinal": 7, 43 | "name": "description", 44 | "type_info": "Varchar" 45 | }, 46 | { 47 | "ordinal": 8, 48 | "name": "metadata", 49 | "type_info": "Jsonb" 50 | }, 51 | { 52 | "ordinal": 9, 53 | "name": "created_at", 54 | "type_info": "Timestamptz" 55 | }, 56 | { 57 | "ordinal": 10, 58 | "name": "modified_at", 59 | "type_info": "Timestamptz" 60 | } 61 | ], 62 | "parameters": { 63 | "Left": [ 64 | "UuidArray" 65 | ] 66 | }, 67 | "nullable": [ 68 | false, 69 | false, 70 | false, 71 | false, 72 | false, 73 | false, 74 | false, 75 | true, 76 | true, 77 | false, 78 | false 79 | ] 80 | }, 81 | "hash": "97e0b4c922ce1927e58ed4836ee5f403d6ca6274a0d7816a8a2979943159c062" 82 | } 83 | -------------------------------------------------------------------------------- /ledger/.sqlx/query-d55823be440da6ca9e60d94299dfa3a4f1df8e71d1459e7889c5cc2450cec803.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT id, version, journal_id, tx_template_id, effective, correlation_id, external_id, description, metadata, created_at, modified_at\n FROM sqlx_ledger_transactions\n WHERE external_id = ANY($1)", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "version", 14 | "type_info": "Int4" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "journal_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "tx_template_id", 24 | "type_info": "Uuid" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "effective", 29 | "type_info": "Date" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "correlation_id", 34 | "type_info": "Uuid" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "external_id", 39 | "type_info": "Varchar" 40 | }, 41 | { 42 | "ordinal": 7, 43 | "name": "description", 44 | "type_info": "Varchar" 45 | }, 46 | { 47 | "ordinal": 8, 48 | "name": "metadata", 49 | "type_info": "Jsonb" 50 | }, 51 | { 52 | "ordinal": 9, 53 | "name": "created_at", 54 | "type_info": "Timestamptz" 55 | }, 56 | { 57 | "ordinal": 10, 58 | "name": "modified_at", 59 | "type_info": "Timestamptz" 60 | } 61 | ], 62 | "parameters": { 63 | "Left": [ 64 | "TextArray" 65 | ] 66 | }, 67 | "nullable": [ 68 | false, 69 | false, 70 | false, 71 | false, 72 | false, 73 | false, 74 | false, 75 | true, 76 | true, 77 | false, 78 | false 79 | ] 80 | }, 81 | "hash": "d55823be440da6ca9e60d94299dfa3a4f1df8e71d1459e7889c5cc2450cec803" 82 | } 83 | -------------------------------------------------------------------------------- /ci/vendor/config/git-cliff.toml: -------------------------------------------------------------------------------- 1 | #! Auto synced from Shared CI Resources repository 2 | #! Don't change this file, instead change it in github.com/GaloyMoney/concourse-shared 3 | 4 | # configuration file for git-cliff (0.1.0) 5 | 6 | [changelog] 7 | # changelog header 8 | header = """""" 9 | 10 | # template for the changelog body 11 | # https://tera.netlify.app/docs/#introduction 12 | body = """ 13 | {% if version %}\ 14 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 15 | {% endif %}\ 16 | {% for group, commits in commits | group_by(attribute="group") %} 17 | ### {{ group | upper_first }} 18 | {% for commit in commits %} 19 | - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ 20 | {% endfor %} 21 | {% endfor %}\n 22 | """ 23 | # remove the leading and trailing whitespaces from the template 24 | trim = true 25 | # changelog footer 26 | footer = """""" 27 | 28 | [git] 29 | # parse the commits based on https://www.conventionalcommits.org 30 | conventional_commits = true 31 | # filter out the commits that are not conventional 32 | filter_unconventional = true 33 | # regex for parsing and grouping commits 34 | commit_parsers = [ 35 | { message = "^feat", group = "Features"}, 36 | { message = "^fix", group = "Bug Fixes"}, 37 | { message = "^doc", group = "Documentation"}, 38 | { message = "^perf", group = "Performance"}, 39 | { message = "^refactor", group = "Refactor"}, 40 | { message = "^style", group = "Styling"}, 41 | { message = "^test", group = "Testing"}, 42 | { message = "^chore\\(release\\): prepare for", skip = true}, 43 | { message = "^chore", group = "Miscellaneous Tasks"}, 44 | { body = ".*security", group = "Security"}, 45 | ] 46 | # filter out the commits that are not matched by commit parsers 47 | filter_commits = true 48 | # glob pattern for matching git tags 49 | tag_pattern = "v[0-9]*" 50 | # regex for skipping tags 51 | skip_tags = "v0.1.0-beta.1" 52 | # regex for ignoring tags 53 | ignore_tags = "" 54 | # sort the tags topologically 55 | topo_order = false 56 | # sort the commits inside sections by oldest/newest order 57 | sort_commits = "newest" 58 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1763312402, 24 | "narHash": "sha256-3YJkOBrFpmcusnh7i8GXXEyh7qZG/8F5z5+717550Hk=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "85a6c4a07faa12aaccd81b36ba9bfc2bec974fa1", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixpkgs-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs", 41 | "rust-overlay": "rust-overlay" 42 | } 43 | }, 44 | "rust-overlay": { 45 | "inputs": { 46 | "nixpkgs": [ 47 | "nixpkgs" 48 | ] 49 | }, 50 | "locked": { 51 | "lastModified": 1763347184, 52 | "narHash": "sha256-6QH8hpCYJxifvyHEYg+Da0BotUn03BwLIvYo3JAxuqQ=", 53 | "owner": "oxalica", 54 | "repo": "rust-overlay", 55 | "rev": "08895cce80433978d5bfd668efa41c5e24578cbd", 56 | "type": "github" 57 | }, 58 | "original": { 59 | "owner": "oxalica", 60 | "repo": "rust-overlay", 61 | "type": "github" 62 | } 63 | }, 64 | "systems": { 65 | "locked": { 66 | "lastModified": 1681028828, 67 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 68 | "owner": "nix-systems", 69 | "repo": "default", 70 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 71 | "type": "github" 72 | }, 73 | "original": { 74 | "owner": "nix-systems", 75 | "repo": "default", 76 | "type": "github" 77 | } 78 | } 79 | }, 80 | "root": "root", 81 | "version": 7 82 | } 83 | -------------------------------------------------------------------------------- /ledger/src/account/entity.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use derive_builder::Builder; 3 | 4 | use crate::primitives::*; 5 | 6 | /// Representation of a ledger account entity. 7 | pub struct Account { 8 | pub id: AccountId, 9 | pub code: String, 10 | pub name: String, 11 | pub normal_balance_type: DebitOrCredit, 12 | pub description: Option, 13 | pub status: Status, 14 | pub metadata: Option, 15 | pub version: u32, 16 | pub modified_at: DateTime, 17 | pub created_at: DateTime, 18 | } 19 | 20 | /// Representation of a ***new*** ledger account entity with required/optional properties and a builder. 21 | #[derive(Builder, Debug)] 22 | pub struct NewAccount { 23 | #[builder(setter(into))] 24 | pub id: AccountId, 25 | #[builder(setter(into))] 26 | pub(super) code: String, 27 | #[builder(setter(into))] 28 | pub(super) name: String, 29 | #[builder(default)] 30 | pub(super) normal_balance_type: DebitOrCredit, 31 | #[builder(setter(strip_option, into), default)] 32 | pub(super) description: Option, 33 | #[builder(default)] 34 | pub(super) status: Status, 35 | #[builder(setter(custom), default)] 36 | pub(super) metadata: Option, 37 | } 38 | 39 | impl NewAccount { 40 | pub fn builder() -> NewAccountBuilder { 41 | NewAccountBuilder::default() 42 | } 43 | } 44 | 45 | impl NewAccountBuilder { 46 | pub fn metadata( 47 | &mut self, 48 | metadata: T, 49 | ) -> Result<&mut Self, serde_json::Error> { 50 | self.metadata = Some(Some(serde_json::to_value(metadata)?)); 51 | Ok(self) 52 | } 53 | } 54 | 55 | #[cfg(test)] 56 | mod tests { 57 | use super::*; 58 | 59 | #[test] 60 | fn it_builds() { 61 | let new_account = NewAccount::builder() 62 | .id(uuid::Uuid::new_v4()) 63 | .code("code") 64 | .name("name") 65 | .build() 66 | .unwrap(); 67 | assert_eq!(new_account.code, "code"); 68 | assert_eq!(new_account.name, "name"); 69 | assert_eq!(new_account.normal_balance_type, DebitOrCredit::Credit); 70 | assert_eq!(new_account.description, None); 71 | assert_eq!(new_account.status, Status::Active); 72 | assert_eq!(new_account.metadata, None); 73 | } 74 | 75 | #[test] 76 | fn fails_when_mandatory_fields_are_missing() { 77 | let new_account = NewAccount::builder().build(); 78 | assert!(new_account.is_err()); 79 | } 80 | 81 | #[test] 82 | fn accepts_metadata() { 83 | use serde_json::json; 84 | let new_account = NewAccount::builder() 85 | .id(uuid::Uuid::new_v4()) 86 | .code("code") 87 | .name("name") 88 | .metadata(json!({"foo": "bar"})) 89 | .unwrap() 90 | .build() 91 | .unwrap(); 92 | assert_eq!(new_account.metadata, Some(json!({"foo": "bar"}))); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /ledger/src/tx_template/repo.rs: -------------------------------------------------------------------------------- 1 | use cached::proc_macro::cached; 2 | use sqlx::{Pool, Postgres}; 3 | use std::sync::Arc; 4 | use tracing::instrument; 5 | 6 | use super::{core::*, entity::*}; 7 | use crate::{error::*, primitives::*}; 8 | 9 | /// Provides methods to interact with `TxTemplateCore` entities. 10 | #[derive(Debug, Clone)] 11 | pub struct TxTemplates { 12 | pool: Pool, 13 | } 14 | 15 | impl TxTemplates { 16 | pub fn new(pool: &Pool) -> Self { 17 | Self { pool: pool.clone() } 18 | } 19 | 20 | #[instrument(name = "sqlx_ledger.tx_templates.create", skip_all)] 21 | pub async fn create( 22 | &self, 23 | NewTxTemplate { 24 | id, 25 | code, 26 | description, 27 | params, 28 | tx_input, 29 | entries, 30 | metadata, 31 | }: NewTxTemplate, 32 | ) -> Result { 33 | let params_json = serde_json::to_value(¶ms)?; 34 | let tx_input_json = serde_json::to_value(&tx_input)?; 35 | let entries_json = serde_json::to_value(&entries)?; 36 | let record = sqlx::query!( 37 | r#"INSERT INTO sqlx_ledger_tx_templates (id, code, description, params, tx_input, entries, metadata) 38 | VALUES ($1, $2, $3, $4, $5, $6, $7) 39 | RETURNING id, version, created_at"#, 40 | id as TxTemplateId, 41 | code, 42 | description, 43 | params_json, 44 | tx_input_json, 45 | entries_json, 46 | metadata 47 | ) 48 | .fetch_one(&self.pool) 49 | .await?; 50 | Ok(TxTemplateId::from(record.id)) 51 | } 52 | 53 | #[instrument(level = "trace", name = "sqlx_ledger.tx_templates.find_core", skip_all)] 54 | pub(crate) async fn find_core( 55 | &self, 56 | code: &str, 57 | ) -> Result, SqlxLedgerError> { 58 | cached_find_core(&self.pool, code).await 59 | } 60 | } 61 | 62 | #[cached( 63 | key = "String", 64 | convert = r#"{ code.to_string() }"#, 65 | result = true, 66 | sync_writes = true 67 | )] 68 | async fn cached_find_core( 69 | pool: &Pool, 70 | code: &str, 71 | ) -> Result, SqlxLedgerError> { 72 | let record = sqlx::query!( 73 | r#"SELECT id, code, params, tx_input, entries FROM sqlx_ledger_tx_templates WHERE code = $1 LIMIT 1"#, 74 | code 75 | ) 76 | .fetch_one(pool) 77 | .await?; 78 | let params = match record.params { 79 | Some(serde_json::Value::Null) => None, 80 | Some(params) => Some(serde_json::from_value(params)?), 81 | None => None, 82 | }; 83 | let tx_input = serde_json::from_value(record.tx_input)?; 84 | Ok(Arc::new(TxTemplateCore { 85 | id: TxTemplateId::from(record.id), 86 | _code: record.code, 87 | params, 88 | entries: serde_json::from_value(record.entries)?, 89 | tx_input, 90 | })) 91 | } 92 | -------------------------------------------------------------------------------- /ledger/src/account/repo.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use sqlx::{Pool, Postgres, Transaction}; 3 | use tracing::instrument; 4 | 5 | use super::entity::*; 6 | use crate::{error::*, primitives::*}; 7 | 8 | /// Repository for working with `Account` entities. 9 | #[derive(Debug, Clone)] 10 | pub struct Accounts { 11 | pool: Pool, 12 | } 13 | 14 | impl Accounts { 15 | pub fn new(pool: &Pool) -> Self { 16 | Self { pool: pool.clone() } 17 | } 18 | 19 | pub async fn create(&self, new_account: NewAccount) -> Result { 20 | let mut tx = self.pool.begin().await?; 21 | let res = self.create_in_tx(&mut tx, new_account).await?; 22 | tx.commit().await?; 23 | Ok(res) 24 | } 25 | 26 | #[instrument(name = "sqlx_ledger.accounts.create", skip(self, tx))] 27 | pub async fn create_in_tx<'a>( 28 | &self, 29 | tx: &mut Transaction<'a, Postgres>, 30 | new_account: NewAccount, 31 | ) -> Result { 32 | let NewAccount { 33 | id, 34 | code, 35 | name, 36 | normal_balance_type, 37 | description, 38 | status, 39 | metadata, 40 | } = new_account; 41 | let record = sqlx::query!( 42 | r#"INSERT INTO sqlx_ledger_accounts (id, code, name, normal_balance_type, description, status, metadata) 43 | VALUES ($1, $2, $3, $4, $5, $6, $7) 44 | RETURNING id, version, created_at"#, 45 | id as AccountId, 46 | code, 47 | name, 48 | normal_balance_type as DebitOrCredit, 49 | description, 50 | status as Status, 51 | metadata 52 | ) 53 | .fetch_one(&mut **tx) 54 | .await?; 55 | Ok(AccountId::from(record.id)) 56 | } 57 | 58 | #[instrument(name = "sqlx_ledger.accounts.update", skip(self))] 59 | pub async fn update( 60 | &self, 61 | id: AccountId, 62 | description: Option, 63 | metadata: Option, 64 | ) -> Result { 65 | let metadata_json = match metadata { 66 | Some(m) => Some(serde_json::to_value(m)?), 67 | None => None, 68 | }; 69 | sqlx::query_file!( 70 | "src/account/sql/update-account.sql", 71 | id as AccountId, 72 | description, 73 | metadata_json 74 | ) 75 | .execute(&self.pool) 76 | .await?; 77 | Ok(id) 78 | } 79 | 80 | #[instrument(name = "sqlx_ledger.accounts.find_by_code", skip(self))] 81 | pub async fn find_by_code(&self, code: &str) -> Result, SqlxLedgerError> { 82 | let record = sqlx::query!( 83 | r#"SELECT id FROM sqlx_ledger_accounts WHERE code = $1 LIMIT 1"#, 84 | code 85 | ) 86 | .fetch_optional(&self.pool) 87 | .await?; 88 | Ok(record.map(|r| AccountId::from(r.id))) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /ledger/src/tx_template/param_definition.rs: -------------------------------------------------------------------------------- 1 | use cel_interpreter::{CelExpression, CelType, CelValue}; 2 | use derive_builder::Builder; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | /// Contains the parameters used to create a new `TxTemplate` 6 | #[derive(Clone, Debug, Deserialize, Serialize, Builder)] 7 | #[builder(build_fn(validate = "Self::validate"))] 8 | pub struct ParamDefinition { 9 | #[builder(setter(into))] 10 | pub(super) name: String, 11 | pub(super) r#type: ParamDataType, 12 | #[builder(setter(strip_option, name = "default_expr", into), default)] 13 | pub(super) default: Option, 14 | #[builder(setter(strip_option, into), default)] 15 | pub(super) description: Option, 16 | } 17 | 18 | impl ParamDefinition { 19 | pub fn builder() -> ParamDefinitionBuilder { 20 | ParamDefinitionBuilder::default() 21 | } 22 | 23 | pub fn default_expr(&self) -> Option { 24 | self.default 25 | .as_ref() 26 | .map(|v| v.parse().expect("Couldn't create default_expr")) 27 | } 28 | } 29 | 30 | impl ParamDefinitionBuilder { 31 | fn validate(&self) -> Result<(), String> { 32 | if let Some(Some(expr)) = self.default.as_ref() { 33 | let expr = CelExpression::try_from(expr.as_str()).map_err(|e| e.to_string())?; 34 | let param_type = ParamDataType::try_from( 35 | &expr 36 | .evaluate(&super::cel_context::initialize()) 37 | .map_err(|e| format!("{e}"))?, 38 | )?; 39 | let specified_type = self.r#type.as_ref().unwrap(); 40 | if ¶m_type != specified_type { 41 | return Err(format!( 42 | "Default expression type {param_type:?} does not match parameter type {specified_type:?}" 43 | )); 44 | } 45 | } 46 | Ok(()) 47 | } 48 | } 49 | 50 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 51 | pub enum ParamDataType { 52 | STRING, 53 | INTEGER, 54 | DECIMAL, 55 | BOOLEAN, 56 | UUID, 57 | DATE, 58 | TIMESTAMP, 59 | JSON, 60 | } 61 | 62 | impl TryFrom<&CelValue> for ParamDataType { 63 | type Error = String; 64 | 65 | fn try_from(value: &CelValue) -> Result { 66 | use cel_interpreter::CelType::*; 67 | match CelType::from(value) { 68 | Int => Ok(ParamDataType::INTEGER), 69 | String => Ok(ParamDataType::STRING), 70 | Map => Ok(ParamDataType::JSON), 71 | Date => Ok(ParamDataType::DATE), 72 | Uuid => Ok(ParamDataType::UUID), 73 | Decimal => Ok(ParamDataType::DECIMAL), 74 | Bool => Ok(ParamDataType::BOOLEAN), 75 | _ => Err(format!("Unsupported type: {value:?}")), 76 | } 77 | } 78 | } 79 | 80 | #[cfg(test)] 81 | mod tests { 82 | use super::*; 83 | 84 | #[test] 85 | fn build_param_definition() { 86 | let definition = ParamDefinition::builder() 87 | .name("name") 88 | .r#type(ParamDataType::JSON) 89 | .default_expr("{'key': 'value'}") 90 | .build() 91 | .unwrap(); 92 | assert_eq!(definition.name, "name"); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /ledger/.sqlx/query-b0c8cdc4866bee576bd760d04693a3e4cc83ddc151aab9f2eb1952bfa5e29cab.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT id, version, transaction_id, account_id, journal_id, entry_type, layer as \"layer: Layer\", units, currency, direction as \"direction: DebitOrCredit\", sequence, description, created_at, modified_at\n FROM sqlx_ledger_entries\n WHERE transaction_id = ANY($1) ORDER BY transaction_id ASC, sequence ASC, version DESC", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "version", 14 | "type_info": "Int4" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "transaction_id", 19 | "type_info": "Uuid" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "account_id", 24 | "type_info": "Uuid" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "journal_id", 29 | "type_info": "Uuid" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "entry_type", 34 | "type_info": "Varchar" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "layer: Layer", 39 | "type_info": { 40 | "Custom": { 41 | "name": "layer", 42 | "kind": { 43 | "Enum": [ 44 | "settled", 45 | "pending", 46 | "encumbered" 47 | ] 48 | } 49 | } 50 | } 51 | }, 52 | { 53 | "ordinal": 7, 54 | "name": "units", 55 | "type_info": "Numeric" 56 | }, 57 | { 58 | "ordinal": 8, 59 | "name": "currency", 60 | "type_info": "Varchar" 61 | }, 62 | { 63 | "ordinal": 9, 64 | "name": "direction: DebitOrCredit", 65 | "type_info": { 66 | "Custom": { 67 | "name": "debitorcredit", 68 | "kind": { 69 | "Enum": [ 70 | "debit", 71 | "credit" 72 | ] 73 | } 74 | } 75 | } 76 | }, 77 | { 78 | "ordinal": 10, 79 | "name": "sequence", 80 | "type_info": "Int4" 81 | }, 82 | { 83 | "ordinal": 11, 84 | "name": "description", 85 | "type_info": "Varchar" 86 | }, 87 | { 88 | "ordinal": 12, 89 | "name": "created_at", 90 | "type_info": "Timestamptz" 91 | }, 92 | { 93 | "ordinal": 13, 94 | "name": "modified_at", 95 | "type_info": "Timestamptz" 96 | } 97 | ], 98 | "parameters": { 99 | "Left": [ 100 | "UuidArray" 101 | ] 102 | }, 103 | "nullable": [ 104 | false, 105 | false, 106 | false, 107 | false, 108 | false, 109 | false, 110 | false, 111 | false, 112 | false, 113 | false, 114 | false, 115 | true, 116 | false, 117 | false 118 | ] 119 | }, 120 | "hash": "b0c8cdc4866bee576bd760d04693a3e4cc83ddc151aab9f2eb1952bfa5e29cab" 121 | } 122 | -------------------------------------------------------------------------------- /ci/pipeline.yml: -------------------------------------------------------------------------------- 1 | #@ load("@ytt:data", "data") 2 | 3 | #@ load("vendor/pipeline-fragments.lib.yml", 4 | #@ "build_edge_image", 5 | #@ "public_docker_registry", 6 | #@ "rust_check_code", 7 | #@ "test_on_docker_host", 8 | #@ "docker_host_pool", 9 | #@ "repo_resource", 10 | #@ "edge_image_resource", 11 | #@ "version_resource", 12 | #@ "gh_release_resource", 13 | #@ "pipeline_tasks_resource", 14 | #@ "release_task_image_config", 15 | #@ "rust_task_image_config", 16 | #@ "charts_repo_resource", 17 | #@ "charts_repo_bot_branch", 18 | #@ "slack_resource_type", 19 | #@ "slack_resource", 20 | #@ "slack_failure_notification" 21 | #@ ) 22 | 23 | groups: 24 | - name: sqlx-ledger 25 | jobs: 26 | - check-code 27 | - integration-tests 28 | - release 29 | - set-dev-version 30 | 31 | jobs: 32 | - #@ rust_check_code() 33 | - #@ test_on_docker_host("integration-tests") 34 | 35 | - name: release 36 | serial: true 37 | plan: 38 | - in_parallel: 39 | - get: repo 40 | passed: 41 | - integration-tests 42 | - check-code 43 | - get: pipeline-tasks 44 | - get: version 45 | - task: prep-release 46 | config: 47 | platform: linux 48 | image_resource: #@ release_task_image_config() 49 | inputs: 50 | - name: pipeline-tasks 51 | - name: repo 52 | - name: version 53 | outputs: 54 | - name: version 55 | - name: artifacts 56 | run: 57 | path: pipeline-tasks/ci/vendor/tasks/prep-release-src.sh 58 | - task: update-repo 59 | config: 60 | platform: linux 61 | image_resource: #@ rust_task_image_config() 62 | inputs: 63 | - name: artifacts 64 | - name: pipeline-tasks 65 | - name: repo 66 | - name: version 67 | outputs: 68 | - name: repo 69 | run: 70 | path: pipeline-tasks/ci/tasks/update-repo.sh 71 | - task: publish-to-crates 72 | config: 73 | image_resource: #@ rust_task_image_config() 74 | platform: linux 75 | inputs: 76 | - name: version 77 | - name: pipeline-tasks 78 | - name: repo 79 | params: 80 | CRATES_API_TOKEN: #@ data.values.crates_api_token 81 | caches: 82 | - path: cargo-home 83 | - path: cargo-target-dir 84 | run: 85 | path: pipeline-tasks/ci/tasks/publish-to-crates.sh 86 | - put: repo 87 | params: 88 | tag: artifacts/gh-release-tag 89 | repository: repo 90 | merge: true 91 | - put: version 92 | params: 93 | file: version/version 94 | - put: gh-release 95 | params: 96 | name: artifacts/gh-release-name 97 | tag: artifacts/gh-release-tag 98 | body: artifacts/gh-release-notes.md 99 | 100 | - name: set-dev-version 101 | plan: 102 | - in_parallel: 103 | - { get: repo, passed: [release] } 104 | - { get: pipeline-tasks } 105 | - get: version 106 | trigger: true 107 | params: { bump: patch } 108 | passed: [release] 109 | - task: set-dev-version 110 | config: 111 | image_resource: #@ release_task_image_config() 112 | platform: linux 113 | inputs: 114 | - name: version 115 | - name: repo 116 | - name: pipeline-tasks 117 | outputs: 118 | - name: repo 119 | run: 120 | path: pipeline-tasks/ci/tasks/set-dev-version.sh 121 | params: 122 | BRANCH: #@ data.values.git_branch 123 | - put: repo 124 | params: 125 | repository: repo 126 | rebase: true 127 | 128 | resources: 129 | - #@ repo_resource(True) 130 | - #@ pipeline_tasks_resource() 131 | - #@ slack_resource() 132 | - #@ version_resource() 133 | - #@ gh_release_resource() 134 | - #@ docker_host_pool() 135 | 136 | resource_types: 137 | - #@ slack_resource_type() 138 | -------------------------------------------------------------------------------- /ledger/.sqlx/query-bf2e1148f97897ad3ec3ffc607d849a381c14f64e2e8fc7ce6396933f99ed980.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT\n a.normal_balance_type as \"normal_balance_type: DebitOrCredit\", b.journal_id, b.account_id, entry_id, b.currency,\n settled_dr_balance, settled_cr_balance, settled_entry_id, settled_modified_at,\n pending_dr_balance, pending_cr_balance, pending_entry_id, pending_modified_at,\n encumbered_dr_balance, encumbered_cr_balance, encumbered_entry_id, encumbered_modified_at,\n c.version, modified_at, created_at\n FROM sqlx_ledger_balances b JOIN (\n SELECT * FROM sqlx_ledger_current_balances WHERE journal_id = $1 AND account_id = ANY($2)) c\n ON b.journal_id = c.journal_id AND b.account_id = c.account_id AND b.currency = c.currency AND b.version = c.version\n JOIN ( SELECT DISTINCT(id), normal_balance_type FROM sqlx_ledger_accounts WHERE id = ANY($2)) a\n ON a.id = b.account_id", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "normal_balance_type: DebitOrCredit", 9 | "type_info": { 10 | "Custom": { 11 | "name": "debitorcredit", 12 | "kind": { 13 | "Enum": [ 14 | "debit", 15 | "credit" 16 | ] 17 | } 18 | } 19 | } 20 | }, 21 | { 22 | "ordinal": 1, 23 | "name": "journal_id", 24 | "type_info": "Uuid" 25 | }, 26 | { 27 | "ordinal": 2, 28 | "name": "account_id", 29 | "type_info": "Uuid" 30 | }, 31 | { 32 | "ordinal": 3, 33 | "name": "entry_id", 34 | "type_info": "Uuid" 35 | }, 36 | { 37 | "ordinal": 4, 38 | "name": "currency", 39 | "type_info": "Varchar" 40 | }, 41 | { 42 | "ordinal": 5, 43 | "name": "settled_dr_balance", 44 | "type_info": "Numeric" 45 | }, 46 | { 47 | "ordinal": 6, 48 | "name": "settled_cr_balance", 49 | "type_info": "Numeric" 50 | }, 51 | { 52 | "ordinal": 7, 53 | "name": "settled_entry_id", 54 | "type_info": "Uuid" 55 | }, 56 | { 57 | "ordinal": 8, 58 | "name": "settled_modified_at", 59 | "type_info": "Timestamptz" 60 | }, 61 | { 62 | "ordinal": 9, 63 | "name": "pending_dr_balance", 64 | "type_info": "Numeric" 65 | }, 66 | { 67 | "ordinal": 10, 68 | "name": "pending_cr_balance", 69 | "type_info": "Numeric" 70 | }, 71 | { 72 | "ordinal": 11, 73 | "name": "pending_entry_id", 74 | "type_info": "Uuid" 75 | }, 76 | { 77 | "ordinal": 12, 78 | "name": "pending_modified_at", 79 | "type_info": "Timestamptz" 80 | }, 81 | { 82 | "ordinal": 13, 83 | "name": "encumbered_dr_balance", 84 | "type_info": "Numeric" 85 | }, 86 | { 87 | "ordinal": 14, 88 | "name": "encumbered_cr_balance", 89 | "type_info": "Numeric" 90 | }, 91 | { 92 | "ordinal": 15, 93 | "name": "encumbered_entry_id", 94 | "type_info": "Uuid" 95 | }, 96 | { 97 | "ordinal": 16, 98 | "name": "encumbered_modified_at", 99 | "type_info": "Timestamptz" 100 | }, 101 | { 102 | "ordinal": 17, 103 | "name": "version", 104 | "type_info": "Int4" 105 | }, 106 | { 107 | "ordinal": 18, 108 | "name": "modified_at", 109 | "type_info": "Timestamptz" 110 | }, 111 | { 112 | "ordinal": 19, 113 | "name": "created_at", 114 | "type_info": "Timestamptz" 115 | } 116 | ], 117 | "parameters": { 118 | "Left": [ 119 | "Uuid", 120 | "UuidArray" 121 | ] 122 | }, 123 | "nullable": [ 124 | false, 125 | false, 126 | false, 127 | false, 128 | false, 129 | false, 130 | false, 131 | false, 132 | false, 133 | false, 134 | false, 135 | false, 136 | false, 137 | false, 138 | false, 139 | false, 140 | false, 141 | false, 142 | false, 143 | false 144 | ] 145 | }, 146 | "hash": "bf2e1148f97897ad3ec3ffc607d849a381c14f64e2e8fc7ce6396933f99ed980" 147 | } 148 | -------------------------------------------------------------------------------- /ledger/.sqlx/query-8362c8aebe79065e4f4559e8a8142e5653f3ddd3e98222a878b1dda2d3dd12a6.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT\n a.normal_balance_type as \"normal_balance_type: DebitOrCredit\", b.journal_id, b.account_id, entry_id, b.currency,\n settled_dr_balance, settled_cr_balance, settled_entry_id, settled_modified_at,\n pending_dr_balance, pending_cr_balance, pending_entry_id, pending_modified_at,\n encumbered_dr_balance, encumbered_cr_balance, encumbered_entry_id, encumbered_modified_at,\n c.version, modified_at, created_at\n FROM sqlx_ledger_balances b JOIN (\n SELECT * FROM sqlx_ledger_current_balances WHERE journal_id = $1 AND account_id = $2 AND currency = $3 ) c\n ON b.journal_id = c.journal_id AND b.account_id = c.account_id AND b.currency = c.currency AND b.version = c.version\n JOIN ( SELECT id, normal_balance_type FROM sqlx_ledger_accounts WHERE id = $2 LIMIT 1 ) a\n ON a.id = b.account_id", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "normal_balance_type: DebitOrCredit", 9 | "type_info": { 10 | "Custom": { 11 | "name": "debitorcredit", 12 | "kind": { 13 | "Enum": [ 14 | "debit", 15 | "credit" 16 | ] 17 | } 18 | } 19 | } 20 | }, 21 | { 22 | "ordinal": 1, 23 | "name": "journal_id", 24 | "type_info": "Uuid" 25 | }, 26 | { 27 | "ordinal": 2, 28 | "name": "account_id", 29 | "type_info": "Uuid" 30 | }, 31 | { 32 | "ordinal": 3, 33 | "name": "entry_id", 34 | "type_info": "Uuid" 35 | }, 36 | { 37 | "ordinal": 4, 38 | "name": "currency", 39 | "type_info": "Varchar" 40 | }, 41 | { 42 | "ordinal": 5, 43 | "name": "settled_dr_balance", 44 | "type_info": "Numeric" 45 | }, 46 | { 47 | "ordinal": 6, 48 | "name": "settled_cr_balance", 49 | "type_info": "Numeric" 50 | }, 51 | { 52 | "ordinal": 7, 53 | "name": "settled_entry_id", 54 | "type_info": "Uuid" 55 | }, 56 | { 57 | "ordinal": 8, 58 | "name": "settled_modified_at", 59 | "type_info": "Timestamptz" 60 | }, 61 | { 62 | "ordinal": 9, 63 | "name": "pending_dr_balance", 64 | "type_info": "Numeric" 65 | }, 66 | { 67 | "ordinal": 10, 68 | "name": "pending_cr_balance", 69 | "type_info": "Numeric" 70 | }, 71 | { 72 | "ordinal": 11, 73 | "name": "pending_entry_id", 74 | "type_info": "Uuid" 75 | }, 76 | { 77 | "ordinal": 12, 78 | "name": "pending_modified_at", 79 | "type_info": "Timestamptz" 80 | }, 81 | { 82 | "ordinal": 13, 83 | "name": "encumbered_dr_balance", 84 | "type_info": "Numeric" 85 | }, 86 | { 87 | "ordinal": 14, 88 | "name": "encumbered_cr_balance", 89 | "type_info": "Numeric" 90 | }, 91 | { 92 | "ordinal": 15, 93 | "name": "encumbered_entry_id", 94 | "type_info": "Uuid" 95 | }, 96 | { 97 | "ordinal": 16, 98 | "name": "encumbered_modified_at", 99 | "type_info": "Timestamptz" 100 | }, 101 | { 102 | "ordinal": 17, 103 | "name": "version", 104 | "type_info": "Int4" 105 | }, 106 | { 107 | "ordinal": 18, 108 | "name": "modified_at", 109 | "type_info": "Timestamptz" 110 | }, 111 | { 112 | "ordinal": 19, 113 | "name": "created_at", 114 | "type_info": "Timestamptz" 115 | } 116 | ], 117 | "parameters": { 118 | "Left": [ 119 | "Uuid", 120 | "Uuid", 121 | "Text" 122 | ] 123 | }, 124 | "nullable": [ 125 | false, 126 | false, 127 | false, 128 | false, 129 | false, 130 | false, 131 | false, 132 | false, 133 | false, 134 | false, 135 | false, 136 | false, 137 | false, 138 | false, 139 | false, 140 | false, 141 | false, 142 | false, 143 | false, 144 | false 145 | ] 146 | }, 147 | "hash": "8362c8aebe79065e4f4559e8a8142e5653f3ddd3e98222a878b1dda2d3dd12a6" 148 | } 149 | -------------------------------------------------------------------------------- /ledger/src/primitives.rs: -------------------------------------------------------------------------------- 1 | use crate::error::*; 2 | use rusty_money::{crypto, iso}; 3 | 4 | use cel_interpreter::{CelResult, CelValue}; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | crate::entity_id! { AccountId } 8 | crate::entity_id! { JournalId } 9 | crate::entity_id! { TransactionId } 10 | crate::entity_id! { EntryId } 11 | crate::entity_id! { TxTemplateId } 12 | crate::entity_id! { CorrelationId } 13 | 14 | #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)] 15 | #[sqlx(type_name = "Layer", rename_all = "snake_case")] 16 | pub enum Layer { 17 | Settled, 18 | Pending, 19 | Encumbered, 20 | } 21 | 22 | impl<'a> TryFrom> for Layer { 23 | type Error = SqlxLedgerError; 24 | 25 | fn try_from(CelResult { val, .. }: CelResult) -> Result { 26 | match val { 27 | CelValue::String(v) if v.as_ref() == "SETTLED" => Ok(Layer::Settled), 28 | CelValue::String(v) if v.as_ref() == "PENDING" => Ok(Layer::Pending), 29 | CelValue::String(v) if v.as_ref() == "ENCUMBERED" => Ok(Layer::Encumbered), 30 | v => Err(SqlxLedgerError::UnknownLayer(format!("{v:?}"))), 31 | } 32 | } 33 | } 34 | 35 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, sqlx::Type)] 36 | #[sqlx(type_name = "DebitOrCredit", rename_all = "snake_case")] 37 | pub enum DebitOrCredit { 38 | Debit, 39 | #[default] 40 | Credit, 41 | } 42 | 43 | impl<'a> TryFrom> for DebitOrCredit { 44 | type Error = SqlxLedgerError; 45 | 46 | fn try_from(CelResult { val, .. }: CelResult) -> Result { 47 | match val { 48 | CelValue::String(v) if v.as_ref() == "DEBIT" => Ok(DebitOrCredit::Debit), 49 | CelValue::String(v) if v.as_ref() == "CREDIT" => Ok(DebitOrCredit::Credit), 50 | v => Err(SqlxLedgerError::UnknownDebitOrCredit(format!("{v:?}"))), 51 | } 52 | } 53 | } 54 | 55 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, sqlx::Type)] 56 | #[sqlx(type_name = "Status", rename_all = "snake_case")] 57 | pub enum Status { 58 | #[default] 59 | Active, 60 | } 61 | 62 | #[derive(Debug, Clone, Copy, Eq, Serialize, Deserialize)] 63 | #[serde(try_from = "String")] 64 | #[serde(into = "&str")] 65 | pub enum Currency { 66 | Iso(&'static iso::Currency), 67 | Crypto(&'static crypto::Currency), 68 | } 69 | 70 | impl Currency { 71 | pub fn code(&self) -> &'static str { 72 | match self { 73 | Currency::Iso(c) => c.iso_alpha_code, 74 | Currency::Crypto(c) => c.code, 75 | } 76 | } 77 | } 78 | 79 | impl std::fmt::Display for Currency { 80 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 81 | write!(f, "{}", self.code()) 82 | } 83 | } 84 | 85 | impl std::hash::Hash for Currency { 86 | fn hash(&self, state: &mut H) { 87 | self.code().hash(state); 88 | } 89 | } 90 | 91 | impl PartialEq for Currency { 92 | fn eq(&self, other: &Self) -> bool { 93 | self.code() == other.code() 94 | } 95 | } 96 | 97 | impl std::str::FromStr for Currency { 98 | type Err = SqlxLedgerError; 99 | 100 | fn from_str(s: &str) -> Result { 101 | match iso::find(s) { 102 | Some(c) => Ok(Currency::Iso(c)), 103 | _ => match crypto::find(s) { 104 | Some(c) => Ok(Currency::Crypto(c)), 105 | _ => Err(SqlxLedgerError::UnknownCurrency(s.to_string())), 106 | }, 107 | } 108 | } 109 | } 110 | 111 | impl TryFrom for Currency { 112 | type Error = SqlxLedgerError; 113 | 114 | fn try_from(s: String) -> Result { 115 | s.parse() 116 | } 117 | } 118 | 119 | impl From for &'static str { 120 | fn from(c: Currency) -> Self { 121 | c.code() 122 | } 123 | } 124 | 125 | impl<'a> TryFrom> for Currency { 126 | type Error = SqlxLedgerError; 127 | 128 | fn try_from(CelResult { val, .. }: CelResult) -> Result { 129 | match val { 130 | CelValue::String(v) => v.as_ref().parse(), 131 | v => Err(SqlxLedgerError::UnknownCurrency(format!("{v:?}"))), 132 | } 133 | } 134 | } 135 | 136 | impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for Currency 137 | where 138 | &'r str: sqlx::Decode<'r, DB>, 139 | { 140 | fn decode( 141 | value: ::ValueRef<'r>, 142 | ) -> Result> { 143 | let value = <&str as sqlx::Decode>::decode(value)?; 144 | 145 | Ok(value.parse().map_err(Box::new)?) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /ledger/src/tx_template/core.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDate; 2 | use rust_decimal::Decimal; 3 | use serde::Deserialize; 4 | use tracing::instrument; 5 | use uuid::Uuid; 6 | 7 | use std::collections::HashMap; 8 | 9 | use crate::{entry::*, error::*, primitives::*, transaction::NewTransaction}; 10 | use cel_interpreter::{CelContext, CelExpression}; 11 | 12 | use super::{param_definition::ParamDefinition, tx_params::TxParams}; 13 | 14 | #[derive(Debug, Clone, Deserialize)] 15 | pub(crate) struct TxInputCel { 16 | effective: CelExpression, 17 | journal_id: CelExpression, 18 | correlation_id: Option, 19 | external_id: Option, 20 | description: Option, 21 | metadata: Option, 22 | } 23 | 24 | #[derive(Debug, Clone, Deserialize)] 25 | pub(crate) struct EntryCel { 26 | entry_type: CelExpression, 27 | account_id: CelExpression, 28 | layer: CelExpression, 29 | direction: CelExpression, 30 | units: CelExpression, 31 | currency: CelExpression, 32 | description: Option, 33 | } 34 | 35 | #[derive(Debug, Clone)] 36 | pub(crate) struct TxTemplateCore { 37 | pub(super) id: TxTemplateId, 38 | pub(super) _code: String, 39 | pub(super) params: Option>, 40 | pub(super) tx_input: TxInputCel, 41 | pub(super) entries: Vec, 42 | } 43 | 44 | impl TxTemplateCore { 45 | #[instrument(level = "trace", name = "sqlx_ledger.tx_template_core.prep_tx")] 46 | pub(crate) fn prep_tx( 47 | &self, 48 | params: TxParams, 49 | ) -> Result<(NewTransaction, Vec), SqlxLedgerError> { 50 | let mut tx_builder = NewTransaction::builder(); 51 | tx_builder.tx_template_id(self.id); 52 | 53 | let ctx = params.to_context(self.params.as_ref())?; 54 | 55 | let journal_id: Uuid = self.tx_input.journal_id.try_evaluate(&ctx)?; 56 | tx_builder.journal_id(journal_id); 57 | 58 | let effective: NaiveDate = self.tx_input.effective.try_evaluate(&ctx)?; 59 | tx_builder.effective(effective); 60 | 61 | if let Some(correlation_id) = self.tx_input.correlation_id.as_ref() { 62 | let correlation_id: Uuid = correlation_id.try_evaluate(&ctx)?; 63 | tx_builder.correlation_id(correlation_id.into()); 64 | } 65 | 66 | if let Some(external_id) = self.tx_input.external_id.as_ref() { 67 | let external_id: String = external_id.try_evaluate(&ctx)?; 68 | tx_builder.external_id(external_id); 69 | } 70 | 71 | if let Some(description) = self.tx_input.description.as_ref() { 72 | let description: String = description.try_evaluate(&ctx)?; 73 | tx_builder.description(description); 74 | } 75 | 76 | if let Some(metadata) = self.tx_input.metadata.as_ref() { 77 | let metadata: serde_json::Value = metadata.try_evaluate(&ctx)?; 78 | tx_builder.metadata(metadata); 79 | } 80 | 81 | let tx = tx_builder.build().expect("tx_build should succeed"); 82 | let entries = self.prep_entries(ctx)?; 83 | 84 | Ok((tx, entries)) 85 | } 86 | 87 | fn prep_entries(&self, ctx: CelContext) -> Result, SqlxLedgerError> { 88 | let mut new_entries = Vec::new(); 89 | let mut totals = HashMap::new(); 90 | for entry in self.entries.iter() { 91 | let mut builder = NewEntry::builder(); 92 | let account_id: Uuid = entry.account_id.try_evaluate(&ctx)?; 93 | builder.account_id(account_id.into()); 94 | 95 | let entry_type: String = entry.entry_type.try_evaluate(&ctx)?; 96 | builder.entry_type(entry_type); 97 | 98 | let layer: Layer = entry.layer.try_evaluate(&ctx)?; 99 | builder.layer(layer); 100 | 101 | let units: Decimal = entry.units.try_evaluate(&ctx)?; 102 | let currency: Currency = entry.currency.try_evaluate(&ctx)?; 103 | let direction: DebitOrCredit = entry.direction.try_evaluate(&ctx)?; 104 | 105 | let total = totals.entry(currency).or_insert(Decimal::ZERO); 106 | match direction { 107 | DebitOrCredit::Debit => *total -= units, 108 | DebitOrCredit::Credit => *total += units, 109 | }; 110 | builder.units(units); 111 | builder.currency(currency); 112 | builder.direction(direction); 113 | 114 | if let Some(description) = entry.description.as_ref() { 115 | let description: String = description.try_evaluate(&ctx)?; 116 | builder.description(description); 117 | } 118 | 119 | new_entries.push(builder.build().expect("Couldn't build entry")); 120 | } 121 | 122 | for (k, v) in totals { 123 | if v != Decimal::ZERO { 124 | return Err(SqlxLedgerError::UnbalancedTransaction(k, v)); 125 | } 126 | } 127 | 128 | Ok(new_entries) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /ledger/src/ledger/mod.rs: -------------------------------------------------------------------------------- 1 | use sqlx::{Acquire, PgPool, Postgres, Transaction}; 2 | use tracing::instrument; 3 | 4 | use std::collections::HashMap; 5 | 6 | use crate::{ 7 | account::Accounts, balance::*, entry::*, error::*, event::*, journal::*, primitives::*, 8 | transaction::*, tx_template::*, 9 | }; 10 | 11 | #[derive(Debug, Clone)] 12 | pub struct SqlxLedger { 13 | pool: PgPool, 14 | accounts: Accounts, 15 | journals: Journals, 16 | tx_templates: TxTemplates, 17 | transactions: Transactions, 18 | entries: Entries, 19 | balances: Balances, 20 | } 21 | 22 | impl SqlxLedger { 23 | pub fn new(pool: &PgPool) -> Self { 24 | Self { 25 | accounts: Accounts::new(pool), 26 | journals: Journals::new(pool), 27 | tx_templates: TxTemplates::new(pool), 28 | transactions: Transactions::new(pool), 29 | entries: Entries::new(pool), 30 | balances: Balances::new(pool), 31 | pool: pool.clone(), 32 | } 33 | } 34 | 35 | pub fn accounts(&self) -> &Accounts { 36 | &self.accounts 37 | } 38 | 39 | pub fn journals(&self) -> &Journals { 40 | &self.journals 41 | } 42 | 43 | pub fn tx_templates(&self) -> &TxTemplates { 44 | &self.tx_templates 45 | } 46 | 47 | pub fn entries(&self) -> &Entries { 48 | &self.entries 49 | } 50 | 51 | pub fn balances(&self) -> &Balances { 52 | &self.balances 53 | } 54 | 55 | pub fn transactions(&self) -> &Transactions { 56 | &self.transactions 57 | } 58 | 59 | pub async fn post_transaction( 60 | &self, 61 | tx_id: TransactionId, 62 | tx_template_code: &str, 63 | params: Option + std::fmt::Debug>, 64 | ) -> Result<(), SqlxLedgerError> { 65 | let tx = self.pool.begin().await?; 66 | self.post_transaction_in_tx(tx, tx_id, tx_template_code, params) 67 | .await?; 68 | Ok(()) 69 | } 70 | 71 | #[instrument(name = "sqlx_ledger.ledger.post_transaction", skip(self, tx))] 72 | pub async fn post_transaction_in_tx( 73 | &self, 74 | mut tx: Transaction<'_, Postgres>, 75 | tx_id: TransactionId, 76 | tx_template_code: &str, 77 | params: Option + std::fmt::Debug>, 78 | ) -> Result<(), SqlxLedgerError> { 79 | let tx_template = self.tx_templates.find_core(tx_template_code).await?; 80 | let (new_tx, new_entries) = 81 | tx_template.prep_tx(params.map(|p| p.into()).unwrap_or_default())?; 82 | let (journal_id, tx_id) = self 83 | .transactions 84 | .create_in_tx(&mut tx, tx_id, new_tx) 85 | .await?; 86 | let entries = self 87 | .entries 88 | .create_all(journal_id, tx_id, new_entries, &mut tx) 89 | .await?; 90 | { 91 | let ids: Vec<(AccountId, &Currency)> = entries 92 | .iter() 93 | .map(|entry| (entry.account_id, &entry.currency)) 94 | .collect(); 95 | let mut balance_tx = tx.begin().await?; 96 | 97 | let mut balances = self 98 | .balances 99 | .find_for_update(journal_id, ids.clone(), &mut balance_tx) 100 | .await?; 101 | let mut latest_balances: HashMap<(AccountId, &Currency), BalanceDetails> = 102 | HashMap::new(); 103 | let mut new_balances = Vec::new(); 104 | for entry in entries.iter() { 105 | let balance = match ( 106 | latest_balances.remove(&(entry.account_id, &entry.currency)), 107 | balances.remove(&(entry.account_id, entry.currency)), 108 | ) { 109 | (Some(latest), _) => { 110 | new_balances.push(latest.clone()); 111 | latest 112 | } 113 | (_, Some(balance)) => balance, 114 | _ => { 115 | latest_balances.insert( 116 | (entry.account_id, &entry.currency), 117 | BalanceDetails::init(journal_id, entry), 118 | ); 119 | continue; 120 | } 121 | }; 122 | latest_balances.insert((entry.account_id, &entry.currency), balance.update(entry)); 123 | } 124 | new_balances.extend(latest_balances.into_values()); 125 | 126 | self.balances 127 | .update_balances(journal_id, new_balances, &mut balance_tx) 128 | .await?; 129 | balance_tx.commit().await?; 130 | } 131 | tx.commit().await?; 132 | Ok(()) 133 | } 134 | 135 | pub async fn events( 136 | &self, 137 | opts: EventSubscriberOpts, 138 | ) -> Result { 139 | EventSubscriber::connect(&self.pool, opts).await 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /cel-parser/src/ast.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | #[derive(Debug, Eq, PartialEq, Clone)] 4 | pub enum LogicOp { 5 | And, 6 | Or, 7 | } 8 | 9 | #[derive(Debug, Eq, PartialEq, Clone, Copy)] 10 | pub enum RelationOp { 11 | LessThan, 12 | LessThanEq, 13 | GreaterThan, 14 | GreaterThanEq, 15 | Equals, 16 | NotEquals, 17 | In, 18 | } 19 | 20 | #[derive(Debug, Eq, PartialEq, Clone, Copy)] 21 | pub enum ArithmeticOp { 22 | Add, 23 | Subtract, 24 | Divide, 25 | Multiply, 26 | Modulus, 27 | } 28 | 29 | #[derive(Debug, Eq, PartialEq, Clone)] 30 | pub enum UnaryOp { 31 | Not, 32 | DoubleNot, 33 | Minus, 34 | DoubleMinus, 35 | } 36 | 37 | #[derive(Debug, Eq, PartialEq, Clone)] 38 | pub enum LeftRightOp { 39 | Logic(LogicOp), 40 | Relation(RelationOp), 41 | Arithmetic(ArithmeticOp), 42 | } 43 | 44 | #[derive(Debug, PartialEq, Clone)] 45 | pub enum Expression { 46 | Ternary(Box, Box, Box), 47 | Relation(RelationOp, Box, Box), 48 | Arithmetic(ArithmeticOp, Box, Box), 49 | Unary(UnaryOp, Box), 50 | 51 | Member(Box, Box), 52 | 53 | List(Vec), 54 | Map(Vec<(Expression, Expression)>), 55 | Struct(Vec>, Vec<(Arc, Expression)>), 56 | 57 | Literal(Literal), 58 | Ident(Arc), 59 | } 60 | 61 | impl Expression { 62 | pub(crate) fn from_op(op: LeftRightOp, left: Box, right: Box) -> Self { 63 | use LeftRightOp::*; 64 | match op { 65 | Logic(LogicOp::Or) => Expression::Ternary( 66 | left, 67 | Box::new(Expression::Literal(Literal::Bool(true))), 68 | right, 69 | ), 70 | Logic(LogicOp::And) => Expression::Ternary( 71 | left, 72 | right, 73 | Box::new(Expression::Literal(Literal::Bool(false))), 74 | ), 75 | Relation(op) => Expression::Relation(op, left, right), 76 | Arithmetic(op) => Expression::Arithmetic(op, left, right), 77 | } 78 | } 79 | } 80 | 81 | #[derive(Debug, PartialEq, Clone)] 82 | pub enum Member { 83 | Attribute(Arc), 84 | FunctionCall(Vec), 85 | Index(Box), 86 | } 87 | 88 | #[derive(Debug, PartialEq, Eq, Clone)] 89 | pub enum Literal { 90 | Int(i64), 91 | UInt(u64), 92 | Double(Arc), 93 | String(Arc), 94 | Bytes(Arc>), 95 | Bool(bool), 96 | Null, 97 | } 98 | 99 | #[cfg(test)] 100 | mod tests { 101 | use crate::parser::ExpressionParser; 102 | use crate::{ArithmeticOp::*, Expression, Expression::*, Literal::*, Member::*}; 103 | 104 | fn parse(input: &str) -> Expression { 105 | ExpressionParser::new() 106 | .parse(input) 107 | .unwrap_or_else(|e| panic!("{}", e)) 108 | } 109 | 110 | fn assert_parse_eq(input: &str, expected: Expression) { 111 | assert_eq!(parse(input), expected); 112 | } 113 | 114 | #[test] 115 | fn op_precedence() { 116 | assert_parse_eq( 117 | "1 + 2 * 3", 118 | Arithmetic( 119 | Add, 120 | Literal(Int(1)).into(), 121 | Arithmetic(Multiply, Literal(Int(2)).into(), Literal(Int(3)).into()).into(), 122 | ), 123 | ); 124 | assert_parse_eq( 125 | "1 * 2 + 3", 126 | Arithmetic( 127 | Add, 128 | Arithmetic(Multiply, Literal(Int(1)).into(), Literal(Int(2)).into()).into(), 129 | Literal(Int(3)).into(), 130 | ), 131 | ); 132 | assert_parse_eq( 133 | "1 * (2 + 3)", 134 | Arithmetic( 135 | Multiply, 136 | Literal(Int(1)).into(), 137 | Arithmetic(Add, Literal(Int(2)).into(), Literal(Int(3)).into()).into(), 138 | ), 139 | ) 140 | } 141 | 142 | #[test] 143 | fn simple_int() { 144 | assert_parse_eq("1", Literal(Int(1))) 145 | } 146 | 147 | #[test] 148 | fn simple_float() { 149 | assert_parse_eq("1.0", Literal(Double("1.0".to_string().into()))) 150 | } 151 | 152 | #[test] 153 | fn lookup() { 154 | assert_parse_eq( 155 | "hello.world", 156 | Member( 157 | Ident("hello".to_string().into()).into(), 158 | Attribute("world".to_string().into()).into(), 159 | ), 160 | ) 161 | } 162 | 163 | #[test] 164 | fn nested_attributes() { 165 | assert_parse_eq( 166 | "a.b[1]", 167 | Member( 168 | Member( 169 | Ident("a".to_string().into()).into(), 170 | Attribute("b".to_string().into()).into(), 171 | ) 172 | .into(), 173 | Index(Literal(Int(1)).into()).into(), 174 | ), 175 | ) 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /ledger/src/balance/entity.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use rust_decimal::Decimal; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::entry::StagedEntry; 6 | use crate::primitives::*; 7 | 8 | /// Representation of account's balance tracked in 3 distinct layers. 9 | #[derive(Debug, Clone)] 10 | pub struct AccountBalance { 11 | pub(super) balance_type: DebitOrCredit, 12 | pub details: BalanceDetails, 13 | } 14 | 15 | impl AccountBalance { 16 | pub fn pending(&self) -> Decimal { 17 | if self.balance_type == DebitOrCredit::Credit { 18 | self.details.pending_cr_balance - self.details.pending_dr_balance 19 | } else { 20 | self.details.pending_dr_balance - self.details.pending_cr_balance 21 | } 22 | } 23 | 24 | pub fn settled(&self) -> Decimal { 25 | if self.balance_type == DebitOrCredit::Credit { 26 | self.details.settled_cr_balance - self.details.settled_dr_balance 27 | } else { 28 | self.details.settled_dr_balance - self.details.settled_cr_balance 29 | } 30 | } 31 | 32 | pub fn encumbered(&self) -> Decimal { 33 | if self.balance_type == DebitOrCredit::Credit { 34 | self.details.encumbered_cr_balance - self.details.encumbered_dr_balance 35 | } else { 36 | self.details.encumbered_dr_balance - self.details.encumbered_cr_balance 37 | } 38 | } 39 | } 40 | 41 | /// Contains the details of the balance and methods to update from new 42 | /// entries. 43 | #[derive(Debug, Clone, Serialize, Deserialize)] 44 | pub struct BalanceDetails { 45 | pub journal_id: JournalId, 46 | pub account_id: AccountId, 47 | pub entry_id: EntryId, 48 | pub currency: Currency, 49 | pub settled_dr_balance: Decimal, 50 | pub settled_cr_balance: Decimal, 51 | pub settled_entry_id: EntryId, 52 | pub settled_modified_at: DateTime, 53 | pub pending_dr_balance: Decimal, 54 | pub pending_cr_balance: Decimal, 55 | pub pending_entry_id: EntryId, 56 | pub pending_modified_at: DateTime, 57 | pub encumbered_dr_balance: Decimal, 58 | pub encumbered_cr_balance: Decimal, 59 | pub encumbered_entry_id: EntryId, 60 | pub encumbered_modified_at: DateTime, 61 | pub version: i32, 62 | pub modified_at: DateTime, 63 | pub created_at: DateTime, 64 | } 65 | 66 | impl BalanceDetails { 67 | pub(crate) fn update(self, entry: &StagedEntry) -> Self { 68 | self.update_inner(entry) 69 | } 70 | 71 | pub(crate) fn init(journal_id: JournalId, entry: &StagedEntry) -> Self { 72 | Self { 73 | journal_id, 74 | account_id: entry.account_id, 75 | entry_id: entry.entry_id, 76 | currency: entry.currency, 77 | settled_dr_balance: Decimal::ZERO, 78 | settled_cr_balance: Decimal::ZERO, 79 | settled_entry_id: entry.entry_id, 80 | settled_modified_at: entry.created_at, 81 | pending_dr_balance: Decimal::ZERO, 82 | pending_cr_balance: Decimal::ZERO, 83 | pending_entry_id: entry.entry_id, 84 | pending_modified_at: entry.created_at, 85 | encumbered_dr_balance: Decimal::ZERO, 86 | encumbered_cr_balance: Decimal::ZERO, 87 | encumbered_entry_id: entry.entry_id, 88 | encumbered_modified_at: entry.created_at, 89 | version: 0, 90 | modified_at: entry.created_at, 91 | created_at: entry.created_at, 92 | } 93 | .update_inner(entry) 94 | } 95 | 96 | fn update_inner(mut self, entry: &StagedEntry) -> Self { 97 | self.version += 1; 98 | self.modified_at = entry.created_at; 99 | self.entry_id = entry.entry_id; 100 | match entry.layer { 101 | Layer::Settled => { 102 | self.settled_entry_id = entry.entry_id; 103 | self.settled_modified_at = entry.created_at; 104 | match entry.direction { 105 | DebitOrCredit::Debit => { 106 | self.settled_dr_balance += entry.units; 107 | } 108 | DebitOrCredit::Credit => { 109 | self.settled_cr_balance += entry.units; 110 | } 111 | } 112 | } 113 | Layer::Pending => { 114 | self.pending_entry_id = entry.entry_id; 115 | self.pending_modified_at = entry.created_at; 116 | match entry.direction { 117 | DebitOrCredit::Debit => { 118 | self.pending_dr_balance += entry.units; 119 | } 120 | DebitOrCredit::Credit => { 121 | self.pending_cr_balance += entry.units; 122 | } 123 | } 124 | } 125 | Layer::Encumbered => { 126 | self.encumbered_entry_id = entry.entry_id; 127 | self.encumbered_modified_at = entry.created_at; 128 | match entry.direction { 129 | DebitOrCredit::Debit => { 130 | self.encumbered_dr_balance += entry.units; 131 | } 132 | DebitOrCredit::Credit => { 133 | self.encumbered_cr_balance += entry.units; 134 | } 135 | } 136 | } 137 | } 138 | self 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /migrations/20221109221657_sqlx_ledger_setup.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE DebitOrCredit AS ENUM ('debit', 'credit'); 2 | CREATE TYPE Status AS ENUM ('active'); 3 | CREATE TYPE Layer AS ENUM ('settled', 'pending', 'encumbered'); 4 | 5 | CREATE TABLE sqlx_ledger_accounts ( 6 | id UUID NOT NULL, 7 | version INT NOT NULL DEFAULT 1, 8 | code VARCHAR NOT NULL, 9 | name VARCHAR NOT NULL, 10 | description VARCHAR, 11 | status Status NOT NULL, 12 | normal_balance_type DebitOrCredit NOT NULL, 13 | metadata JSONB, 14 | modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 15 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 16 | UNIQUE(id, version), 17 | UNIQUE(code, version), 18 | UNIQUE(name, version) 19 | ); 20 | 21 | CREATE TABLE sqlx_ledger_journals ( 22 | id UUID NOT NULL, 23 | version INT NOT NULL DEFAULT 1, 24 | name VARCHAR NOT NULL, 25 | description VARCHAR, 26 | status Status NOT NULL, 27 | modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 28 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 29 | UNIQUE(id, version), 30 | UNIQUE(name, version) 31 | ); 32 | 33 | CREATE TABLE sqlx_ledger_tx_templates ( 34 | id UUID NOT NULL, 35 | code VARCHAR NOT NULL, 36 | version INT NOT NULL DEFAULT 1, 37 | params JSONB, 38 | tx_input JSONB NOT NULL, 39 | entries JSONB NOT NULL, 40 | description VARCHAR, 41 | metadata JSONB, 42 | modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 43 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 44 | UNIQUE(id, version), 45 | UNIQUE(code, version) 46 | ); 47 | 48 | CREATE TABLE sqlx_ledger_transactions ( 49 | id UUID NOT NULL, 50 | version INT NOT NULL DEFAULT 1, 51 | journal_id UUID NOT NULL, 52 | tx_template_id UUID NOT NULL, 53 | correlation_id UUID NOT NULL, 54 | effective Date NOT NULL, 55 | external_id VARCHAR NOT NULL, 56 | description VARCHAR, 57 | metadata JSONB, 58 | modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 59 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 60 | UNIQUE(id, version), 61 | UNIQUE(external_id, version) 62 | ); 63 | 64 | CREATE TABLE sqlx_ledger_entries ( 65 | id UUID NOT NULL, 66 | version INT NOT NULL DEFAULT 1, 67 | transaction_id UUID NOT NULL, 68 | account_id UUID NOT NULL, 69 | journal_id UUID NOT NULL, 70 | entry_type VARCHAR NOT NULL, 71 | layer Layer NOT NULL, 72 | units NUMERIC NOT NULL, 73 | currency VARCHAR NOT NULL, 74 | direction DebitOrCredit NOT NULL, 75 | sequence INT NOT NULL, 76 | description VARCHAR, 77 | modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 78 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 79 | UNIQUE(id, version) 80 | ); 81 | 82 | CREATE TABLE sqlx_ledger_balances ( 83 | journal_id UUID NOT NULL, 84 | account_id UUID NOT NULL, 85 | entry_id UUID NOT NULL, 86 | currency VARCHAR NOT NULL, 87 | settled_dr_balance NUMERIC NOT NULL, 88 | settled_cr_balance NUMERIC NOT NULL, 89 | settled_entry_id UUID NOT NULL, 90 | settled_modified_at TIMESTAMPTZ NOT NULL, 91 | pending_dr_balance NUMERIC NOT NULL, 92 | pending_cr_balance NUMERIC NOT NULL, 93 | pending_entry_id UUID NOT NULL, 94 | pending_modified_at TIMESTAMPTZ NOT NULL, 95 | encumbered_dr_balance NUMERIC NOT NULL, 96 | encumbered_cr_balance NUMERIC NOT NULL, 97 | encumbered_entry_id UUID NOT NULL, 98 | encumbered_modified_at TIMESTAMPTZ NOT NULL, 99 | version INT NOT NULL, 100 | modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 101 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 102 | UNIQUE(journal_id, account_id, currency, version) 103 | ); 104 | 105 | CREATE TABLE sqlx_ledger_current_balances ( 106 | journal_id UUID NOT NULL, 107 | account_id UUID NOT NULL, 108 | currency VARCHAR NOT NULL, 109 | version INT NOT NULL, 110 | UNIQUE(journal_id, account_id, currency) 111 | ); 112 | 113 | CREATE TABLE sqlx_ledger_events ( 114 | id BIGSERIAL PRIMARY KEY, 115 | type VARCHAR NOT NULL, 116 | data JSONB NOT NULL, 117 | recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 118 | ); 119 | 120 | CREATE FUNCTION sqlx_ledger_transactions_event() RETURNS TRIGGER AS $$ 121 | BEGIN 122 | INSERT INTO sqlx_ledger_events (type, data, recorded_at) 123 | SELECT CASE 124 | WHEN NEW.id = ANY(SELECT id FROM sqlx_ledger_transactions WHERE id = NEW.id AND version < NEW.version LIMIT 1) THEN 'TransactionUpdated' 125 | ELSE 'TransactionCreated' 126 | END as type, 127 | row_to_json(NEW), 128 | NEW.modified_at; 129 | RETURN NEW; 130 | END; 131 | $$ LANGUAGE plpgsql; 132 | CREATE TRIGGER sqlx_ledger_transactions AFTER INSERT ON sqlx_ledger_transactions 133 | FOR EACH ROW EXECUTE FUNCTION sqlx_ledger_transactions_event(); 134 | 135 | CREATE FUNCTION sqlx_ledger_balances_event() RETURNS TRIGGER AS $$ 136 | BEGIN 137 | INSERT INTO sqlx_ledger_events (type, data, recorded_at) 138 | SELECT 'BalanceUpdated', row_to_json(NEW), NEW.modified_at; 139 | RETURN NEW; 140 | END; 141 | $$ LANGUAGE plpgsql; 142 | CREATE TRIGGER sqlx_ledger_balances AFTER INSERT ON sqlx_ledger_balances 143 | FOR EACH ROW EXECUTE FUNCTION sqlx_ledger_balances_event(); 144 | 145 | CREATE FUNCTION notify_sqlx_ledger_events() RETURNS TRIGGER AS $$ 146 | DECLARE 147 | payload TEXT; 148 | BEGIN 149 | payload := row_to_json(NEW)::text; 150 | 151 | -- If payload exceeds pg_notify's 8KB limit send minimal payload 152 | IF octet_length(payload) > 8000 THEN 153 | payload := json_build_object( 154 | 'id', NEW.id, 155 | 'type', NEW.type, 156 | 'recorded_at', NEW.recorded_at 157 | )::text; 158 | END IF; 159 | 160 | PERFORM pg_notify('sqlx_ledger_events', payload); 161 | RETURN NULL; 162 | END; 163 | $$ LANGUAGE plpgsql; 164 | 165 | CREATE TRIGGER sqlx_ledger_events AFTER INSERT ON sqlx_ledger_events 166 | FOR EACH ROW EXECUTE FUNCTION notify_sqlx_ledger_events(); 167 | -------------------------------------------------------------------------------- /cel-parser/src/cel.lalrpop: -------------------------------------------------------------------------------- 1 | use crate::{LeftRightOp, LogicOp, RelationOp, ArithmeticOp, Expression, UnaryOp, Member, Literal}; 2 | use std::sync::Arc; 3 | 4 | grammar; 5 | 6 | match { 7 | // Skip whitespace and comments 8 | r"\s*" => { }, 9 | r"//[^\n\r]*[\n\r]*" => { }, 10 | } else { 11 | _ 12 | } 13 | 14 | pub Expression: Expression = { 15 | "?" ":" => Expression::Ternary(Box::new(condition), Box::new(left), Box::new(right)), 16 | ConditionalOr 17 | }; 18 | 19 | Tier: Expression = { 20 | > => Expression::from_op(op, left.into(), right.into()), 21 | NextTier 22 | }; 23 | 24 | ConditionalOr: Expression = Tier; 25 | ConditionalAnd: Expression = Tier; 26 | Relation: Expression = Tier; 27 | Addition: Expression = Tier; 28 | Multiplication: Expression = Tier; 29 | 30 | Unary: Expression = { 31 | => Expression::Unary(op, expr.into()), 32 | Member 33 | }; 34 | 35 | Member: Expression = { 36 | "." => Expression::Member(left.into(), Box::new(Member::Attribute(identifier))), 37 | "." "(" > ")" => { 38 | let inner = Expression::Member(Box::new(left), Box::new(Member::Attribute(identifier))); 39 | Expression::Member(Box::new(inner), Member::FunctionCall(arguments).into()) 40 | }, 41 | "[" "]" => Expression::Member(Box::new(left), Box::new(Member::Index(expression.into()))), 42 | Primary, 43 | } 44 | 45 | Primary: Expression = { 46 | "."? => Expression::Ident(<>.into()), 47 | "."? "(" > ")" => { 48 | let inner = Expression::Ident(identifier); 49 | Expression::Member(Box::new(inner), Box::new(Member::FunctionCall(arguments))) 50 | }, 51 | "(" ")", 52 | "[" > "]" => Expression::List(<>), 53 | "{" > "}" => Expression::Map(<>), 54 | "."? "{" > "}" => Expression::Struct(ident,fields), 55 | Literal => Expression::Literal(<>) 56 | } 57 | 58 | FieldInits: (Arc, Expression) = { 59 | ":" 60 | } 61 | 62 | MapInits: (Expression, Expression) = { 63 | ":" 64 | } 65 | 66 | CommaSeparated: Vec = { 67 | ",")*> => match e { 68 | None => v, 69 | Some(e) => { 70 | let mut v = v; 71 | v.push(e); 72 | v 73 | } 74 | } 75 | }; 76 | 77 | LogicOr: LeftRightOp = { 78 | "||" => LeftRightOp::Logic(LogicOp::Or) 79 | }; 80 | LogicAnd: LeftRightOp = { 81 | "&&" => LeftRightOp::Logic(LogicOp::And) 82 | }; 83 | RelationOp: LeftRightOp = { 84 | "<" => LeftRightOp::Relation(RelationOp::LessThan), 85 | "<=" => LeftRightOp::Relation(RelationOp::LessThanEq), 86 | ">" => LeftRightOp::Relation(RelationOp::GreaterThan), 87 | ">=" => LeftRightOp::Relation(RelationOp::GreaterThanEq), 88 | "==" => LeftRightOp::Relation(RelationOp::Equals), 89 | "!=" => LeftRightOp::Relation(RelationOp::NotEquals), 90 | "in" => LeftRightOp::Relation(RelationOp::In) 91 | } 92 | AdditionOp: LeftRightOp = { 93 | "+" => LeftRightOp::Arithmetic(ArithmeticOp::Add), 94 | "-" => LeftRightOp::Arithmetic(ArithmeticOp::Subtract), 95 | }; 96 | MultiplicationOp: LeftRightOp = { 97 | "*" => LeftRightOp::Arithmetic(ArithmeticOp::Multiply), 98 | "/" => LeftRightOp::Arithmetic(ArithmeticOp::Divide), 99 | "%" => LeftRightOp::Arithmetic(ArithmeticOp::Modulus), 100 | }; 101 | UnaryOp: UnaryOp = { 102 | => if v.len() % 2 == 0 { UnaryOp::DoubleNot } else { UnaryOp::Not }, 103 | => if v.len() % 2 == 0 { UnaryOp::DoubleMinus } else { UnaryOp::Minus }, 104 | }; 105 | 106 | Literal: Literal = { 107 | // Integer literals. Annoying to parse :/ 108 | r"-?[0-9]+" => Literal::Int(<>.parse().unwrap()), 109 | r"-?0[xX]([0-9a-fA-F]+)" => Literal::Int(i64::from_str_radix(<>, 16).unwrap()), 110 | r"-?[0-9]+ [uU]" => Literal::UInt(<>.parse().unwrap()), 111 | r"-?0[xX]([0-9a-fA-F]+) [uU]" => Literal::UInt(u64::from_str_radix(<>, 16).unwrap()), 112 | 113 | // Float with decimals and optional exponent 114 | r"([-+]?[0-9]*\.[0-9]+([eE][-+]?[0-9]+)?)" => Literal::Double(<>.to_string().into()), 115 | // Float with no decimals and required exponent 116 | r"[-+]?[0-9]+[eE][-+]?[0-9]+" => Literal::Double(<>.to_string().into()), 117 | 118 | // Double quoted string 119 | r#""(\\.|[^"\n])*""# => Literal::String(<>[1..(<>.len()-1)].to_string().into()), 120 | r#""""(\\.|[^"{3}])*""""# => Literal::String(<>[3..(<>.len()-3)].to_string().into()), 121 | 122 | // Single quoted string 123 | r#"'(\\.|[^'\n])*'"# => Literal::String(<>[1..(<>.len()-1)].to_string().into()), 124 | r#"'''(\\.|[^'{3}])*'''"# => Literal::String(<>[3..(<>.len()-3)].to_string().into()), 125 | 126 | // Double quoted bytes 127 | r#"[bB]"(\\.|[^"\n])*""# => Literal::Bytes(Vec::from(<>.as_bytes()).into()), 128 | r#"[bB]"""(\\.|[^"{3}])*""""# => Literal::Bytes(Vec::from(<>.as_bytes()).into()), 129 | 130 | // Single quoted bytes 131 | r#"[bB]'(\\.|[^'\n])*'"# => Literal::Bytes(Vec::from(<>.as_bytes()).into()), 132 | r#"[bB]'''(\\.|[^'{3}])*'''"# => Literal::Bytes(Vec::from(<>.as_bytes()).into()), 133 | 134 | "true" => Literal::Bool(true), 135 | "false" => Literal::Bool(false), 136 | "null" => Literal::Null, 137 | }; 138 | 139 | Ident: Arc = { 140 | r"[_a-zA-Z][_a-zA-Z0-9]*" => <>.to_string().into() 141 | } 142 | -------------------------------------------------------------------------------- /ledger/src/transaction/repo.rs: -------------------------------------------------------------------------------- 1 | use sqlx::{Pool, Postgres, Transaction as DbTransaction}; 2 | use tracing::instrument; 3 | use uuid::Uuid; 4 | 5 | use super::entity::*; 6 | use crate::{error::*, primitives::*}; 7 | 8 | /// Repository for working with `TxTemplate` entities. 9 | #[derive(Debug, Clone)] 10 | pub struct Transactions { 11 | pool: Pool, 12 | } 13 | 14 | impl Transactions { 15 | pub fn new(pool: &Pool) -> Self { 16 | Self { pool: pool.clone() } 17 | } 18 | 19 | #[instrument(level = "trace", name = "sqlx_ledger.transactions.create_in_tx")] 20 | pub(crate) async fn create_in_tx( 21 | &self, 22 | tx: &mut DbTransaction<'_, Postgres>, 23 | tx_id: TransactionId, 24 | NewTransaction { 25 | journal_id, 26 | tx_template_id, 27 | effective, 28 | correlation_id, 29 | external_id, 30 | description, 31 | metadata, 32 | }: NewTransaction, 33 | ) -> Result<(JournalId, TransactionId), SqlxLedgerError> { 34 | let record = sqlx::query!( 35 | r#"INSERT INTO sqlx_ledger_transactions (id, version, journal_id, tx_template_id, effective, correlation_id, external_id, description, metadata) 36 | VALUES ($1, 1, (SELECT id FROM sqlx_ledger_journals WHERE id = $2 LIMIT 1), (SELECT id FROM sqlx_ledger_tx_templates WHERE id = $3 LIMIT 1), $4, $5, $6, $7, $8) 37 | RETURNING id, version, created_at"#, 38 | tx_id as TransactionId, 39 | journal_id as JournalId, 40 | tx_template_id as TxTemplateId, 41 | effective, 42 | correlation_id.map(Uuid::from).unwrap_or(Uuid::from(tx_id)), 43 | external_id.unwrap_or_else(|| tx_id.to_string()), 44 | description, 45 | metadata 46 | ) 47 | .fetch_one(&mut **tx) 48 | .await?; 49 | Ok((journal_id, TransactionId::from(record.id))) 50 | } 51 | 52 | pub async fn list_by_external_ids( 53 | &self, 54 | ids: Vec, 55 | ) -> Result, SqlxLedgerError> { 56 | let records = sqlx::query!( 57 | r#"SELECT id, version, journal_id, tx_template_id, effective, correlation_id, external_id, description, metadata, created_at, modified_at 58 | FROM sqlx_ledger_transactions 59 | WHERE external_id = ANY($1)"#, 60 | &ids[..] 61 | ) 62 | .fetch_all(&self.pool) 63 | .await?; 64 | Ok(records 65 | .into_iter() 66 | .map(|row| Transaction { 67 | id: TransactionId::from(row.id), 68 | version: row.version as u32, 69 | journal_id: JournalId::from(row.journal_id), 70 | tx_template_id: TxTemplateId::from(row.tx_template_id), 71 | effective: row.effective, 72 | correlation_id: CorrelationId::from(row.correlation_id), 73 | external_id: row.external_id, 74 | description: row.description, 75 | metadata_json: row.metadata, 76 | created_at: row.created_at, 77 | modified_at: row.modified_at, 78 | }) 79 | .collect()) 80 | } 81 | 82 | pub async fn list_by_ids( 83 | &self, 84 | ids: impl IntoIterator>, 85 | ) -> Result, SqlxLedgerError> { 86 | let ids: Vec<_> = ids.into_iter().map(|id| Uuid::from(id.borrow())).collect(); 87 | let records = sqlx::query!( 88 | r#"SELECT id, version, journal_id, tx_template_id, effective, correlation_id, external_id, description, metadata, created_at, modified_at 89 | FROM sqlx_ledger_transactions 90 | WHERE id = ANY($1)"#, 91 | &ids[..] 92 | ) 93 | .fetch_all(&self.pool) 94 | .await?; 95 | Ok(records 96 | .into_iter() 97 | .map(|row| Transaction { 98 | id: TransactionId::from(row.id), 99 | version: row.version as u32, 100 | journal_id: JournalId::from(row.journal_id), 101 | tx_template_id: TxTemplateId::from(row.tx_template_id), 102 | effective: row.effective, 103 | correlation_id: CorrelationId::from(row.correlation_id), 104 | external_id: row.external_id, 105 | description: row.description, 106 | metadata_json: row.metadata, 107 | created_at: row.created_at, 108 | modified_at: row.modified_at, 109 | }) 110 | .collect()) 111 | } 112 | 113 | pub async fn list_by_template_id( 114 | &self, 115 | id: TxTemplateId, 116 | ) -> Result, SqlxLedgerError> { 117 | let records = sqlx::query!( 118 | r#"SELECT id, version, journal_id, tx_template_id, effective, correlation_id, external_id, description, metadata, created_at, modified_at 119 | FROM sqlx_ledger_transactions 120 | WHERE tx_template_id = $1"#, 121 | id as TxTemplateId 122 | ) 123 | .fetch_all(&self.pool) 124 | .await?; 125 | Ok(records 126 | .into_iter() 127 | .map(|row| Transaction { 128 | id: TransactionId::from(row.id), 129 | version: row.version as u32, 130 | journal_id: JournalId::from(row.journal_id), 131 | tx_template_id: TxTemplateId::from(row.tx_template_id), 132 | effective: row.effective, 133 | correlation_id: CorrelationId::from(row.correlation_id), 134 | external_id: row.external_id, 135 | description: row.description, 136 | metadata_json: row.metadata, 137 | created_at: row.created_at, 138 | modified_at: row.modified_at, 139 | }) 140 | .collect()) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /ledger/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # sqlx-ledger 2 | //! 3 | //! This crate builds on the sqlx crate to provide a set of primitives for 4 | //! implementing an SQL-compatible double-entry accounting system. This system 5 | //! is engineered specifically for dealing with money and building financial 6 | //! products. 7 | //! 8 | //! ## Quick Start 9 | //! 10 | //! Add and execute the migrations from the migrations directory before usage. 11 | //! ```bash,ignore 12 | //! cp ./migrations/* 13 | //! # in your project 14 | //! cargo sqlx migrate 15 | //! ``` 16 | //! 17 | //! Here is how to initialize a ledger create a primitive template and post a transaction. 18 | //! This is a toy example that brings all pieces together end-to-end. 19 | //! Not recommended for real use. 20 | //! ```rust 21 | //! use uuid::uuid; 22 | //! use rust_decimal::Decimal; 23 | //! use sqlx_ledger::{*, journal::*, account::*, tx_template::*}; 24 | //! 25 | //! async fn init_ledger(journal_id: JournalId) -> SqlxLedger { 26 | //! let pg_con = 27 | //! std::env::var("PG_CON").unwrap_or(format!("postgres://user:password@localhost:5432/pg")); 28 | //! let pool = sqlx::PgPool::connect(&pg_con).await.unwrap(); 29 | //! let ledger = SqlxLedger::new(&pool); 30 | //! 31 | //! // Initialize the journal - all entities are constructed via builders 32 | //! let new_journal = NewJournal::builder() 33 | //! .id(journal_id) 34 | //! .description("General ledger".to_string()) 35 | //! .name("Ledger") 36 | //! .build() 37 | //! .expect("Couldn't build NewJournal"); 38 | //! 39 | //! let _ = ledger.journals().create(new_journal).await; 40 | //! 41 | //! // Initialize an income omnibus account 42 | //! let main_account_id = uuid!("00000000-0000-0000-0000-000000000001"); 43 | //! let new_account = NewAccount::builder() 44 | //! .id(main_account_id) 45 | //! .name("Income") 46 | //! .code("Income") 47 | //! .build() 48 | //! .unwrap(); 49 | //! 50 | //! let _ = ledger.accounts().create(new_account).await; 51 | //! 52 | //! // Create the trivial 'income' template 53 | //! // 54 | //! // Here are the 'parameters' that the template will require as inputs. 55 | //! let params = vec![ 56 | //! ParamDefinition::builder() 57 | //! .name("sender_account_id") 58 | //! .r#type(ParamDataType::UUID) 59 | //! .build() 60 | //! .unwrap(), 61 | //! ParamDefinition::builder() 62 | //! .name("units") 63 | //! .r#type(ParamDataType::DECIMAL) 64 | //! .build() 65 | //! .unwrap() 66 | //! ]; 67 | //! 68 | //! // The templates for the Entries that will be created as part of the transaction. 69 | //! let entries = vec![ 70 | //! EntryInput::builder() 71 | //! .entry_type("'INCOME_DR'") 72 | //! // Reference the input parameters via CEL syntax 73 | //! .account_id("params.sender_account_id") 74 | //! .layer("SETTLED") 75 | //! .direction("DEBIT") 76 | //! .units("params.units") 77 | //! .currency("'BTC'") 78 | //! .build() 79 | //! .unwrap(), 80 | //! EntryInput::builder() 81 | //! .entry_type("'INCOME_CR'") 82 | //! .account_id(format!("uuid('{main_account_id}')")) 83 | //! .layer("SETTLED") 84 | //! .direction("CREDIT") 85 | //! .units("params.units") 86 | //! .currency("'BTC'") 87 | //! .build() 88 | //! .unwrap(), 89 | //! ]; 90 | //! let tx_code = "GENERAL_INCOME"; 91 | //! let new_template = NewTxTemplate::builder() 92 | //! .id(uuid::Uuid::new_v4()) 93 | //! .code(tx_code) 94 | //! .params(params) 95 | //! .tx_input( 96 | //! // Template for the Transaction metadata. 97 | //! TxInput::builder() 98 | //! .effective("date()") 99 | //! .journal_id(format!("uuid('{journal_id}')")) 100 | //! .build() 101 | //! .unwrap(), 102 | //! ) 103 | //! .entries(entries) 104 | //! .build() 105 | //! .unwrap(); 106 | //! 107 | //! let _ = ledger.tx_templates().create(new_template).await; 108 | //! 109 | //! ledger 110 | //! } 111 | //! 112 | //! tokio_test::block_on(async { 113 | //! let journal_id = JournalId::from(uuid!("00000000-0000-0000-0000-000000000001")); 114 | //! let ledger = init_ledger(journal_id).await; 115 | //! 116 | //! // The account that is sending to the general income account 117 | //! let sender_account_id = AccountId::new(); 118 | //! let sender_account = NewAccount::builder() 119 | //! .id(sender_account_id) 120 | //! .name(format!("Sender-{sender_account_id}")) 121 | //! .code(format!("Sender-{sender_account_id}")) 122 | //! .build() 123 | //! .unwrap(); 124 | //! ledger.accounts().create(sender_account).await.unwrap(); 125 | //! 126 | //! // Prepare the input parameters that the template requires 127 | //! let mut params = TxParams::new(); 128 | //! params.insert("sender_account_id", sender_account_id); 129 | //! params.insert("units", Decimal::ONE); 130 | //! 131 | //! // Create the transaction via the template 132 | //! ledger 133 | //! .post_transaction(TransactionId::new(), "GENERAL_INCOME", Some(params)) 134 | //! .await 135 | //! .unwrap(); 136 | //! 137 | //! // Check the resulting balance 138 | //! let account_balance = ledger 139 | //! .balances() 140 | //! .find(journal_id, sender_account_id, "BTC".parse().unwrap()) 141 | //! .await 142 | //! .unwrap(); 143 | //! 144 | //! assert_eq!(account_balance.unwrap().settled(), Decimal::NEGATIVE_ONE); 145 | //! }); 146 | //! ``` 147 | 148 | #![cfg_attr(feature = "fail-on-warnings", deny(warnings))] 149 | #![cfg_attr(feature = "fail-on-warnings", deny(clippy::all))] 150 | #![allow(clippy::result_large_err)] 151 | #![allow(clippy::large_enum_variant)] 152 | 153 | pub mod account; 154 | pub mod balance; 155 | pub mod entry; 156 | pub mod event; 157 | pub mod journal; 158 | pub mod transaction; 159 | pub mod tx_template; 160 | 161 | mod error; 162 | mod ledger; 163 | mod macros; 164 | mod primitives; 165 | 166 | pub use error::*; 167 | pub use ledger::*; 168 | pub use primitives::*; 169 | -------------------------------------------------------------------------------- /ledger/src/entry/repo.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use rust_decimal::Decimal; 3 | use sqlx::{PgPool, Postgres, QueryBuilder, Row, Transaction}; 4 | use tracing::instrument; 5 | use uuid::Uuid; 6 | 7 | use std::{collections::HashMap, str::FromStr}; 8 | 9 | use super::entity::*; 10 | use crate::{error::*, primitives::*}; 11 | 12 | /// Repository for working with `Entry` (Debit/Credit) entities. 13 | #[derive(Debug, Clone)] 14 | pub struct Entries { 15 | pool: PgPool, 16 | } 17 | 18 | #[derive(Debug)] 19 | pub(crate) struct StagedEntry { 20 | pub(crate) account_id: AccountId, 21 | pub(crate) entry_id: EntryId, 22 | pub(crate) units: Decimal, 23 | pub(crate) currency: Currency, 24 | pub(crate) direction: DebitOrCredit, 25 | pub(crate) layer: Layer, 26 | pub(crate) created_at: DateTime, 27 | } 28 | 29 | impl Entries { 30 | pub fn new(pool: &PgPool) -> Self { 31 | Self { pool: pool.clone() } 32 | } 33 | 34 | #[instrument( 35 | level = "trace", 36 | name = "sqlx_ledger.entries.create_all", 37 | skip(self, tx) 38 | )] 39 | pub(crate) async fn create_all<'a>( 40 | &self, 41 | journal_id: JournalId, 42 | transaction_id: TransactionId, 43 | entries: Vec, 44 | tx: &mut Transaction<'a, Postgres>, 45 | ) -> Result, SqlxLedgerError> { 46 | let mut query_builder: QueryBuilder = QueryBuilder::new( 47 | r#"WITH new_entries as ( 48 | INSERT INTO sqlx_ledger_entries 49 | (id, transaction_id, journal_id, entry_type, layer, 50 | units, currency, direction, description, sequence, account_id)"#, 51 | ); 52 | let mut partial_ret = HashMap::new(); 53 | let mut sequence = 1; 54 | query_builder.push_values( 55 | entries, 56 | |mut builder, 57 | NewEntry { 58 | account_id, 59 | entry_type, 60 | layer, 61 | units, 62 | currency, 63 | direction, 64 | description, 65 | }: NewEntry| { 66 | builder.push("gen_random_uuid()"); 67 | builder.push_bind(transaction_id); 68 | builder.push_bind(journal_id); 69 | builder.push_bind(entry_type); 70 | builder.push_bind(layer); 71 | builder.push_bind(units); 72 | builder.push_bind(currency.code()); 73 | builder.push_bind(direction); 74 | builder.push_bind(description); 75 | builder.push_bind(sequence); 76 | builder.push("(SELECT id FROM sqlx_ledger_accounts WHERE id = "); 77 | builder.push_bind_unseparated(account_id); 78 | builder.push_unseparated(")"); 79 | partial_ret.insert(sequence, (account_id, units, currency, layer, direction)); 80 | sequence += 1; 81 | }, 82 | ); 83 | query_builder.push( 84 | "RETURNING id, sequence, created_at ) SELECT * FROM new_entries ORDER BY sequence", 85 | ); 86 | let query = query_builder.build(); 87 | let records = query.fetch_all(&mut **tx).await?; 88 | 89 | let mut ret = Vec::new(); 90 | sequence = 1; 91 | for r in records { 92 | let entry_id: Uuid = r.get("id"); 93 | let created_at = r.get("created_at"); 94 | let (account_id, units, currency, layer, direction) = 95 | partial_ret.remove(&sequence).expect("sequence not found"); 96 | ret.push(StagedEntry { 97 | entry_id: entry_id.into(), 98 | account_id, 99 | units, 100 | currency, 101 | layer, 102 | direction, 103 | created_at, 104 | }); 105 | sequence += 1; 106 | } 107 | 108 | Ok(ret) 109 | } 110 | 111 | pub async fn list_by_transaction_ids( 112 | &self, 113 | tx_ids: impl IntoIterator>, 114 | ) -> Result>, SqlxLedgerError> { 115 | let tx_ids: Vec = tx_ids 116 | .into_iter() 117 | .map(|id| Uuid::from(id.borrow())) 118 | .collect(); 119 | let records = sqlx::query!( 120 | r#"SELECT id, version, transaction_id, account_id, journal_id, entry_type, layer as "layer: Layer", units, currency, direction as "direction: DebitOrCredit", sequence, description, created_at, modified_at 121 | FROM sqlx_ledger_entries 122 | WHERE transaction_id = ANY($1) ORDER BY transaction_id ASC, sequence ASC, version DESC"#, 123 | &tx_ids[..] 124 | ).fetch_all(&self.pool).await?; 125 | 126 | let mut transactions: HashMap> = HashMap::new(); 127 | 128 | let mut current_tx_id = TransactionId::new(); 129 | let mut last_sequence = 0; 130 | for row in records { 131 | let transaction_id = TransactionId::from(row.transaction_id); 132 | // Skip old entry versions (description is mutable) 133 | if last_sequence == row.sequence && transaction_id == current_tx_id { 134 | continue; 135 | } 136 | current_tx_id = transaction_id; 137 | last_sequence = row.sequence; 138 | 139 | let entry = transactions.entry(transaction_id).or_default(); 140 | 141 | entry.push(Entry { 142 | id: EntryId::from(row.id), 143 | transaction_id, 144 | version: row.version as u32, 145 | account_id: AccountId::from(row.account_id), 146 | journal_id: JournalId::from(row.journal_id), 147 | entry_type: row.entry_type, 148 | layer: row.layer, 149 | units: row.units, 150 | currency: Currency::from_str(row.currency.as_str()) 151 | .expect("Couldn't convert currency"), 152 | direction: row.direction, 153 | sequence: row.sequence as u32, 154 | description: row.description, 155 | created_at: row.created_at, 156 | modified_at: row.modified_at, 157 | }) 158 | } 159 | 160 | Ok(transactions) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /ledger/src/tx_template/entity.rs: -------------------------------------------------------------------------------- 1 | use derive_builder::Builder; 2 | use serde::Serialize; 3 | 4 | use cel_interpreter::CelExpression; 5 | 6 | use super::param_definition::*; 7 | use crate::primitives::*; 8 | 9 | /// Representation of a new TxTemplate created via a builder. 10 | /// 11 | /// TxTemplate is an entity that takes a set of params including 12 | /// a `TxInput` entity and a set of `EntryInput` entities. It can 13 | /// later be used to create a `Transaction`. 14 | #[derive(Builder)] 15 | pub struct NewTxTemplate { 16 | #[builder(setter(into))] 17 | pub(super) id: TxTemplateId, 18 | #[builder(setter(into))] 19 | pub(super) code: String, 20 | #[builder(setter(strip_option, into), default)] 21 | pub(super) description: Option, 22 | #[builder(setter(strip_option), default)] 23 | pub(super) params: Option>, 24 | pub(super) tx_input: TxInput, 25 | pub(super) entries: Vec, 26 | #[builder(setter(custom), default)] 27 | pub(super) metadata: Option, 28 | } 29 | 30 | impl NewTxTemplate { 31 | pub fn builder() -> NewTxTemplateBuilder { 32 | NewTxTemplateBuilder::default() 33 | } 34 | } 35 | 36 | impl NewTxTemplateBuilder { 37 | pub fn metadata( 38 | &mut self, 39 | metadata: T, 40 | ) -> Result<&mut Self, serde_json::Error> { 41 | self.metadata = Some(Some(serde_json::to_value(metadata)?)); 42 | Ok(self) 43 | } 44 | } 45 | 46 | /// Contains the transaction-level details needed to create a `Transaction`. 47 | #[derive(Clone, Serialize, Builder)] 48 | #[builder(build_fn(validate = "Self::validate"))] 49 | pub struct TxInput { 50 | #[builder(setter(into))] 51 | effective: String, 52 | #[builder(setter(into))] 53 | journal_id: String, 54 | #[builder(setter(strip_option, into), default)] 55 | correlation_id: Option, 56 | #[builder(setter(strip_option, into), default)] 57 | external_id: Option, 58 | #[builder(setter(strip_option, into), default)] 59 | description: Option, 60 | #[builder(setter(strip_option, into), default)] 61 | metadata: Option, 62 | } 63 | 64 | impl TxInput { 65 | pub fn builder() -> TxInputBuilder { 66 | TxInputBuilder::default() 67 | } 68 | } 69 | 70 | impl TxInputBuilder { 71 | fn validate(&self) -> Result<(), String> { 72 | validate_expression( 73 | self.effective 74 | .as_ref() 75 | .expect("Mandatory field 'effective' not set"), 76 | )?; 77 | validate_expression( 78 | self.journal_id 79 | .as_ref() 80 | .expect("Mandatory field 'journal_id' not set"), 81 | )?; 82 | validate_optional_expression(&self.correlation_id)?; 83 | validate_optional_expression(&self.external_id)?; 84 | validate_optional_expression(&self.description)?; 85 | validate_optional_expression(&self.metadata) 86 | } 87 | } 88 | 89 | /// Contains the details for each accounting entry in a `Transaction`. 90 | #[derive(Clone, Serialize, Builder)] 91 | #[builder(build_fn(validate = "Self::validate"))] 92 | pub struct EntryInput { 93 | #[builder(setter(into))] 94 | entry_type: String, 95 | #[builder(setter(into))] 96 | account_id: String, 97 | #[builder(setter(into))] 98 | layer: String, 99 | #[builder(setter(into))] 100 | direction: String, 101 | #[builder(setter(into))] 102 | units: String, 103 | #[builder(setter(into))] 104 | currency: String, 105 | #[builder(setter(strip_option), default)] 106 | description: Option, 107 | } 108 | 109 | impl EntryInput { 110 | pub fn builder() -> EntryInputBuilder { 111 | EntryInputBuilder::default() 112 | } 113 | } 114 | impl EntryInputBuilder { 115 | fn validate(&self) -> Result<(), String> { 116 | validate_expression( 117 | self.entry_type 118 | .as_ref() 119 | .expect("Mandatory field 'entry_type' not set"), 120 | )?; 121 | validate_expression( 122 | self.account_id 123 | .as_ref() 124 | .expect("Mandatory field 'account_id' not set"), 125 | )?; 126 | validate_expression( 127 | self.layer 128 | .as_ref() 129 | .expect("Mandatory field 'layer' not set"), 130 | )?; 131 | validate_expression( 132 | self.direction 133 | .as_ref() 134 | .expect("Mandatory field 'direction' not set"), 135 | )?; 136 | validate_expression( 137 | self.units 138 | .as_ref() 139 | .expect("Mandatory field 'units' not set"), 140 | )?; 141 | validate_expression( 142 | self.currency 143 | .as_ref() 144 | .expect("Mandatory field 'currency' not set"), 145 | )?; 146 | validate_optional_expression(&self.description) 147 | } 148 | } 149 | 150 | fn validate_expression(expr: &str) -> Result<(), String> { 151 | CelExpression::try_from(expr).map_err(|e| e.to_string())?; 152 | Ok(()) 153 | } 154 | fn validate_optional_expression(expr: &Option>) -> Result<(), String> { 155 | if let Some(Some(expr)) = expr.as_ref() { 156 | CelExpression::try_from(expr.as_str()).map_err(|e| e.to_string())?; 157 | } 158 | Ok(()) 159 | } 160 | 161 | #[cfg(test)] 162 | mod tests { 163 | use super::*; 164 | use uuid::Uuid; 165 | 166 | #[test] 167 | fn it_builds() { 168 | let journal_id = Uuid::new_v4(); 169 | let entries = vec![EntryInput::builder() 170 | .entry_type("'TEST_DR'") 171 | .account_id("param.recipient") 172 | .layer("'Settled'") 173 | .direction("'Settled'") 174 | .units("1290") 175 | .currency("'BTC'") 176 | .build() 177 | .unwrap()]; 178 | let new_journal = NewTxTemplate::builder() 179 | .id(Uuid::new_v4()) 180 | .code("CODE") 181 | .tx_input( 182 | TxInput::builder() 183 | .effective("date('2022-11-01')") 184 | .journal_id(format!("'{journal_id}'")) 185 | .build() 186 | .unwrap(), 187 | ) 188 | .entries(entries) 189 | .build() 190 | .unwrap(); 191 | assert_eq!(new_journal.description, None); 192 | } 193 | 194 | #[test] 195 | fn fails_when_mandatory_fields_are_missing() { 196 | let new_account = NewTxTemplate::builder().build(); 197 | assert!(new_account.is_err()); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /ci/build/pipeline.yml: -------------------------------------------------------------------------------- 1 | groups: 2 | - name: sqlx-ledger 3 | jobs: 4 | - check-code 5 | - integration-tests 6 | - release 7 | - set-dev-version 8 | jobs: 9 | - name: check-code 10 | serial: true 11 | plan: 12 | - in_parallel: 13 | - get: repo 14 | trigger: true 15 | - get: pipeline-tasks 16 | - task: check-code 17 | config: 18 | platform: linux 19 | image_resource: 20 | type: registry-image 21 | source: 22 | username: ((docker-creds.username)) 23 | password: ((docker-creds.password)) 24 | repository: us.gcr.io/galoy-org/rust-concourse 25 | inputs: 26 | - name: pipeline-tasks 27 | - name: repo 28 | caches: 29 | - path: cargo-home 30 | - path: cargo-target-dir 31 | run: 32 | path: pipeline-tasks/ci/vendor/tasks/rust-check-code.sh 33 | on_failure: 34 | put: slack 35 | params: 36 | channel: sqlx-ledger-github 37 | username: concourse 38 | icon_url: https://cl.ly/2F421Y300u07/concourse-logo-blue-transparent.png 39 | text: '<$ATC_EXTERNAL_URL/teams/$BUILD_TEAM_NAME/pipelines/$BUILD_PIPELINE_NAME/jobs/$BUILD_JOB_NAME/builds/$BUILD_NAME| :face_with_symbols_on_mouth: $BUILD_JOB_NAME> failed!' 40 | - name: integration-tests 41 | serial: true 42 | plan: 43 | - put: docker-host 44 | params: 45 | acquire: true 46 | - in_parallel: 47 | - get: repo 48 | trigger: true 49 | - get: pipeline-tasks 50 | - task: integration-tests 51 | timeout: 12m 52 | tags: 53 | - galoy-staging 54 | config: 55 | platform: linux 56 | image_resource: 57 | type: registry-image 58 | source: 59 | username: ((docker-creds.username)) 60 | password: ((docker-creds.password)) 61 | repository: us.gcr.io/galoy-org/rust-concourse 62 | inputs: 63 | - name: pipeline-tasks 64 | - name: docker-host 65 | - name: repo 66 | path: sqlx-ledger-integration-tests 67 | caches: 68 | - path: cargo-home 69 | - path: cargo-target-dir 70 | params: 71 | REPO_PATH: sqlx-ledger-integration-tests 72 | GOOGLE_CREDENTIALS: ((staging-gcp-creds.creds_json)) 73 | SSH_PRIVATE_KEY: ((staging-ssh.ssh_private_key)) 74 | SSH_PUB_KEY: ((staging-ssh.ssh_public_key)) 75 | TEST_CONTAINER: integration-tests 76 | JEST_TIMEOUT: 90000 77 | run: 78 | path: pipeline-tasks/ci/vendor/tasks/test-on-docker-host.sh 79 | ensure: 80 | put: docker-host 81 | params: 82 | release: docker-host 83 | on_failure: 84 | put: slack 85 | params: 86 | channel: sqlx-ledger-github 87 | username: concourse 88 | icon_url: https://cl.ly/2F421Y300u07/concourse-logo-blue-transparent.png 89 | text: '<$ATC_EXTERNAL_URL/teams/$BUILD_TEAM_NAME/pipelines/$BUILD_PIPELINE_NAME/jobs/$BUILD_JOB_NAME/builds/$BUILD_NAME| :face_with_symbols_on_mouth: $BUILD_JOB_NAME> failed!' 90 | - name: release 91 | serial: true 92 | plan: 93 | - in_parallel: 94 | - get: repo 95 | passed: 96 | - integration-tests 97 | - check-code 98 | - get: pipeline-tasks 99 | - get: version 100 | - task: prep-release 101 | config: 102 | platform: linux 103 | image_resource: 104 | type: registry-image 105 | source: 106 | username: ((docker-creds.username)) 107 | password: ((docker-creds.password)) 108 | repository: us.gcr.io/galoy-org/release-pipeline 109 | inputs: 110 | - name: pipeline-tasks 111 | - name: repo 112 | - name: version 113 | outputs: 114 | - name: version 115 | - name: artifacts 116 | run: 117 | path: pipeline-tasks/ci/vendor/tasks/prep-release-src.sh 118 | - task: update-repo 119 | config: 120 | platform: linux 121 | image_resource: 122 | type: registry-image 123 | source: 124 | username: ((docker-creds.username)) 125 | password: ((docker-creds.password)) 126 | repository: us.gcr.io/galoy-org/rust-concourse 127 | inputs: 128 | - name: artifacts 129 | - name: pipeline-tasks 130 | - name: repo 131 | - name: version 132 | outputs: 133 | - name: repo 134 | run: 135 | path: pipeline-tasks/ci/tasks/update-repo.sh 136 | - task: publish-to-crates 137 | config: 138 | image_resource: 139 | type: registry-image 140 | source: 141 | username: ((docker-creds.username)) 142 | password: ((docker-creds.password)) 143 | repository: us.gcr.io/galoy-org/rust-concourse 144 | platform: linux 145 | inputs: 146 | - name: version 147 | - name: pipeline-tasks 148 | - name: repo 149 | params: 150 | CRATES_API_TOKEN: ((crates.token)) 151 | caches: 152 | - path: cargo-home 153 | - path: cargo-target-dir 154 | run: 155 | path: pipeline-tasks/ci/tasks/publish-to-crates.sh 156 | - put: repo 157 | params: 158 | tag: artifacts/gh-release-tag 159 | repository: repo 160 | merge: true 161 | - put: version 162 | params: 163 | file: version/version 164 | - put: gh-release 165 | params: 166 | name: artifacts/gh-release-name 167 | tag: artifacts/gh-release-tag 168 | body: artifacts/gh-release-notes.md 169 | - name: set-dev-version 170 | plan: 171 | - in_parallel: 172 | - get: repo 173 | passed: 174 | - release 175 | - get: pipeline-tasks 176 | - get: version 177 | trigger: true 178 | params: 179 | bump: patch 180 | passed: 181 | - release 182 | - task: set-dev-version 183 | config: 184 | image_resource: 185 | type: registry-image 186 | source: 187 | username: ((docker-creds.username)) 188 | password: ((docker-creds.password)) 189 | repository: us.gcr.io/galoy-org/release-pipeline 190 | platform: linux 191 | inputs: 192 | - name: version 193 | - name: repo 194 | - name: pipeline-tasks 195 | outputs: 196 | - name: repo 197 | run: 198 | path: pipeline-tasks/ci/tasks/set-dev-version.sh 199 | params: 200 | BRANCH: fix-query-builder 201 | - put: repo 202 | params: 203 | repository: repo 204 | rebase: true 205 | resources: 206 | - name: repo 207 | type: git 208 | source: 209 | ignore_paths: 210 | - ci/*[^md] 211 | fetch_tags: true 212 | uri: git@github.com:blinkbitcoin/sqlx-ledger.git 213 | branch: fix-query-builder 214 | private_key: ((github-blinkbitcoin.private_key)) 215 | webhook_token: ((webhook.secret)) 216 | - name: pipeline-tasks 217 | type: git 218 | source: 219 | paths: 220 | - ci/vendor/* 221 | - ci/tasks/* 222 | - ci/config/* 223 | - Makefile 224 | uri: git@github.com:blinkbitcoin/sqlx-ledger.git 225 | branch: fix-query-builder 226 | private_key: ((github-blinkbitcoin.private_key)) 227 | - name: slack 228 | type: slack-notification 229 | source: 230 | url: ((addons-slack.api_url)) 231 | - name: version 232 | type: semver 233 | source: 234 | initial_version: 0.0.0 235 | driver: git 236 | file: version 237 | uri: git@github.com:blinkbitcoin/sqlx-ledger.git 238 | branch: version 239 | private_key: ((github-blinkbitcoin.private_key)) 240 | - name: gh-release 241 | type: github-release 242 | source: 243 | owner: blinkbitcoin 244 | repository: sqlx-ledger 245 | access_token: ((github-blinkbitcoin.api_token)) 246 | - name: docker-host 247 | type: pool 248 | source: 249 | uri: git@github.com:GaloyMoney/concourse-locks.git 250 | branch: main 251 | pool: docker-hosts 252 | private_key: ((github-blinkbitcoin.private_key)) 253 | resource_types: 254 | - name: slack-notification 255 | type: docker-image 256 | source: 257 | repository: cfcommunity/slack-notification-resource 258 | --- 259 | apiVersion: vendir.k14s.io/v1alpha1 260 | directories: 261 | - contents: 262 | - git: 263 | commitTitle: 'chore: add webhook config' 264 | sha: 9d0f008e41df2f5d5e0461171c02fc0c4aee1d6f 265 | path: . 266 | path: ../.github/workflows/vendor 267 | - contents: 268 | - git: 269 | commitTitle: 'chore: add webhook config' 270 | sha: 9d0f008e41df2f5d5e0461171c02fc0c4aee1d6f 271 | path: . 272 | path: ./vendor 273 | kind: LockConfig 274 | --- 275 | apiVersion: vendir.k14s.io/v1alpha1 276 | kind: Config 277 | directories: 278 | - path: ../.github/workflows/vendor 279 | contents: 280 | - path: . 281 | git: 282 | url: https://github.com/GaloyMoney/concourse-shared.git 283 | ref: 9d0f008e41df2f5d5e0461171c02fc0c4aee1d6f 284 | includePaths: 285 | - shared/actions/* 286 | excludePaths: 287 | - shared/actions/nodejs-* 288 | - shared/actions/chart-* 289 | newRootPath: shared/actions 290 | - path: ./vendor 291 | contents: 292 | - path: . 293 | git: 294 | url: https://github.com/GaloyMoney/concourse-shared.git 295 | ref: 9d0f008e41df2f5d5e0461171c02fc0c4aee1d6f 296 | includePaths: 297 | - shared/ci/**/* 298 | excludePaths: 299 | - shared/ci/**/nodejs-* 300 | - shared/ci/**/chart-* 301 | newRootPath: shared/ci 302 | -------------------------------------------------------------------------------- /ledger/tests/post_transactions.rs: -------------------------------------------------------------------------------- 1 | mod helpers; 2 | 3 | use rust_decimal::Decimal; 4 | 5 | use rand::distributions::{Alphanumeric, DistString}; 6 | use sqlx_ledger::{account::*, balance::AccountBalance, event::*, journal::*, tx_template::*, *}; 7 | 8 | #[tokio::test] 9 | async fn post_transaction() -> anyhow::Result<()> { 10 | let pool = helpers::init_pool().await?; 11 | 12 | let tx_code = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); 13 | 14 | let name = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); 15 | let new_journal = NewJournal::builder().name(name).build().unwrap(); 16 | let ledger = SqlxLedger::new(&pool); 17 | 18 | let journal_id = ledger.journals().create(new_journal).await.unwrap(); 19 | let code = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); 20 | let new_account = NewAccount::builder() 21 | .id(uuid::Uuid::new_v4()) 22 | .name(format!("Test Sender Account {code}")) 23 | .code(code) 24 | .build() 25 | .unwrap(); 26 | let sender_account_id = ledger.accounts().create(new_account).await.unwrap(); 27 | let code = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); 28 | let new_account = NewAccount::builder() 29 | .id(uuid::Uuid::new_v4()) 30 | .name(format!("Test Recipient Account {code}")) 31 | .code(code) 32 | .build() 33 | .unwrap(); 34 | let recipient_account_id = ledger.accounts().create(new_account).await.unwrap(); 35 | 36 | let params = vec![ 37 | ParamDefinition::builder() 38 | .name("recipient") 39 | .r#type(ParamDataType::UUID) 40 | .build() 41 | .unwrap(), 42 | ParamDefinition::builder() 43 | .name("sender") 44 | .r#type(ParamDataType::UUID) 45 | .build() 46 | .unwrap(), 47 | ParamDefinition::builder() 48 | .name("journal_id") 49 | .r#type(ParamDataType::UUID) 50 | .build() 51 | .unwrap(), 52 | ParamDefinition::builder() 53 | .name("external_id") 54 | .r#type(ParamDataType::STRING) 55 | .build() 56 | .unwrap(), 57 | ParamDefinition::builder() 58 | .name("effective") 59 | .r#type(ParamDataType::DATE) 60 | .default_expr("date()") 61 | .build() 62 | .unwrap(), 63 | ]; 64 | let entries = vec![ 65 | EntryInput::builder() 66 | .entry_type("'TEST_BTC_DR'") 67 | .account_id("params.sender") 68 | .layer("SETTLED") 69 | .direction("DEBIT") 70 | .units("decimal('1290')") 71 | .currency("'BTC'") 72 | .build() 73 | .unwrap(), 74 | EntryInput::builder() 75 | .entry_type("'TEST_BTC_CR'") 76 | .account_id("params.recipient") 77 | .layer("SETTLED") 78 | .direction("CREDIT") 79 | .units("decimal('1290')") 80 | .currency("'BTC'") 81 | .build() 82 | .unwrap(), 83 | EntryInput::builder() 84 | .entry_type("'TEST_USD_DR'") 85 | .account_id("params.sender") 86 | .layer("SETTLED") 87 | .direction("DEBIT") 88 | .units("decimal('100')") 89 | .currency("'USD'") 90 | .build() 91 | .unwrap(), 92 | EntryInput::builder() 93 | .entry_type("'TEST_USD_CR'") 94 | .account_id("params.recipient") 95 | .layer("SETTLED") 96 | .direction("CREDIT") 97 | .units("decimal('100')") 98 | .currency("'USD'") 99 | .build() 100 | .unwrap(), 101 | ]; 102 | let new_template = NewTxTemplate::builder() 103 | .id(uuid::Uuid::new_v4()) 104 | .code(&tx_code) 105 | .params(params) 106 | .tx_input( 107 | TxInput::builder() 108 | .effective("params.effective") 109 | .journal_id("params.journal_id") 110 | .external_id("params.external_id") 111 | .metadata(r#"{"foo": "bar"}"#) 112 | .build() 113 | .unwrap(), 114 | ) 115 | .entries(entries) 116 | .build() 117 | .unwrap(); 118 | ledger.tx_templates().create(new_template).await.unwrap(); 119 | 120 | let events = ledger.events(Default::default()).await?; 121 | let mut sender_account_balance_events = events 122 | .account_balance(journal_id, sender_account_id) 123 | .await 124 | .expect("event subscriber closed"); 125 | let mut all_events = events.all().expect("event subscriber closed"); 126 | let mut journal_events = events 127 | .journal(journal_id) 128 | .await 129 | .expect("event subscriber closed"); 130 | 131 | let external_id = uuid::Uuid::new_v4().to_string(); 132 | let mut params = TxParams::new(); 133 | params.insert("journal_id", journal_id); 134 | params.insert("sender", sender_account_id); 135 | params.insert("recipient", recipient_account_id); 136 | params.insert("external_id", external_id.clone()); 137 | 138 | ledger 139 | .post_transaction(TransactionId::new(), &tx_code, Some(params)) 140 | .await 141 | .unwrap(); 142 | let transactions = ledger 143 | .transactions() 144 | .list_by_external_ids(vec![external_id.clone()]) 145 | .await?; 146 | assert_eq!(transactions.len(), 1); 147 | 148 | let entries = ledger 149 | .entries() 150 | .list_by_transaction_ids(vec![transactions[0].id]) 151 | .await?; 152 | 153 | assert!(entries.get(&transactions[0].id).is_some()); 154 | assert_eq!(entries.get(&transactions[0].id).unwrap().len(), 4); 155 | 156 | assert_eq!( 157 | sender_account_balance_events.recv().await.unwrap().r#type, 158 | SqlxLedgerEventType::BalanceUpdated 159 | ); 160 | let next_event = all_events.recv().await.unwrap(); 161 | assert_eq!(next_event.r#type, SqlxLedgerEventType::TransactionCreated); 162 | assert_eq!( 163 | all_events.recv().await.unwrap().r#type, 164 | SqlxLedgerEventType::BalanceUpdated 165 | ); 166 | let after_events = ledger 167 | .events(EventSubscriberOpts { 168 | after_id: Some(next_event.id), 169 | ..Default::default() 170 | }) 171 | .await?; 172 | assert_eq!( 173 | after_events.all().unwrap().recv().await.unwrap().r#type, 174 | SqlxLedgerEventType::BalanceUpdated 175 | ); 176 | assert_eq!( 177 | all_events.recv().await.unwrap().r#type, 178 | SqlxLedgerEventType::BalanceUpdated 179 | ); 180 | assert_eq!( 181 | journal_events.recv().await.unwrap().r#type, 182 | SqlxLedgerEventType::TransactionCreated 183 | ); 184 | assert_eq!( 185 | journal_events.recv().await.unwrap().r#type, 186 | SqlxLedgerEventType::BalanceUpdated 187 | ); 188 | assert_eq!( 189 | journal_events.recv().await.unwrap().r#type, 190 | SqlxLedgerEventType::BalanceUpdated 191 | ); 192 | 193 | let usd = rusty_money::iso::find("USD").unwrap(); 194 | let btc = rusty_money::crypto::find("BTC").unwrap(); 195 | 196 | let usd_credit_balance = get_balance( 197 | &ledger, 198 | journal_id, 199 | recipient_account_id, 200 | Currency::Iso(usd), 201 | ) 202 | .await?; 203 | assert_eq!(usd_credit_balance.settled(), Decimal::from(100)); 204 | 205 | let btc_credit_balance = get_balance( 206 | &ledger, 207 | journal_id, 208 | recipient_account_id, 209 | Currency::Crypto(btc), 210 | ) 211 | .await?; 212 | assert_eq!(btc_credit_balance.settled(), Decimal::from(1290)); 213 | 214 | let btc_debit_balance = get_balance( 215 | &ledger, 216 | journal_id, 217 | sender_account_id, 218 | Currency::Crypto(btc), 219 | ) 220 | .await?; 221 | assert_eq!(btc_debit_balance.settled(), Decimal::from(-1290)); 222 | 223 | let usd_credit_balance = 224 | get_balance(&ledger, journal_id, sender_account_id, Currency::Iso(usd)).await?; 225 | assert_eq!(usd_credit_balance.settled(), Decimal::from(-100)); 226 | 227 | let external_id = uuid::Uuid::new_v4().to_string(); 228 | params = TxParams::new(); 229 | params.insert("journal_id", journal_id); 230 | params.insert("sender", sender_account_id); 231 | params.insert("recipient", recipient_account_id); 232 | params.insert("external_id", external_id.clone()); 233 | 234 | ledger 235 | .post_transaction(TransactionId::new(), &tx_code, Some(params)) 236 | .await 237 | .unwrap(); 238 | 239 | let usd_credit_balance = get_balance( 240 | &ledger, 241 | journal_id, 242 | recipient_account_id, 243 | Currency::Iso(usd), 244 | ) 245 | .await?; 246 | assert_eq!(usd_credit_balance.settled(), Decimal::from(200)); 247 | 248 | let btc_credit_balance = get_balance( 249 | &ledger, 250 | journal_id, 251 | recipient_account_id, 252 | Currency::Crypto(btc), 253 | ) 254 | .await?; 255 | assert_eq!(btc_credit_balance.settled(), Decimal::from(2580)); 256 | 257 | let btc_debit_balance = get_balance( 258 | &ledger, 259 | journal_id, 260 | sender_account_id, 261 | Currency::Crypto(btc), 262 | ) 263 | .await?; 264 | assert_eq!(btc_debit_balance.settled(), Decimal::from(-2580)); 265 | 266 | let usd_credit_balance = 267 | get_balance(&ledger, journal_id, sender_account_id, Currency::Iso(usd)).await?; 268 | assert_eq!(usd_credit_balance.settled(), Decimal::from(-200)); 269 | 270 | Ok(()) 271 | } 272 | 273 | async fn get_balance( 274 | ledger: &SqlxLedger, 275 | journal_id: JournalId, 276 | account_id: AccountId, 277 | currency: Currency, 278 | ) -> anyhow::Result { 279 | let balance = ledger 280 | .balances() 281 | .find(journal_id, account_id, currency) 282 | .await? 283 | .unwrap(); 284 | Ok(balance) 285 | } 286 | -------------------------------------------------------------------------------- /cel-interpreter/src/value.rs: -------------------------------------------------------------------------------- 1 | use cel_parser::{ast::Literal, Expression}; 2 | use chrono::NaiveDate; 3 | use rust_decimal::Decimal; 4 | use uuid::Uuid; 5 | 6 | use std::{collections::HashMap, sync::Arc}; 7 | 8 | use crate::{cel_type::*, error::*}; 9 | 10 | pub struct CelResult<'a> { 11 | pub expr: &'a Expression, 12 | pub val: CelValue, 13 | } 14 | 15 | #[derive(Debug, Clone, PartialEq)] 16 | pub enum CelValue { 17 | // Builtins 18 | Map(Arc), 19 | List(Arc), 20 | Int(i64), 21 | UInt(u64), 22 | Double(f64), 23 | String(Arc), 24 | Bytes(Arc>), 25 | Bool(bool), 26 | Null, 27 | 28 | // Addons 29 | Decimal(Decimal), 30 | Date(NaiveDate), 31 | Uuid(Uuid), 32 | } 33 | 34 | impl CelValue { 35 | pub(crate) fn try_bool(&self) -> Result { 36 | if let CelValue::Bool(val) = self { 37 | Ok(*val) 38 | } else { 39 | Err(CelError::BadType(CelType::Bool, CelType::from(self))) 40 | } 41 | } 42 | } 43 | 44 | #[derive(Debug, PartialEq)] 45 | pub struct CelMap { 46 | inner: HashMap, 47 | } 48 | 49 | #[derive(Debug, PartialEq)] 50 | pub struct CelArray { 51 | inner: Vec, 52 | } 53 | 54 | impl CelArray { 55 | pub fn new() -> Self { 56 | Self { inner: Vec::new() } 57 | } 58 | 59 | pub fn push(&mut self, elem: impl Into) { 60 | self.inner.push(elem.into()); 61 | } 62 | } 63 | 64 | impl Default for CelArray { 65 | fn default() -> Self { 66 | Self::new() 67 | } 68 | } 69 | 70 | impl CelMap { 71 | pub fn new() -> Self { 72 | Self { 73 | inner: HashMap::new(), 74 | } 75 | } 76 | 77 | pub fn insert(&mut self, k: impl Into, val: impl Into) { 78 | self.inner.insert(k.into(), val.into()); 79 | } 80 | 81 | pub fn get(&self, key: impl Into) -> CelValue { 82 | self.inner 83 | .get(&key.into()) 84 | .cloned() 85 | .unwrap_or(CelValue::Null) 86 | } 87 | } 88 | 89 | impl Default for CelMap { 90 | fn default() -> Self { 91 | Self::new() 92 | } 93 | } 94 | 95 | impl From> for CelMap { 96 | fn from(map: HashMap) -> Self { 97 | let mut res = CelMap::new(); 98 | for (k, v) in map { 99 | res.insert(CelKey::String(Arc::from(k)), v); 100 | } 101 | res 102 | } 103 | } 104 | 105 | impl From for CelValue { 106 | fn from(m: CelMap) -> Self { 107 | CelValue::Map(Arc::from(m)) 108 | } 109 | } 110 | 111 | impl From for CelValue { 112 | fn from(i: i64) -> Self { 113 | CelValue::Int(i) 114 | } 115 | } 116 | 117 | impl From for CelValue { 118 | fn from(d: Decimal) -> Self { 119 | CelValue::Decimal(d) 120 | } 121 | } 122 | 123 | impl From for CelValue { 124 | fn from(b: bool) -> Self { 125 | CelValue::Bool(b) 126 | } 127 | } 128 | 129 | impl From for CelValue { 130 | fn from(s: String) -> Self { 131 | CelValue::String(Arc::from(s)) 132 | } 133 | } 134 | 135 | impl From for CelValue { 136 | fn from(d: NaiveDate) -> Self { 137 | CelValue::Date(d) 138 | } 139 | } 140 | 141 | impl From for CelValue { 142 | fn from(id: Uuid) -> Self { 143 | CelValue::Uuid(id) 144 | } 145 | } 146 | 147 | impl From<&str> for CelValue { 148 | fn from(s: &str) -> Self { 149 | CelValue::String(Arc::from(s.to_string())) 150 | } 151 | } 152 | 153 | impl From for CelValue { 154 | fn from(v: serde_json::Value) -> Self { 155 | use serde_json::Value::*; 156 | match v { 157 | Null => CelValue::Null, 158 | Bool(b) => CelValue::Bool(b), 159 | Number(n) => { 160 | if let Some(u) = n.as_u64() { 161 | CelValue::UInt(u) 162 | } else if let Some(i) = n.as_i64() { 163 | CelValue::Int(i) 164 | } else { 165 | unimplemented!() 166 | } 167 | } 168 | String(s) => CelValue::String(Arc::from(s)), 169 | Object(o) => { 170 | let mut map = CelMap::new(); 171 | for (k, v) in o.into_iter() { 172 | map.insert(CelKey::String(Arc::from(k)), CelValue::from(v)); 173 | } 174 | CelValue::Map(Arc::from(map)) 175 | } 176 | Array(a) => { 177 | let mut ar = CelArray::new(); 178 | for v in a.into_iter() { 179 | ar.push(CelValue::from(v)); 180 | } 181 | CelValue::List(Arc::from(ar)) 182 | } 183 | } 184 | } 185 | } 186 | 187 | #[derive(Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] 188 | pub enum CelKey { 189 | Int(i64), 190 | UInt(u64), 191 | Bool(bool), 192 | String(Arc), 193 | } 194 | 195 | impl From<&str> for CelKey { 196 | fn from(s: &str) -> Self { 197 | CelKey::String(Arc::from(s.to_string())) 198 | } 199 | } 200 | 201 | impl From for CelKey { 202 | fn from(s: String) -> Self { 203 | CelKey::String(Arc::from(s)) 204 | } 205 | } 206 | 207 | impl From<&Arc> for CelKey { 208 | fn from(s: &Arc) -> Self { 209 | CelKey::String(s.clone()) 210 | } 211 | } 212 | 213 | impl From<&CelValue> for CelType { 214 | fn from(v: &CelValue) -> Self { 215 | match v { 216 | CelValue::Map(_) => CelType::Map, 217 | CelValue::List(_) => CelType::List, 218 | CelValue::Int(_) => CelType::Int, 219 | CelValue::UInt(_) => CelType::UInt, 220 | CelValue::Double(_) => CelType::Double, 221 | CelValue::String(_) => CelType::String, 222 | CelValue::Bytes(_) => CelType::Bytes, 223 | CelValue::Bool(_) => CelType::Bool, 224 | CelValue::Null => CelType::Null, 225 | 226 | CelValue::Decimal(_) => CelType::Decimal, 227 | CelValue::Date(_) => CelType::Date, 228 | CelValue::Uuid(_) => CelType::Uuid, 229 | } 230 | } 231 | } 232 | 233 | impl From<&Literal> for CelValue { 234 | fn from(l: &Literal) -> Self { 235 | use Literal::*; 236 | match l { 237 | Int(i) => CelValue::Int(*i), 238 | UInt(u) => CelValue::UInt(*u), 239 | Double(d) => CelValue::Double(d.parse().expect("Couldn't parse Decimal")), 240 | String(s) => CelValue::String(s.clone()), 241 | Bytes(b) => CelValue::Bytes(b.clone()), 242 | Bool(b) => CelValue::Bool(*b), 243 | Null => CelValue::Null, 244 | } 245 | } 246 | } 247 | 248 | impl TryFrom<&CelValue> for Arc { 249 | type Error = CelError; 250 | 251 | fn try_from(v: &CelValue) -> Result { 252 | if let CelValue::String(s) = v { 253 | Ok(s.clone()) 254 | } else { 255 | Err(CelError::BadType(CelType::String, CelType::from(v))) 256 | } 257 | } 258 | } 259 | 260 | impl<'a> TryFrom> for NaiveDate { 261 | type Error = CelError; 262 | 263 | fn try_from(CelResult { expr, val }: CelResult) -> Result { 264 | if let CelValue::Date(d) = val { 265 | Ok(d) 266 | } else { 267 | Err(CelError::EvaluationError( 268 | format!("{expr:?}"), 269 | Box::new(CelError::BadType(CelType::Date, CelType::from(&val))), 270 | )) 271 | } 272 | } 273 | } 274 | 275 | impl<'a> TryFrom> for Uuid { 276 | type Error = CelError; 277 | 278 | fn try_from(CelResult { expr, val }: CelResult) -> Result { 279 | if let CelValue::Uuid(id) = val { 280 | Ok(id) 281 | } else { 282 | Err(CelError::EvaluationError( 283 | format!("{expr:?}"), 284 | Box::new(CelError::BadType(CelType::Uuid, CelType::from(&val))), 285 | )) 286 | } 287 | } 288 | } 289 | 290 | impl<'a> TryFrom> for String { 291 | type Error = CelError; 292 | 293 | fn try_from(CelResult { expr, val }: CelResult) -> Result { 294 | if let CelValue::String(s) = val { 295 | Ok(s.to_string()) 296 | } else { 297 | Err(CelError::EvaluationError( 298 | format!("{expr:?}"), 299 | Box::new(CelError::BadType(CelType::String, CelType::from(&val))), 300 | )) 301 | } 302 | } 303 | } 304 | 305 | impl<'a> TryFrom> for Decimal { 306 | type Error = CelError; 307 | 308 | fn try_from(CelResult { expr, val }: CelResult) -> Result { 309 | match val { 310 | CelValue::Decimal(n) => Ok(n), 311 | _ => Err(CelError::EvaluationError( 312 | format!("{expr:?}"), 313 | Box::new(CelError::BadType(CelType::Decimal, CelType::from(&val))), 314 | )), 315 | } 316 | } 317 | } 318 | 319 | impl From<&CelKey> for CelType { 320 | fn from(v: &CelKey) -> Self { 321 | match v { 322 | CelKey::Int(_) => CelType::Int, 323 | CelKey::UInt(_) => CelType::UInt, 324 | CelKey::Bool(_) => CelType::Bool, 325 | CelKey::String(_) => CelType::String, 326 | } 327 | } 328 | } 329 | 330 | impl TryFrom<&CelKey> for String { 331 | type Error = CelError; 332 | 333 | fn try_from(v: &CelKey) -> Result { 334 | if let CelKey::String(s) = v { 335 | Ok(s.to_string()) 336 | } else { 337 | Err(CelError::BadType(CelType::String, CelType::from(v))) 338 | } 339 | } 340 | } 341 | 342 | impl<'a> TryFrom> for serde_json::Value { 343 | type Error = CelError; 344 | 345 | fn try_from(CelResult { expr, val }: CelResult) -> Result { 346 | use serde_json::*; 347 | Ok(match val { 348 | CelValue::Int(n) => Value::from(n), 349 | CelValue::UInt(n) => Value::from(n), 350 | CelValue::Double(n) => Value::from(n.to_string()), 351 | CelValue::Bool(b) => Value::from(b), 352 | CelValue::String(n) => Value::from(n.as_str()), 353 | CelValue::Null => Value::Null, 354 | CelValue::Date(d) => Value::from(d.to_string()), 355 | CelValue::Uuid(u) => Value::from(u.to_string()), 356 | CelValue::Map(m) => { 357 | let mut res = serde_json::Map::new(); 358 | for (k, v) in m.inner.iter() { 359 | let key: String = k.try_into()?; 360 | let value = Self::try_from(CelResult { 361 | expr, 362 | val: v.clone(), 363 | })?; 364 | res.insert(key, value); 365 | } 366 | Value::from(res) 367 | } 368 | CelValue::List(a) => { 369 | let mut res = Vec::new(); 370 | for v in a.inner.iter() { 371 | res.push(Self::try_from(CelResult { 372 | expr, 373 | val: v.clone(), 374 | })?); 375 | } 376 | Value::from(res) 377 | } 378 | _ => unimplemented!(), 379 | }) 380 | } 381 | } 382 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [sqlx-ledger release v0.11.11](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.11.11) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | - Update sqlx cache queries (#109) 7 | 8 | # [sqlx-ledger release v0.11.10](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.11.10) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | - Refactor events subscription (#108) 14 | 15 | # [sqlx-ledger release v0.11.9](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.11.9) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | - Add unlisten after events loop (#105) 21 | 22 | # [sqlx-ledger release v0.11.8](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.11.8) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | - Pg_notify payload limit (#101) 28 | 29 | ### Miscellaneous Tasks 30 | 31 | - Update rust version (#103) 32 | 33 | # [sqlx-ledger release v0.11.7](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.11.7) 34 | 35 | 36 | 37 | # [sqlx-ledger release v0.11.3](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.11.3) 38 | 39 | 40 | ### Bug Fixes 41 | 42 | - Var naming (#65) 43 | 44 | ### Miscellaneous Tasks 45 | 46 | - Bump otel tracing, derive-builder and cached (#73) 47 | 48 | # [sqlx-ledger release v0.11.2](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.11.2) 49 | 50 | 51 | ### Miscellaneous Tasks 52 | 53 | - Update opentelemetry and tracing-opentelemetry (#64) 54 | 55 | # [sqlx-ledger release v0.11.1](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.11.1) 56 | 57 | 58 | ### Bug Fixes 59 | 60 | - Current_balance error when executing template (#58) 61 | - Check-code error on concourse (#57) 62 | - Account can have multiple currencies (#56) 63 | 64 | ### Miscellaneous Tasks 65 | 66 | - Add flake 67 | - Update tracing-opentelemetry requirement from 0.20 to 0.21 (#53) 68 | 69 | # [sqlx-ledger release v0.11.0](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.11.0) 70 | 71 | 72 | ### Bug Fixes 73 | 74 | - Formatting in Cargo.toml 75 | 76 | ### Miscellaneous Tasks 77 | 78 | - Add async feature for cached crate 79 | - Update cached requirement from 0.44 to 0.46 80 | 81 | # [sqlx-ledger release v0.10.0](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.10.0) 82 | 83 | 84 | ### Miscellaneous Tasks 85 | 86 | - [**breaking**] Update opentelemetry and tracing-opentelemetry (#47) 87 | 88 | # [sqlx-ledger release v0.9.0](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.9.0) 89 | 90 | 91 | ### Miscellaneous Tasks 92 | 93 | - [**breaking**] Update sqlx to 0.7.1 (#45) 94 | 95 | # [sqlx-ledger release v0.8.5](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.8.5) 96 | 97 | 98 | ### Features 99 | 100 | - Accept IntoIterator for tx_ids 101 | 102 | # [sqlx-ledger release v0.8.4](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.8.4) 103 | 104 | 105 | ### Features 106 | 107 | - Add transactions.list_by_template_id 108 | 109 | # [sqlx-ledger release v0.8.3](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.8.3) 110 | 111 | 112 | ### Miscellaneous Tasks 113 | 114 | - Bump dependencies 115 | 116 | # [sqlx-ledger release v0.8.2](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.8.2) 117 | 118 | 119 | ### Bug Fixes 120 | 121 | - Typo in error.rs 122 | 123 | ### Miscellaneous Tasks 124 | 125 | - Bump lalrpop 126 | - Update cached requirement from 0.43.0 to 0.44.0 127 | 128 | # [sqlx-ledger release v0.8.1](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.8.1) 129 | 130 | 131 | ### Miscellaneous Tasks 132 | 133 | - Update opentelemetry and tracing-opentelemetry (#35) 134 | 135 | # [sqlx-ledger release v0.8.0](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.8.0) 136 | 137 | 138 | ### Bug Fixes 139 | 140 | - Line up ParamDataType with new Decimal 141 | - [**breaking**] Correct numeric types 142 | 143 | ### Refactor 144 | 145 | - Array->List 146 | - Remove ledger specific literals from cel-interpreter 147 | 148 | # [sqlx-ledger release v0.7.7](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.7.7) 149 | 150 | 151 | ### Miscellaneous Tasks 152 | 153 | - Bump lalrpop 154 | 155 | # [sqlx-ledger release v0.7.6](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.7.6) 156 | 157 | 158 | ### Bug Fixes 159 | 160 | - Add CelArray type 161 | 162 | ### Miscellaneous Tasks 163 | 164 | - Missing Default for CelArray 165 | 166 | # [sqlx-ledger release v0.7.5](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.7.5) 167 | 168 | 169 | ### Miscellaneous Tasks 170 | 171 | - Impl Type on entity ids 172 | 173 | # [sqlx-ledger release v0.7.4](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.7.4) 174 | 175 | 176 | ### Bug Fixes 177 | 178 | - Pass reload to sqlx_ledger_notfification_received 179 | 180 | # [sqlx-ledger release v0.7.3](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.7.3) 181 | 182 | 183 | ### Bug Fixes 184 | 185 | - Make BEGIN marker pub 186 | 187 | # [sqlx-ledger release v0.7.2](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.7.2) 188 | 189 | 190 | ### Bug Fixes 191 | 192 | - Only reload if after_id is set 193 | 194 | # [sqlx-ledger release v0.7.1](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.7.1) 195 | 196 | 197 | ### Miscellaneous Tasks 198 | 199 | - Type safe SqlxLedgerEventId 200 | 201 | # [sqlx-ledger release v0.7.0](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.7.0) 202 | 203 | 204 | ### Miscellaneous Tasks 205 | 206 | - Add otel feature 207 | 208 | # [sqlx-ledger release v0.6.1](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.6.1) 209 | 210 | 211 | ### Miscellaneous Tasks 212 | 213 | - Rename idx -> id 214 | 215 | # [sqlx-ledger release v0.6.0](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.6.0) 216 | 217 | 218 | ### Features 219 | 220 | - [**breaking**] EventSubscriberOpts to configure event listening 221 | 222 | ### Miscellaneous Tasks 223 | 224 | - Try to use working lalrpop 225 | - Attempt to pin lalrpop to a ref 226 | - Temporarily pin lalrpop 227 | 228 | # [sqlx-ledger release v0.5.12](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.5.12) 229 | 230 | 231 | ### Bug Fixes 232 | 233 | - Spelling 234 | 235 | ### Miscellaneous Tasks 236 | 237 | - Bump cached crate 238 | 239 | # [sqlx-ledger release v0.5.11](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.5.11) 240 | 241 | 242 | ### Miscellaneous Tasks 243 | 244 | - Include dec in builtins 245 | 246 | # [sqlx-ledger release v0.5.10](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.5.10) 247 | 248 | 249 | ### Bug Fixes 250 | 251 | - Error output in relation 252 | 253 | ### Miscellaneous Tasks 254 | 255 | - Implement interpretation of some Relations 256 | 257 | # [sqlx-ledger release v0.5.9](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.5.9) 258 | 259 | 260 | ### Miscellaneous Tasks 261 | 262 | - Better bool handling 263 | 264 | # [sqlx-ledger release v0.5.8](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.5.8) 265 | 266 | 267 | ### Features 268 | 269 | - Transactions.list_by_ids 270 | 271 | # [sqlx-ledger release v0.5.7](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.5.7) 272 | 273 | 274 | 275 | # [sqlx-ledger release v0.5.5](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.5.5) 276 | 277 | 278 | ### Documentation 279 | 280 | - Link to readme in ledger/Cargo.toml 281 | 282 | # [sqlx-ledger release v0.5.4](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.5.4) 283 | 284 | 285 | ### Documentation 286 | 287 | - Readme + small improvements 288 | 289 | # [sqlx-ledger release v0.5.3](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.5.3) 290 | 291 | 292 | ### Documentation 293 | 294 | - No-deps to cargo doc 295 | - Add quick-start section 296 | - Move module docs to repos 297 | - 1st attempt at documenting tx_template module 298 | - Adds documentation for 'transaction' module 299 | - Adds documentation for 'journal' module 300 | - Adds documentation for 'entry' module 301 | - Adds documentation for 'balance' module 302 | - Adds documentation for 'account' module 303 | - Adds documentation for events module 304 | - Adds documentation for sqlx-ledger crate 305 | 306 | # [sqlx-ledger release v0.5.2](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.5.2) 307 | 308 | 309 | ### Features 310 | 311 | - Add balances.find_all 312 | 313 | ### Miscellaneous Tasks 314 | 315 | - Simplify as TxTemplateCore is now Send 316 | - Less cloning within TxTemplateCore 317 | 318 | # [sqlx-ledger release v0.5.1](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.5.1) 319 | 320 | 321 | ### Miscellaneous Tasks 322 | 323 | - Add some trace spans 324 | - Cache tx_templates.find_core 325 | - Include current span in SqlxLedgerEvent 326 | 327 | # [sqlx-ledger release v0.5.0](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.5.0) 328 | 329 | 330 | ### Bug Fixes 331 | 332 | - Sqlx_ledger.notification_received tracing name 333 | 334 | ### Miscellaneous Tasks 335 | 336 | - [**breaking**] Make ids mandatory in tx_template + account 337 | 338 | ### Testing 339 | 340 | - Remove bitfinex from hedging test 341 | 342 | # [sqlx-ledger release v0.4.0](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.4.0) 343 | 344 | 345 | ### Features 346 | 347 | - [**breaking**] Better event interface 348 | 349 | ### Miscellaneous Tasks 350 | 351 | - Derive Clone for EventSubscriber 352 | 353 | # [sqlx-ledger release v0.3.0](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.3.0) 354 | 355 | 356 | ### Features 357 | 358 | - [**breaking**] Post_transaction requires tx_id for idempotency 359 | 360 | # [sqlx-ledger release v0.2.1](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.2.1) 361 | 362 | 363 | ### Features 364 | 365 | - Use entry entity outside of crate (#11) 366 | 367 | # [sqlx-ledger release v0.2.0](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.2.0) 368 | 369 | 370 | ### Features 371 | 372 | - List entries by external id (#9) 373 | - [**breaking**] Expose ledger.event_stream 374 | 375 | ### Miscellaneous Tasks 376 | 377 | - Clippy 378 | - Use DESC LIMIT 1 to get current balance 379 | - Exclude CHANGELOG in typos 380 | 381 | # [sqlx-ledger release v0.1.1](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.1.1) 382 | 383 | 384 | ### Miscellaneous Tasks 385 | 386 | - Expose deserialized metadata on Transaction 387 | - List_by_external_tx_ids 388 | 389 | # [sqlx-ledger release v0.1.0](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.1.0) 390 | 391 | 392 | ### Bug Fixes 393 | 394 | - Referenced dev versions 395 | - Balance is unique accross journal_id 396 | - Updating existing balances 397 | - Null params should become None 398 | - .gitignore 399 | - Unique index on balances 400 | 401 | ### Features 402 | 403 | - Add op to cel 404 | - Balances e2e 405 | - Journals 406 | - Create account 407 | 408 | ### Miscellaneous Tasks 409 | 410 | - Descriptions 411 | - Fix release of worspace versions 412 | - Update derive_builder requirement from 0.11.2 to 0.12.0 413 | - Remove --locked when testing 414 | - Typos 415 | - Deps and check-code 416 | - Expose balance.encumbered 417 | - Clippy 418 | - Report original expression in CelError 419 | - Add ledger/sqlx-data.json 420 | - Implement Multiply op 421 | - Add settled to AccountBalance 422 | - Add From for CelValue 423 | - Improve tracing 424 | - Add a bunch of tracing 425 | - Fix TxTemplateCore not being Send 426 | - Improve metadata handling 427 | - Expose AccountBalance 428 | - Fix timestamp TZ 429 | - Enable post_transaction in tx 430 | - Small fixes 431 | - Add From for CelValue 432 | - Better error output 433 | - Support DuplicateKey error 434 | - Add find_by_code to Accounts 435 | - Optional settes for tx_input 436 | - Make SqlxLedger Clone 437 | - Switch pg settings 438 | - Update sqlx-data 439 | - Add create_in_tx to account / journal 440 | - Add id to journal/account builder 441 | - Add update-account 442 | - Remove (n) constraint on columns 443 | - Rename tables with sqlx_ledger prefix 444 | - Remove unused make commands 445 | - Currency from CelValue 446 | - Clippy 447 | - OptimisticLockingError 448 | - Return StagedEntries 449 | - Persist entries 450 | - Add EntryInput 451 | - Validate params against defs 452 | - E2e post-transaction kind of working 453 | - Remove ledger/cel module 454 | - Interpreter can lookup values 455 | - Interpreter wip 456 | - Cleanup grammar 457 | - Workspace 458 | - Prep TxTemplate.prep_tx 459 | - Transaction wip 460 | - Params is optional 461 | - Tx_template pt 1 462 | - Cel wip 463 | - Some account scaffolding 464 | - Initial commit 465 | 466 | ### Refactor 467 | 468 | - External_id is String 469 | - Expose BalanceDetails 470 | - Accept impl Into in post_transaction 471 | - &str for post_transaction tx_template_code 472 | - More efficient balance selection 473 | - Fix parser 474 | - Rename perm -> core 475 | - Remove current / history tables 476 | 477 | # [sqlx-ledger release v0.0.5](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.0.5) 478 | 479 | 480 | ### Miscellaneous Tasks 481 | 482 | - Descriptions 483 | 484 | # [sqlx-ledger release v0.0.4](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.0.4) 485 | 486 | 487 | ### Bug Fixes 488 | 489 | - Referenced dev versions 490 | 491 | ### Miscellaneous Tasks 492 | 493 | - Fix release of worspace versions 494 | 495 | # [sqlx-ledger release v0.0.3](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.0.3) 496 | 497 | 498 | 499 | # [sqlx-ledger release v0.0.2](https://github.com/GaloyMoney/sqlx-ledger/releases/tag/v0.0.2) 500 | -------------------------------------------------------------------------------- /cel-interpreter/src/interpreter.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use cel_parser::{ 4 | ast::{self, ArithmeticOp, Expression, RelationOp}, 5 | parser::ExpressionParser, 6 | }; 7 | 8 | use std::sync::Arc; 9 | 10 | use crate::{cel_type::*, context::*, error::*, value::*}; 11 | 12 | #[derive(Debug, Clone, Deserialize, Serialize)] 13 | #[serde(try_from = "String")] 14 | #[serde(into = "String")] 15 | pub struct CelExpression { 16 | source: String, 17 | expr: Expression, 18 | } 19 | impl CelExpression { 20 | pub fn try_evaluate<'a, T: TryFrom, Error = E>, E: From>( 21 | &'a self, 22 | ctx: &CelContext, 23 | ) -> Result { 24 | let res = self.evaluate(ctx)?; 25 | T::try_from(CelResult { 26 | expr: &self.expr, 27 | val: res, 28 | }) 29 | } 30 | 31 | pub fn evaluate(&self, ctx: &CelContext) -> Result { 32 | match evaluate_expression(&self.expr, ctx)? { 33 | EvalType::Value(val) => Ok(val), 34 | EvalType::ContextItem(ContextItem::Value(val)) => Ok(val.clone()), 35 | _ => Err(CelError::Unexpected( 36 | "evaluate didn't return a value".to_string(), 37 | )), 38 | } 39 | } 40 | } 41 | 42 | enum EvalType<'a> { 43 | Value(CelValue), 44 | ContextItem(&'a ContextItem), 45 | } 46 | 47 | impl<'a> EvalType<'a> { 48 | fn try_bool(&self) -> Result { 49 | if let EvalType::Value(val) = self { 50 | val.try_bool() 51 | } else { 52 | Err(CelError::Unexpected( 53 | "Expression didn't resolve to a bool".to_string(), 54 | )) 55 | } 56 | } 57 | 58 | fn try_key(&self) -> Result { 59 | if let EvalType::Value(val) = self { 60 | match val { 61 | CelValue::Int(i) => Ok(CelKey::Int(*i)), 62 | CelValue::UInt(u) => Ok(CelKey::UInt(*u)), 63 | CelValue::Bool(b) => Ok(CelKey::Bool(*b)), 64 | CelValue::String(s) => Ok(CelKey::String(s.clone())), 65 | _ => Err(CelError::Unexpected( 66 | "Expression didn't resolve to a valid key".to_string(), 67 | )), 68 | } 69 | } else { 70 | Err(CelError::Unexpected( 71 | "Expression didn't resolve to value".to_string(), 72 | )) 73 | } 74 | } 75 | 76 | fn try_value(&self) -> Result { 77 | if let EvalType::Value(val) = self { 78 | Ok(val.clone()) 79 | } else { 80 | Err(CelError::Unexpected("Couldn't unwrap value".to_string())) 81 | } 82 | } 83 | } 84 | 85 | fn evaluate_expression<'a>( 86 | expr: &Expression, 87 | ctx: &'a CelContext, 88 | ) -> Result, CelError> { 89 | match evaluate_expression_inner(expr, ctx) { 90 | Ok(val) => Ok(val), 91 | Err(e) => Err(CelError::EvaluationError(format!("{expr:?}"), Box::new(e))), 92 | } 93 | } 94 | 95 | fn evaluate_expression_inner<'a>( 96 | expr: &Expression, 97 | ctx: &'a CelContext, 98 | ) -> Result, CelError> { 99 | use Expression::*; 100 | match expr { 101 | Ternary(cond, left, right) => { 102 | if evaluate_expression(cond, ctx)?.try_bool()? { 103 | evaluate_expression(left, ctx) 104 | } else { 105 | evaluate_expression(right, ctx) 106 | } 107 | } 108 | Member(expr, member) => { 109 | let ident = evaluate_expression(expr, ctx)?; 110 | evaluate_member(ident, member, ctx) 111 | } 112 | Map(entries) => { 113 | let mut map = CelMap::new(); 114 | for (k, v) in entries { 115 | let key = evaluate_expression(k, ctx)?; 116 | let value = evaluate_expression(v, ctx)?; 117 | map.insert(key.try_key()?, value.try_value()?) 118 | } 119 | Ok(EvalType::Value(CelValue::from(map))) 120 | } 121 | Ident(name) => Ok(EvalType::ContextItem(ctx.lookup(Arc::clone(name))?)), 122 | Literal(val) => Ok(EvalType::Value(CelValue::from(val))), 123 | Arithmetic(op, left, right) => { 124 | let left = evaluate_expression(left, ctx)?; 125 | let right = evaluate_expression(right, ctx)?; 126 | Ok(EvalType::Value(evaluate_arithmetic( 127 | *op, 128 | left.try_value()?, 129 | right.try_value()?, 130 | )?)) 131 | } 132 | Relation(op, left, right) => { 133 | let left = evaluate_expression(left, ctx)?; 134 | let right = evaluate_expression(right, ctx)?; 135 | Ok(EvalType::Value(evaluate_relation( 136 | *op, 137 | left.try_value()?, 138 | right.try_value()?, 139 | )?)) 140 | } 141 | e => Err(CelError::Unexpected(format!("unimplemented {e:?}"))), 142 | } 143 | } 144 | 145 | fn evaluate_member<'a>( 146 | target: EvalType, 147 | member: &ast::Member, 148 | ctx: &CelContext, 149 | ) -> Result, CelError> { 150 | use ast::Member::*; 151 | match member { 152 | Attribute(name) => match target { 153 | EvalType::ContextItem(ContextItem::Value(CelValue::Map(map))) => { 154 | Ok(EvalType::Value(map.get(name))) 155 | } 156 | _ => Err(CelError::IllegalTarget), 157 | }, 158 | FunctionCall(exprs) => match target { 159 | EvalType::ContextItem(ContextItem::Function(f)) => { 160 | let mut args = Vec::new(); 161 | for e in exprs { 162 | args.push(evaluate_expression(e, ctx)?.try_value()?) 163 | } 164 | Ok(EvalType::Value(f(args)?)) 165 | } 166 | _ => Err(CelError::IllegalTarget), 167 | }, 168 | _ => unimplemented!(), 169 | } 170 | } 171 | 172 | fn evaluate_arithmetic( 173 | op: ArithmeticOp, 174 | left: CelValue, 175 | right: CelValue, 176 | ) -> Result { 177 | use CelValue::*; 178 | match op { 179 | ArithmeticOp::Multiply => match (&left, &right) { 180 | (UInt(l), UInt(r)) => Ok(UInt(l * r)), 181 | (Int(l), Int(r)) => Ok(Int(l * r)), 182 | (Double(l), Double(r)) => Ok(Double(l * r)), 183 | (Decimal(l), Decimal(r)) => Ok(Decimal(l * r)), 184 | _ => Err(CelError::NoMatchingOverload(format!( 185 | "Cannot apply '*' to {:?} and {:?}", 186 | CelType::from(&left), 187 | CelType::from(&right) 188 | ))), 189 | }, 190 | ArithmeticOp::Add => match (&left, &right) { 191 | (UInt(l), UInt(r)) => Ok(UInt(l + r)), 192 | (Int(l), Int(r)) => Ok(Int(l + r)), 193 | (Double(l), Double(r)) => Ok(Double(l + r)), 194 | (Decimal(l), Decimal(r)) => Ok(Decimal(l + r)), 195 | _ => Err(CelError::NoMatchingOverload(format!( 196 | "Cannot apply '+' to {:?} and {:?}", 197 | CelType::from(&left), 198 | CelType::from(&right) 199 | ))), 200 | }, 201 | ArithmeticOp::Subtract => match (&left, &right) { 202 | (UInt(l), UInt(r)) => Ok(UInt(l - r)), 203 | (Int(l), Int(r)) => Ok(Int(l - r)), 204 | (Double(l), Double(r)) => Ok(Double(l - r)), 205 | (Decimal(l), Decimal(r)) => Ok(Decimal(l - r)), 206 | _ => Err(CelError::NoMatchingOverload(format!( 207 | "Cannot apply '-' to {:?} and {:?}", 208 | CelType::from(&left), 209 | CelType::from(&right) 210 | ))), 211 | }, 212 | _ => unimplemented!(), 213 | } 214 | } 215 | 216 | fn evaluate_relation( 217 | op: RelationOp, 218 | left: CelValue, 219 | right: CelValue, 220 | ) -> Result { 221 | use CelValue::*; 222 | match op { 223 | RelationOp::LessThan => match (&left, &right) { 224 | (UInt(l), UInt(r)) => Ok(Bool(l < r)), 225 | (Int(l), Int(r)) => Ok(Bool(l < r)), 226 | (Double(l), Double(r)) => Ok(Bool(l < r)), 227 | (Decimal(l), Decimal(r)) => Ok(Bool(l < r)), 228 | _ => Err(CelError::NoMatchingOverload(format!( 229 | "Cannot apply '<' to {:?} and {:?}", 230 | CelType::from(&left), 231 | CelType::from(&right) 232 | ))), 233 | }, 234 | RelationOp::LessThanEq => match (&left, &right) { 235 | (UInt(l), UInt(r)) => Ok(Bool(l <= r)), 236 | (Int(l), Int(r)) => Ok(Bool(l <= r)), 237 | (Double(l), Double(r)) => Ok(Bool(l <= r)), 238 | (Decimal(l), Decimal(r)) => Ok(Bool(l <= r)), 239 | _ => Err(CelError::NoMatchingOverload(format!( 240 | "Cannot apply '<=' to {:?} and {:?}", 241 | CelType::from(&left), 242 | CelType::from(&right) 243 | ))), 244 | }, 245 | RelationOp::GreaterThan => match (&left, &right) { 246 | (UInt(l), UInt(r)) => Ok(Bool(l > r)), 247 | (Int(l), Int(r)) => Ok(Bool(l > r)), 248 | (Double(l), Double(r)) => Ok(Bool(l > r)), 249 | (Decimal(l), Decimal(r)) => Ok(Bool(l > r)), 250 | _ => Err(CelError::NoMatchingOverload(format!( 251 | "Cannot apply '>' to {:?} and {:?}", 252 | CelType::from(&left), 253 | CelType::from(&right) 254 | ))), 255 | }, 256 | RelationOp::GreaterThanEq => match (&left, &right) { 257 | (UInt(l), UInt(r)) => Ok(Bool(l >= r)), 258 | (Int(l), Int(r)) => Ok(Bool(l >= r)), 259 | (Double(l), Double(r)) => Ok(Bool(l >= r)), 260 | (Decimal(l), Decimal(r)) => Ok(Bool(l >= r)), 261 | _ => Err(CelError::NoMatchingOverload(format!( 262 | "Cannot apply '>=' to {:?} and {:?}", 263 | CelType::from(&left), 264 | CelType::from(&right) 265 | ))), 266 | }, 267 | RelationOp::Equals => match (&left, &right) { 268 | (UInt(l), UInt(r)) => Ok(Bool(l == r)), 269 | (Int(l), Int(r)) => Ok(Bool(l == r)), 270 | (Double(l), Double(r)) => Ok(Bool(l == r)), 271 | (Decimal(l), Decimal(r)) => Ok(Bool(l == r)), 272 | _ => Err(CelError::NoMatchingOverload(format!( 273 | "Cannot apply '==' to {:?} and {:?}", 274 | CelType::from(&left), 275 | CelType::from(&right) 276 | ))), 277 | }, 278 | RelationOp::NotEquals => match (&left, &right) { 279 | (UInt(l), UInt(r)) => Ok(Bool(l != r)), 280 | (Int(l), Int(r)) => Ok(Bool(l != r)), 281 | (Double(l), Double(r)) => Ok(Bool(l != r)), 282 | (Decimal(l), Decimal(r)) => Ok(Bool(l != r)), 283 | _ => Err(CelError::NoMatchingOverload(format!( 284 | "Cannot apply '!=' to {:?} and {:?}", 285 | CelType::from(&left), 286 | CelType::from(&right) 287 | ))), 288 | }, 289 | _ => unimplemented!(), 290 | } 291 | } 292 | 293 | impl From for String { 294 | fn from(expr: CelExpression) -> Self { 295 | expr.source 296 | } 297 | } 298 | 299 | impl TryFrom for CelExpression { 300 | type Error = CelError; 301 | 302 | fn try_from(source: String) -> Result { 303 | let expr = ExpressionParser::new() 304 | .parse(&source) 305 | .map_err(|e| CelError::CelParseError(e.to_string()))?; 306 | Ok(Self { source, expr }) 307 | } 308 | } 309 | impl TryFrom<&str> for CelExpression { 310 | type Error = CelError; 311 | 312 | fn try_from(source: &str) -> Result { 313 | Self::try_from(source.to_string()) 314 | } 315 | } 316 | impl std::str::FromStr for CelExpression { 317 | type Err = CelError; 318 | 319 | fn from_str(source: &str) -> Result { 320 | Self::try_from(source.to_string()) 321 | } 322 | } 323 | 324 | #[cfg(test)] 325 | mod tests { 326 | use super::*; 327 | use chrono::NaiveDate; 328 | 329 | #[test] 330 | fn literals() { 331 | let expression = "true".parse::().unwrap(); 332 | let context = CelContext::new(); 333 | assert_eq!(expression.evaluate(&context).unwrap(), CelValue::Bool(true)); 334 | 335 | let expression = "1".parse::().unwrap(); 336 | assert_eq!(expression.evaluate(&context).unwrap(), CelValue::Int(1)); 337 | 338 | let expression = "-1".parse::().unwrap(); 339 | assert_eq!(expression.evaluate(&context).unwrap(), CelValue::Int(-1)); 340 | 341 | let expression = "'hello'".parse::().unwrap(); 342 | assert_eq!( 343 | expression.evaluate(&context).unwrap(), 344 | CelValue::String("hello".to_string().into()) 345 | ); 346 | 347 | // Tokenizer needs fixing 348 | // let expression = "1u".parse::().unwrap(); 349 | // assert_eq!(expression.evaluate(&context).unwrap(), CelValue::UInt(1)) 350 | } 351 | 352 | #[test] 353 | fn logic() { 354 | let expression = "true || false ? false && true : true" 355 | .parse::() 356 | .unwrap(); 357 | let context = CelContext::new(); 358 | assert_eq!( 359 | expression.evaluate(&context).unwrap(), 360 | CelValue::Bool(false) 361 | ); 362 | let expression = "true && false ? false : true || false" 363 | .parse::() 364 | .unwrap(); 365 | assert_eq!(expression.evaluate(&context).unwrap(), CelValue::Bool(true)) 366 | } 367 | 368 | #[test] 369 | fn lookup() { 370 | let expression = "params.hello".parse::().unwrap(); 371 | let mut context = CelContext::new(); 372 | let mut params = CelMap::new(); 373 | params.insert("hello", 42); 374 | context.add_variable("params", params); 375 | assert_eq!(expression.evaluate(&context).unwrap(), CelValue::Int(42)); 376 | } 377 | 378 | #[test] 379 | fn function() { 380 | let expression = "date('2022-10-10')".parse::().unwrap(); 381 | let context = CelContext::new(); 382 | assert_eq!( 383 | expression.evaluate(&context).unwrap(), 384 | CelValue::Date(NaiveDate::parse_from_str("2022-10-10", "%Y-%m-%d").unwrap()) 385 | ); 386 | } 387 | } 388 | --------------------------------------------------------------------------------