Compare commits
185
Commits
main
..
2a00f4ef33
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a00f4ef33
|
||
|
|
54d2775a33
|
||
|
|
01d41c0daf
|
||
|
|
005a96055a
|
||
|
|
45784266b5
|
||
|
|
9694d2a89a
|
||
|
|
714867ed58
|
||
|
|
105fd52694
|
||
|
|
b48addd0e0
|
||
|
|
d65f9aa840
|
||
|
|
ddb67a2ca2
|
||
|
|
dbdc8e671f
|
||
|
|
f858fb4cfe
|
||
|
|
8164abb2b8
|
||
|
|
f591264533
|
||
|
|
8f623ba810
|
||
|
|
02b3b9c053
|
||
|
|
f4cb01b3f9
|
||
|
|
42e600bbf2 | ||
|
|
03212265ce | ||
|
|
b5af21bb0e | ||
|
|
c6faeeb827 | ||
|
|
5548902f27 | ||
|
|
e59e8001c6 | ||
|
|
3bfea47a59 | ||
|
|
52674af74f | ||
|
|
296818f73c | ||
|
|
a016af9cd4 | ||
|
|
3aae8cd2a6 | ||
|
|
e96b5672e9 | ||
|
|
94c874a56f | ||
|
|
6540bceac1 | ||
|
|
da4a9cb0ee | ||
|
|
623d9462fe | ||
|
|
15a9b0961a | ||
|
|
48ef705185 | ||
|
|
98015b6c58 | ||
|
|
09d1537225 | ||
|
|
0a90a72962 | ||
|
|
22cfc37bbd | ||
|
|
0d91d5839f | ||
|
|
72f7143aa9 | ||
|
|
fe50b38616 | ||
|
|
4969cb557e | ||
|
|
bad75ede4d | ||
|
|
2dd23df7ae | ||
|
|
61b6e48df7 | ||
|
|
ab67dccf8e | ||
|
|
005dcbb803 | ||
|
|
d50b844476 | ||
|
|
3d80a270ca | ||
|
|
f521d7d8c6 | ||
|
|
6fdd8898a9 | ||
|
|
c7a47ce76f | ||
|
|
134c77ae41 | ||
|
|
d201b71d45 | ||
|
|
15c177850b | ||
|
|
4a4d03c329 | ||
|
|
91368eda18 | ||
|
|
6b6e88516d | ||
|
|
eb30becb95 | ||
|
|
71d415d2a8 | ||
|
|
727bdc61e6 | ||
|
|
747f0c951e | ||
|
|
26f15eb867 | ||
|
|
dc852d5f7e | ||
|
|
bb0243c78e | ||
|
|
78af7e201d | ||
|
|
3039625805 | ||
|
|
608788bbe4 | ||
|
|
5e9388e6ad | ||
|
|
343a10a1ac | ||
|
|
a4b6a41ce9 | ||
|
|
fc9960eb83 | ||
|
|
92921d8a72 | ||
|
|
96a7727f92 | ||
|
|
7a1c3a2531 | ||
|
|
d6de58bb41 | ||
|
|
42088e06d5 | ||
|
|
df376d760f | ||
|
|
a27c17c025 | ||
|
|
fa6173be84 | ||
|
|
377756ff94 | ||
|
|
90656055cc | ||
|
|
870a4d359b | ||
|
|
77a51cfd6f | ||
|
|
9a2705d055 | ||
|
|
7025056125 | ||
|
|
0e4ec80cd8 | ||
|
|
5baaa12bf7 | ||
|
|
6313c50b11 | ||
|
|
9ba4917899 | ||
|
|
78915fc3bb | ||
|
|
9fa4232664 | ||
|
|
5225abb9dd | ||
|
|
ac1604ed3f | ||
|
|
792a0dce19 | ||
|
|
9ff4b5261a | ||
|
|
65dd11d26b | ||
|
|
d703874f87 | ||
|
|
2a72f37a9d | ||
|
|
349ccd1ccf | ||
|
|
1c6524ad20 | ||
|
|
8177e6aee3 | ||
|
|
793c4e8ab6 | ||
|
|
6c052141a0 | ||
|
|
686306544a | ||
|
|
50c45e5229 | ||
|
|
d36d718be0 | ||
|
|
284e53feb7 | ||
|
|
87b5b90baf | ||
|
|
8c115f1b9e | ||
|
|
1674ce70b4 | ||
|
|
f92bb4c39c | ||
|
|
325eb1003a | ||
|
|
9126e33f77 | ||
|
|
0d3a6d18ad | ||
|
|
1d211c8faa | ||
|
|
7098ac08e5 | ||
|
|
3790b4e0b6 | ||
|
|
5054268474 | ||
|
|
95d46c27d5 | ||
|
|
d1e3a9525b | ||
|
|
ad0c99853f | ||
|
|
fcdd002fe0 | ||
|
|
afcbc4bbdf | ||
|
|
41acb73b5b | ||
|
|
a7a8a70496 | ||
|
|
99b5b5d0b8 | ||
|
|
31d3c61e32 | ||
|
|
2fcb50c2c4 | ||
|
|
008b5d2d52 | ||
|
|
eb28e87662 | ||
|
|
a44f14a275 | ||
|
|
1f9f441a16 | ||
|
|
e0214fcc4c | ||
|
|
fc5bb190a1 | ||
|
|
291451f725 | ||
|
|
090be37107 | ||
|
|
01d109e80e | ||
|
|
877e6ed503 | ||
|
|
7a26fd2fb1 | ||
|
|
fac7d681e4 | ||
|
|
fae0827246 | ||
|
|
4884fea21a | ||
|
|
cbaa858380 | ||
|
|
7d23f42185 | ||
|
|
9be8398519 | ||
|
|
a65d10c78e | ||
|
|
7d41b99707 | ||
|
|
0ff778526a | ||
|
|
e5f63b0d48 | ||
|
|
08b53d2e55 | ||
|
|
fcf6e5743d | ||
|
|
57c28679d5 | ||
|
|
25df4962ea | ||
|
|
283e2b4a51 | ||
|
|
922e5d3128 | ||
|
|
794b18e6ab | ||
|
|
6979a20414 | ||
|
|
1b1a1791f1 | ||
|
|
b23e0a9011 | ||
|
|
e72b3542d2 | ||
|
|
e313fec903 | ||
|
|
3c33c79a75 | ||
|
|
2c22024abd | ||
|
|
1fa76aeecd | ||
|
|
11397e9c24 | ||
|
|
440c10534d | ||
|
|
57feb81698 | ||
|
|
fd72f1969e | ||
|
|
6df4020c3f | ||
|
|
3eaa669ab9 | ||
|
|
b866e601e7 | ||
|
|
47743bea79 | ||
|
|
7b77a437f1 | ||
|
|
1b8da0a477 | ||
|
|
2a1a7512ae | ||
|
|
f8393d961a | ||
|
|
d18f68464f | ||
|
|
437977f848 | ||
|
|
a3fdc37f1d | ||
|
|
ae61f02eb8 | ||
|
|
3a9d83ec6c | ||
|
|
da5715e215 |
No files matched your search
@@ -1,36 +0,0 @@
|
||||
# Every change: format, clippy and rustdoc with zero warnings, every test.
|
||||
# Tools come from mise.toml, the same as on a laptop.
|
||||
name: check
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
# A newer push to the same branch makes an older check pointless: it is
|
||||
# cancelled, so only the newest reports.
|
||||
concurrency:
|
||||
group: check-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-24.04
|
||||
# The runner's disk is small (the first run after P6 landed died in
|
||||
# the linker: "No space left on device", ld killed by SIGBUS). A fresh
|
||||
# runner gains nothing from incremental caches, and line tables keep
|
||||
# file:line in a panic's backtrace at a fraction of full debug info.
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
CARGO_PROFILE_TEST_DEBUG: line-tables-only
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Room for the build (the image ships toolchains we never use)
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
|
||||
/opt/hostedtoolcache/CodeQL /usr/local/.ghcup
|
||||
df -h / | tail -1
|
||||
- uses: jdx/mise-action@v2
|
||||
- name: GUI build dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libgtk-3-dev
|
||||
- run: just check
|
||||
- name: What the build used
|
||||
if: always()
|
||||
run: df -h / | tail -1
|
||||
@@ -1,59 +0,0 @@
|
||||
# The portable builds (plan P11a step 0, D-56): the workspace builds and
|
||||
# passes its tests on macOS and Windows, not only on Linux. A workflow of
|
||||
# its own per OS, not a job beside `check` or beside the other OS: GitHub
|
||||
# serves a job's log only when its whole run has completed, and `check`
|
||||
# takes over an hour (the Windows suite most of one), so a failure's reason
|
||||
# was unreadable for that long while it sat in a shared run.
|
||||
name: portable-macos
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: portable-macos-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
# The destination is one installation for macOS, Windows and Linux (D-56,
|
||||
# P11a step 0): the workspace must build and pass its tests on each, not
|
||||
# only here. `just check` (fmt, clippy, rustdoc, proofs' friends) stays
|
||||
# the Linux job's; these run the build and the tests. The job tells
|
||||
# which OS a failure is on; neither is allowed to hide the other.
|
||||
portable:
|
||||
name: portable (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
experimental: false
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: ${{ matrix.experimental }}
|
||||
# Same disk discipline as above: no incremental caches, line tables only.
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
CARGO_PROFILE_TEST_DEBUG: line-tables-only
|
||||
# macOS: a Unix socket path holds 104 bytes, and the runner's default
|
||||
# temp folder (/var/folders/...) plus a test home plus
|
||||
# `Library/Application Support/wanderbooks/run/` is longer than that;
|
||||
# the server then refuses the root ("too long for its sockets"). The
|
||||
# tests' temp homes go under /tmp there. Windows ignores TMPDIR.
|
||||
TMPDIR: /tmp
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Rust (the version mise.toml pins)
|
||||
shell: bash
|
||||
run: rustup toolchain install 1.99 --profile minimal && rustup default 1.99
|
||||
- name: Room for the build
|
||||
shell: bash
|
||||
run: df -h . | tail -1
|
||||
- name: The server-only build (no viewer)
|
||||
run: cargo build -p ledger-cli --no-default-features
|
||||
- name: Everything compiles
|
||||
run: cargo build --workspace --all-targets
|
||||
- name: Tests
|
||||
run: cargo test --workspace
|
||||
- name: What the build used
|
||||
if: always()
|
||||
shell: bash
|
||||
run: df -h . | tail -1
|
||||
@@ -1,59 +0,0 @@
|
||||
# The portable builds (plan P11a step 0, D-56): the workspace builds and
|
||||
# passes its tests on macOS and Windows, not only on Linux. A workflow of
|
||||
# its own per OS, not a job beside `check` or beside the other OS: GitHub
|
||||
# serves a job's log only when its whole run has completed, and `check`
|
||||
# takes over an hour (the Windows suite most of one), so a failure's reason
|
||||
# was unreadable for that long while it sat in a shared run.
|
||||
name: portable-windows
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: portable-windows-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
# The destination is one installation for macOS, Windows and Linux (D-56,
|
||||
# P11a step 0): the workspace must build and pass its tests on each, not
|
||||
# only here. `just check` (fmt, clippy, rustdoc, proofs' friends) stays
|
||||
# the Linux job's; these run the build and the tests. The job tells
|
||||
# which OS a failure is on; neither is allowed to hide the other.
|
||||
portable:
|
||||
name: portable (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
experimental: true
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: ${{ matrix.experimental }}
|
||||
# Same disk discipline as above: no incremental caches, line tables only.
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
CARGO_PROFILE_TEST_DEBUG: line-tables-only
|
||||
# macOS: a Unix socket path holds 104 bytes, and the runner's default
|
||||
# temp folder (/var/folders/...) plus a test home plus
|
||||
# `Library/Application Support/wanderbooks/run/` is longer than that;
|
||||
# the server then refuses the root ("too long for its sockets"). The
|
||||
# tests' temp homes go under /tmp there. Windows ignores TMPDIR.
|
||||
TMPDIR: /tmp
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Rust (the version mise.toml pins)
|
||||
shell: bash
|
||||
run: rustup toolchain install 1.99 --profile minimal && rustup default 1.99
|
||||
- name: Room for the build
|
||||
shell: bash
|
||||
run: df -h . | tail -1
|
||||
- name: The server-only build (no viewer)
|
||||
run: cargo build -p ledger-cli --no-default-features
|
||||
- name: Everything compiles
|
||||
run: cargo build --workspace --all-targets
|
||||
- name: Tests
|
||||
run: cargo test --workspace
|
||||
- name: What the build used
|
||||
if: always()
|
||||
shell: bash
|
||||
run: df -h . | tail -1
|
||||
@@ -1,123 +0,0 @@
|
||||
# Releases (plan P11a, D-24, D-26): on a version tag, build the Linux
|
||||
# packages on the oldest system they claim (ubuntu-24.04, glibc 2.39; a
|
||||
# program linked on a newer one does not start on an older one), attach
|
||||
# them to the GitHub release, and add them to the apt and dnf repositories
|
||||
# on the gh-pages branch (GitHub Pages serves them). Unsigned: step 5 of
|
||||
# the unit (the release key, Q-4) signs the manifest and the indexes. The
|
||||
# macOS job builds the .dmg UNSIGNED (no Developer ID, no notarization:
|
||||
# step 5); the Windows job is unit P11c. The release holds no key today.
|
||||
# To see the artifacts without a tag: Actions > release > Run workflow,
|
||||
# pick the branch; each job uploads its files as a run artifact.
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-24.04
|
||||
# Same disk discipline as check.yml: no incremental caches.
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Room for the build (the image ships toolchains we never use)
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
|
||||
/opt/hostedtoolcache/CodeQL /usr/local/.ghcup
|
||||
df -h / | tail -1
|
||||
- uses: jdx/mise-action@v2
|
||||
with:
|
||||
# Only what packaging needs, not the proof and fuzz tools.
|
||||
install_args: "rust just cargo:cargo-packager cargo:cargo-generate-rpm"
|
||||
- name: GUI build dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libgtk-3-dev apt-utils createrepo-c
|
||||
- name: The tag is the program's version
|
||||
id: version
|
||||
run: |
|
||||
own=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ledger-cli/Cargo.toml | head -n1)
|
||||
tag="${GITHUB_REF_NAME#v}"
|
||||
if [ "${GITHUB_REF_TYPE}" = tag ] && [ "$tag" != "$own" ]; then
|
||||
echo "tag $GITHUB_REF_NAME is not version $own" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$own" >> "$GITHUB_OUTPUT"
|
||||
- name: Build the packages
|
||||
run: just package-linux ${{ steps.version.outputs.version }}
|
||||
- name: Attach them to the release
|
||||
if: github.ref_type == 'tag'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
v=${{ steps.version.outputs.version }}
|
||||
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "wanderbooks $v"
|
||||
gh release upload "$GITHUB_REF_NAME" --clobber target/packages/$v/*.AppImage target/packages/$v/*.deb target/packages/$v/*.rpm target/packages/$v/*.tar.gz
|
||||
- name: Add them to the apt and dnf repositories (gh-pages)
|
||||
if: github.ref_type == 'tag'
|
||||
run: |
|
||||
v=${{ steps.version.outputs.version }}
|
||||
git fetch origin gh-pages || true
|
||||
if git rev-parse --verify origin/gh-pages >/dev/null 2>&1; then
|
||||
git worktree add repo origin/gh-pages
|
||||
else
|
||||
git worktree add --detach repo && git -C repo checkout --orphan gh-pages && git -C repo rm -rfq . || true
|
||||
fi
|
||||
mkdir -p repo/apt/pool repo/dnf
|
||||
cp target/packages/$v/*.deb repo/apt/pool/
|
||||
cp target/packages/$v/*.rpm repo/dnf/
|
||||
deploy/packaging/repo-indexes.sh apt repo/apt
|
||||
deploy/packaging/repo-indexes.sh dnf repo/dnf
|
||||
cd repo
|
||||
git config user.name "wanderbooks release"
|
||||
git config user.email "release@wanderbooks.ai"
|
||||
git add -A
|
||||
git commit -m "wanderbooks $v"
|
||||
git push origin HEAD:gh-pages
|
||||
- name: Keep them with the run (a dispatch has no release)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-packages
|
||||
path: target/packages/${{ steps.version.outputs.version }}/
|
||||
|
||||
macos:
|
||||
# UNSIGNED: a .dmg that Gatekeeper refuses until step 5 signs and
|
||||
# notarizes it. Apple silicon (macos-latest); an Intel build is not
|
||||
# made yet.
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
with:
|
||||
install_args: "rust just cargo:cargo-packager"
|
||||
- name: The tag is the program's version
|
||||
id: version
|
||||
run: |
|
||||
own=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ledger-cli/Cargo.toml | head -n1)
|
||||
tag="${GITHUB_REF_NAME#v}"
|
||||
if [ "${GITHUB_REF_TYPE}" = tag ] && [ "$tag" != "$own" ]; then
|
||||
echo "tag $GITHUB_REF_NAME is not version $own" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$own" >> "$GITHUB_OUTPUT"
|
||||
- name: Build the unsigned .dmg
|
||||
run: just package-macos ${{ steps.version.outputs.version }}
|
||||
- name: Attach it to the release
|
||||
if: github.ref_type == 'tag'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
v=${{ steps.version.outputs.version }}
|
||||
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "wanderbooks $v"
|
||||
gh release upload "$GITHUB_REF_NAME" --clobber target/packages/$v/*.dmg
|
||||
- name: Keep it with the run (a dispatch has no release)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-dmg-unsigned
|
||||
path: target/packages/${{ steps.version.outputs.version }}/*.dmg
|
||||
@@ -1,48 +0,0 @@
|
||||
# Weekly, and on demand: the slow layers (docs/testing.md): the proofs,
|
||||
# the GnuCash oracle and the fuzz targets. Mutation testing is not run in
|
||||
# CI (owner, 2026-10-29, D-59): it is a local run (`just mutants-*`), and
|
||||
# P12 runs it whole on the owner's desktop.
|
||||
name: scheduled
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 6 * * 1"
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
prove:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
- run: just prove
|
||||
# The Kani harnesses too slow for prove's 30 minutes on four cores
|
||||
# (justfile kani_slow; docs/verification.md), on a runner of their own.
|
||||
prove-slow:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
- run: just prove-slow
|
||||
oracle:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
- run: just oracle
|
||||
fuzz:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_settings_replay, fuzz_import_csv, fuzz_config_parse, fuzz_extract_pdf, fuzz_extract_xlsx]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
- run: just fuzz ${{ matrix.target }} 600
|
||||
# A crash's input is the finding: keep it (the 2026-10-26 run found one
|
||||
# in fuzz_store_replay and kept nothing, so it had to be hunted again).
|
||||
- if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fuzz-${{ matrix.target }}-artifacts
|
||||
path: fuzz/artifacts
|
||||
if-no-files-found: ignore
|
||||
@@ -1,111 +0,0 @@
|
||||
# The two-account, self-host deployment (plan 8.2 P9a, D-51): the books
|
||||
# server runs as one account, an agent as another, and the agent reaches
|
||||
# the books only through the server's HTTP door with a static bearer
|
||||
# token. `crates/ledger-cli/tests/two_user.rs` is the check; this file
|
||||
# makes the deployment it needs and runs it against real accounts. A
|
||||
# laptop's `cargo test` skips those two tests (WB_TWO_USER unset); this
|
||||
# job sets it, so a skip here is a failure's cousin: the job only goes
|
||||
# green when both tests ran.
|
||||
#
|
||||
# The token is minted as on a real server: `token create` keeps it in a
|
||||
# 0600 file under the root's run/ (ledger-host tokens.rs INVARIANT 3).
|
||||
name: two-user
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
# A newer push makes an older run pointless: it is cancelled.
|
||||
concurrency:
|
||||
group: two-user-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
two-user:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
WB_PORT: "17411"
|
||||
WB: /usr/local/bin/wanderbooks
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
- name: Build the server-only executable and the test
|
||||
run: |
|
||||
cargo build --release -p ledger-cli --no-default-features
|
||||
cargo test -p ledger-cli --test two_user --no-run
|
||||
- name: Two accounts, one data root only one can read
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo useradd --system --create-home --home-dir /var/lib/wanderbooks \
|
||||
--shell /usr/sbin/nologin wanderbooks
|
||||
sudo install -d -o wanderbooks -g wanderbooks -m 0700 /srv/wanderbooks
|
||||
sudo install -d -o wanderbooks -g wanderbooks -m 0700 /srv/desktop
|
||||
sudo install -d -o wanderbooks -g wanderbooks -m 0700 /srv/desktop-home
|
||||
sudo install -m 0755 target/release/wanderbooks "$WB"
|
||||
- name: The deployment, the move, and the check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
as_books() { sudo -u wanderbooks -H env HOME=/var/lib/wanderbooks "$@"; }
|
||||
wait_open() {
|
||||
for _ in $(seq 1 240); do
|
||||
grep -q "The books are open" "$1" && return 0
|
||||
sleep 0.5
|
||||
done
|
||||
echo "the server did not open:" >&2
|
||||
cat "$1" >&2
|
||||
return 1
|
||||
}
|
||||
stop_servers() {
|
||||
as_books "$WB" close --root /srv/desktop >/dev/null 2>&1 || true
|
||||
as_books "$WB" close --root /srv/wanderbooks >/dev/null 2>&1 || true
|
||||
}
|
||||
trap stop_servers EXIT
|
||||
|
||||
# The machine the books come from: books of this version, made by
|
||||
# adopting the repository's fixture (so the move changes no event).
|
||||
# Its own home, so the two roots are not "somewhere else" to one
|
||||
# another (root::setup records one root per account home).
|
||||
as_books "$WB" setup --root /srv/desktop --home /srv/desktop-home
|
||||
as_books "$WB" serve --root /srv/desktop > /tmp/desktop.log 2>&1 &
|
||||
wait_open /tmp/desktop.log
|
||||
sudo cp -a crates/ledger-cli/tests/fixtures/p2-served /tmp/fixture
|
||||
# The server account adopts it, so it must own the copy: adoption
|
||||
# takes the source's lock (root.rs lock_source), which the runner's
|
||||
# ownership from the checkout refused it (the job's first runs).
|
||||
sudo chown -R wanderbooks:wanderbooks /tmp/fixture
|
||||
as_books "$WB" books adopt /tmp/fixture --root /srv/desktop --id acme --yes
|
||||
as_books "$WB" close --root /srv/desktop
|
||||
|
||||
# The backup clone that is the transport (plan D-31): owned by the
|
||||
# server account, which adopts it (and locks it), and readable by
|
||||
# the agent account, because the test reads its journal to know
|
||||
# the head the move must keep.
|
||||
sudo cp -a /srv/desktop/books/acme /tmp/source
|
||||
sudo chown -R wanderbooks:wanderbooks /tmp/source
|
||||
sudo chmod -R a+rX /tmp/source
|
||||
|
||||
# The server's machine: the same books, moved, behind `enforced`.
|
||||
as_books "$WB" setup --root /srv/wanderbooks
|
||||
as_books "$WB" serve --root /srv/wanderbooks --isolation enforced \
|
||||
--http "127.0.0.1:$WB_PORT" > /tmp/tenant.log 2>&1 &
|
||||
wait_open /tmp/tenant.log
|
||||
as_books "$WB" books adopt /tmp/source --root /srv/wanderbooks --id acme --yes
|
||||
as_books "$WB" books create tunnel --root /srv/wanderbooks
|
||||
|
||||
# Tokens for the agent account (the test), kept in the root's run/.
|
||||
as_books "$WB" token create --root /srv/wanderbooks --book acme --name ci >/dev/null
|
||||
TOKEN=$(as_books "$WB" token print --root /srv/wanderbooks --book acme \
|
||||
--name ci)
|
||||
as_books "$WB" token create --root /srv/wanderbooks --book tunnel --name ci >/dev/null
|
||||
WRITE_TOKEN=$(as_books "$WB" token print --root /srv/wanderbooks --book tunnel \
|
||||
--name ci)
|
||||
|
||||
# The check, as the agent account, over the loopback door.
|
||||
WB_TWO_USER=1 \
|
||||
WB_TENANT_ROOT=/srv/wanderbooks \
|
||||
WB_SERVER_URL="http://127.0.0.1:$WB_PORT" \
|
||||
WB_BOOK=acme \
|
||||
WB_TOKEN="$TOKEN" \
|
||||
WB_WRITE_BOOK=tunnel \
|
||||
WB_WRITE_TOKEN="$WRITE_TOKEN" \
|
||||
WB_SOURCE_DIR=/tmp/source \
|
||||
cargo test -p ledger-cli --test two_user -- --nocapture --test-threads=1
|
||||
@@ -3,7 +3,3 @@
|
||||
.DS_Store
|
||||
site/dist/
|
||||
.netlify/
|
||||
# cargo-mutants output (just mutants*)
|
||||
/mutants.out*/
|
||||
/mutants-*/
|
||||
# bookspec's own workspace target
|
||||
@@ -12,13 +12,9 @@ what they haven't demonstrated.
|
||||
semantics, built to become a full bookkeeping application (engine now, UI
|
||||
later). The reference points:
|
||||
|
||||
- **Spec:** accounting principles, written down as proofs
|
||||
(`docs/verification.md`). That is the only authority.
|
||||
- **Cross-check oracles:** GnuCash (open source, open file formats), the
|
||||
old Google Sheet, QBO/Xero exports. Useful to diff against, *never*
|
||||
trusted to be right: a disagreement is adjudicated from principles, and
|
||||
nothing may rest on another program's behavior alone. See
|
||||
`docs/oracle.md`, "Who is right".
|
||||
- **Semantic oracle:** GnuCash. It is open source with open file formats
|
||||
(XML/SQLite), which makes it both the spec and the reference implementation
|
||||
we diff against. See `docs/oracle.md`.
|
||||
- **Workflow reference:** QuickBooks Online and Xero — for *what the UI must
|
||||
do*, never for code. We observe behavior; we write our own implementation.
|
||||
- **Method reference:** storytold/ArtCraft's app-cloning pipeline
|
||||
@@ -41,10 +37,6 @@ later). The reference points:
|
||||
IRS publications for tax-adjacent logic later. GnuCash itself is GPL —
|
||||
read its docs, never copy its code. This repo is MIT OR Apache-2.0.
|
||||
Never disassemble or copy from proprietary software.
|
||||
6. **Accounting laws are proved, not just tested.** Arithmetic that money
|
||||
flows through lives in `kernel.rs` and carries Kani proofs; the laws
|
||||
it serves carry Lean proofs. `just prove` stays green. See
|
||||
"Formal methods" below.
|
||||
|
||||
## Verification protocol (read this twice)
|
||||
|
||||
@@ -68,76 +60,6 @@ numbers**. The analog of storytold's screenshot-in-the-loop:
|
||||
- The randomized invariant test (`random_balanced_journals_preserve_invariants`)
|
||||
is the always-on property oracle. Extend it when you add invariants.
|
||||
|
||||
## Formal methods: the bar (read this three times)
|
||||
|
||||
Tests check the cases someone thought of; proofs check all of them. The
|
||||
first formal pass on this engine (2026-10, `docs/verification.md`) paid
|
||||
for itself several times over, on code that already had 190+ tests,
|
||||
property tests, mutation testing, and 11.5M fuzz executions:
|
||||
|
||||
- **Negative line amounts were accepted by the engine.** Only the MCP
|
||||
layer refused them. Any other caller could post them.
|
||||
- **A large enough amount crashed the engine** (a `Decimal` overflow
|
||||
panic while summing lines) instead of returning an error.
|
||||
- **Currency conversion could crash** the same way.
|
||||
- **The verifier itself had a soundness bug.** Kani 0.68 mis-models
|
||||
`copy_from_slice` of symbolic length (model-checking/kani#4904): it
|
||||
reported a reversal that doesn't net to zero. We didn't wave it away.
|
||||
Replaying the counterexample natively, then shrinking it to a 15-line
|
||||
crate, proved it was the tool, not the books. Now a canary harness
|
||||
watches for the fix, and covers prove no proof is vacuous.
|
||||
- **Writing the spec simplified the code.** `credit_line` and
|
||||
`closing_line` turned out to be the same function. Lean also proved
|
||||
that no draft can exist in a closed period, the invariant that lets
|
||||
the store journal a post before applying it.
|
||||
|
||||
So the bar, for every agent, every change:
|
||||
|
||||
0. **Specs come from authorities, not memory.** Every rule the engine
|
||||
enforces is in `docs/spec/rules.md` with its source: Pacioli for the
|
||||
mechanics of double entry, the IRS (vendored verbatim, public domain)
|
||||
for records, periods, 1099s, capitalization, partnerships, and payroll,
|
||||
FASB's Codification (cited, never copied) for GAAP. A rule no authority
|
||||
states is marked CONVENTION or CHOICE (bookspec's word for a
|
||||
decision), with who decided and when.
|
||||
Spec blocks in the code (```text GOAL/SOURCE/INVARIANTS/SCENARIOS)
|
||||
carry a `SOURCE:` line. Never write a spec from what the code happens
|
||||
to do, or from what you remember; find the document, or ask.
|
||||
|
||||
1. **Money arithmetic goes in `kernel.rs`, generic over `Amount`.** If
|
||||
you write a sum, a sign flip, a split, or a rounding plug anywhere
|
||||
else, move it into the kernel. The engine calls the kernel. It never
|
||||
gets a second copy.
|
||||
2. **Every kernel function gets a Kani harness** in `src/proofs.rs`, on
|
||||
fixed arrays (no symbolic-length `Vec` growth, no `copy_from_slice`),
|
||||
with a `kani::cover!` showing the interesting case is reached.
|
||||
3. **Every new law gets a Lean theorem** in `verify/lean`. Laws about
|
||||
the books over time are theorems about `Reachable` states. A new
|
||||
`Ledger` command is a new `Step` constructor, with its preservation
|
||||
proof. No `sorry`, ever; `Audit.lean` lists every headline theorem.
|
||||
4. **Every kernel instantiation gets a bridge property** in
|
||||
`tests/kernel_bridge.rs`, so what Kani proved on `i64` holds on
|
||||
`Decimal`.
|
||||
5. **A failing proof is a finding, not an obstacle.** Get the
|
||||
counterexample, replay it natively, and decide on evidence: bug in
|
||||
our code, bug in the spec, or bug in the tool. Never weaken an
|
||||
assertion, raise a bound, or delete a harness to make red go green
|
||||
without writing down why in `docs/verification.md`.
|
||||
6. **More, not less.** When you touch an area the proofs don't cover
|
||||
yet (A/R and A/P documents, payroll, depreciation, FX, persistence),
|
||||
leave it with more formal coverage than you found it. Proptest,
|
||||
mutation testing, fuzzing, Kani, and Lean all stack. Use all of them.
|
||||
|
||||
`just check` and `just prove` both pass before a batch of work touching
|
||||
`ledger-core` is pushed: once, at the end of the batch, with everything
|
||||
they find fixed in one pass. Inside a batch (a merge, a fix, a unit's
|
||||
commits), the check is proportional to the change: the workspace
|
||||
compiles and the tests the change touched pass. The owner's rule
|
||||
(plan D-60, 2026-10-29): the first wave's five branches took twelve
|
||||
hours to land with a full gate around every merge, and the one gate at
|
||||
the end found the same five tests. GitHub's `check` job runs `just
|
||||
check` on every push and is the exhaustive gate in the background.
|
||||
|
||||
## Parity tracking
|
||||
|
||||
`docs/parity.md` is the parity catalog: every semantic feature we intend to
|
||||
@@ -156,23 +78,15 @@ clone, with status. Rules:
|
||||
`post_entry`, `reverse_entry`, `add_account`). Struct fields stay private;
|
||||
no other mutation path may exist.
|
||||
- `ledger-core` depends only on `rust_decimal`, `thiserror`, `chrono`,
|
||||
`serde`, `serde_json`, and `jiff` (the bundled time-zone database for
|
||||
`company.timezone`: "today" is computed by the caller and passed in;
|
||||
the core still reads no clock). New dependencies need justification in
|
||||
the commit message. No I/O, no networking, no time-source in the core.
|
||||
- Future crates (`ledger-store`, `ledger-import`, `ledger-reader`, `ledger-oracle`, `books-app`)
|
||||
`serde`, `serde_json`. New dependencies need justification in the commit
|
||||
message. No I/O, no networking, no time-source in the core.
|
||||
- Future crates (`ledger-store`, `ledger-import`, `ledger-oracle`, `books-app`)
|
||||
depend inward toward the core, never the reverse. See `docs/architecture.md`.
|
||||
|
||||
## Working here
|
||||
|
||||
- `mise install` sets up every tool (pinned in `mise.toml`): Rust,
|
||||
just, Kani, Lean, cargo-mutants, cargo-fuzz. If a recipe needs a tool,
|
||||
it goes in `mise.toml`; nothing gets installed by hand.
|
||||
- Rust stable, edition 2024. `just check` must pass: fmt, clippy with zero
|
||||
warnings, every test. `just prove` must pass for core changes. Both run
|
||||
once per batch, before the push, never around every merge or commit
|
||||
inside one (D-60); the cheap checks (compile, the touched tests, clippy
|
||||
on the touched crate) run as you go.
|
||||
- Rust stable, edition 2024. `cargo test` must pass; `cargo clippy` should be
|
||||
clean; `cargo fmt` before committing.
|
||||
- Conventional commits: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`.
|
||||
One logical change per commit. The commit message explains the *accounting*
|
||||
reasoning, not just the code.
|
||||
@@ -186,24 +100,16 @@ clone, with status. Rules:
|
||||
|
||||
| Read this | When you need |
|
||||
|---|---|
|
||||
| `plan/served.md` | The plan every remaining unit is built from: section 8.0 (status, order, recipes), 8.2 (the units), 10 (decisions) |
|
||||
| `plan/HANDOFF.md` | The prompt for the implementing team, and how to resume the paused worktrees |
|
||||
| `docs/requirements.md` | What the engine must do (R-1…), what is future work |
|
||||
| `docs/architecture.md` | Crate layout, command pattern, dependency rules |
|
||||
| `docs/oracle.md` | How we check correctness: golden files, property tests, fuzzing, cross-checks against other implementations (and who is right) |
|
||||
| `docs/spec/` | The authorities every rule comes from (`rules.md`), with public-domain sources vendored verbatim |
|
||||
| `docs/verification.md` | The formal proofs: every accounting law, its Kani harness and Lean theorem, what they found |
|
||||
| `docs/oracle.md` | How we prove correctness: golden files, property tests, GnuCash diffing |
|
||||
| `docs/parity.md` | Feature catalog and the non-regression floor |
|
||||
| `docs/capability-parity.md` | Parity vs the GSheet+GDrive system + the cutover rule |
|
||||
| `docs/mcp-interface.md` | The MCP tool surface: the primary interface spec |
|
||||
| `docs/receipt-store.md` | Blob store + `ledger-store` SQLite persistence spec |
|
||||
| `docs/testing.md` | Every test layer, each unit's test record, and what P12 must mutate |
|
||||
| `docs/parties-and-attachments.md` | The party directory, roles, the TIN vault, attachments to any subject |
|
||||
| https://git.swaits.com/swaits/bookspec | The formal accounting specification's own repository: README, examples, checker, runner (P13); pinned in `crates/ledger-core/Cargo.toml` |
|
||||
| `deploy/README.md` | Running the books on a machine of your own (P9a) |
|
||||
| `docs/glossary.md` | Debit/credit and friends, for agents new to accounting |
|
||||
| `plan/STATUS.md` | Where the project stands right now |
|
||||
| `plan/roadmap.md` | Where the project has been and is going: the installed app, then the hosted service |
|
||||
| `plan/roadmap.md` | Phase plan: semantics → oracle harness → egui app → workflow parity |
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Generated
+26
-1449
File diff suppressed because it is too large.
Load diff
+1
-27
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-reader", "crates/ledger-cli", "crates/ledger-gui", "crates/ledger-vault", "crates/ledger-host", "crates/ledger-package"]
|
||||
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
@@ -9,29 +9,3 @@ repository = "https://git.swaits.com/swaits/wanderbooks"
|
||||
|
||||
[workspace.lints.rust]
|
||||
missing_docs = "warn"
|
||||
# `cargo kani` sets cfg(kani) for the proof harnesses.
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(kani)"] }
|
||||
|
||||
# Argon2id at the plan's 64 MiB, three passes, takes seconds unoptimized:
|
||||
# its crates are built optimized in debug and test builds too.
|
||||
[profile.dev.package.argon2]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.blake2]
|
||||
opt-level = 3
|
||||
|
||||
# `wanderbooks update` hashes and verifies a whole program (P11a); its
|
||||
# tests do it over a debug build's hundreds of megabytes.
|
||||
[profile.dev.package.sha2]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.minisign]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.minisign-verify]
|
||||
opt-level = 3
|
||||
|
||||
# The program that ships in the packages (P11a): symbols stripped, so the
|
||||
# AppImage, .deb and .rpm are not carrying debug tables.
|
||||
[profile.release]
|
||||
strip = "symbols"
|
||||
@@ -9,17 +9,14 @@ through MCP. Self-hosted, no rent.
|
||||
|
||||
- **`ledger-core`** — the semantics engine. Exact decimal money, five
|
||||
account types, balanced journal entries, immutable posted log with
|
||||
reversing entries, trial balance, P&L, balance sheet, the statement of
|
||||
cash flows (direct method, with the reconciliation and noncash
|
||||
schedules), fixed-asset register with straight-line depreciation.
|
||||
reversing entries, trial balance, P&L, balance sheet, indirect-method
|
||||
cash flow, fixed-asset register with straight-line depreciation.
|
||||
- **`ledger-store`** — durable storage. The source of truth is an
|
||||
append-only, hash-chained JSONL journal; SQLite is a disposable
|
||||
projection rebuilt from it; receipts live in a content-addressed blob
|
||||
store. Drafts are durable, corrupt projections self-heal.
|
||||
- **`ledger-mcp`** — the AI-first interface. 122 MCP tools (2026-10-28), served by the
|
||||
books server (`wanderbooks server`) on the data root's local sockets and
|
||||
reached through the agents' bridge (`wanderbooks mcp --book ID`): draft →
|
||||
preview → post workflow, server-side receipt
|
||||
- **`ledger-mcp`** — the AI-first interface. 64 MCP tools over stdio or
|
||||
Streamable HTTP: draft → preview → post workflow, server-side receipt
|
||||
policy (>$25), anti-splitting detection, asset management, digest for
|
||||
human review, plus read-only resources for dashboards.
|
||||
- **`ledger-import`** — QuickBooks interop. Import bank/credit-card CSVs
|
||||
@@ -27,7 +24,7 @@ through MCP. Self-hosted, no rent.
|
||||
Desktop) and XLSX reports.
|
||||
- **`ledger-config`** — the company settings in a books dir's
|
||||
`wanderbooks.toml`.
|
||||
- **`ledger-gui`** — the viewer: a read-only, live window on the books for owners and their
|
||||
- **`ledger-gui`** — the read-only desktop view for owners and their
|
||||
accountant (egui). Answers first (cash, runway, profit, what needs
|
||||
attention), then every number drills down to its lines, its entry, the
|
||||
rendered receipt, and the journal history. Follows the journal live:
|
||||
@@ -39,37 +36,19 @@ through MCP. Self-hosted, no rent.
|
||||
This is the storytold way applied to accounting: agents verify against
|
||||
oracles, never ship what they haven't demonstrated.
|
||||
|
||||
- About 2,950 tests across the two workspaces (2026-10-29): invariant tests encoding accounting law, golden-file
|
||||
report snapshots, property tests (proptest), end-to-end MCP tests through
|
||||
a real books server.
|
||||
- **Formal proofs of the accounting laws** (`just prove`,
|
||||
`docs/verification.md`). Kani model-checks the engine's arithmetic
|
||||
kernel exhaustively within bounds. Lean 4 proves that, after any
|
||||
sequence of commands, the trial balance balances, the accounting
|
||||
equation holds, posted entries are forever, closed periods stay
|
||||
frozen, and a close moves exactly the net income to equity. The first
|
||||
pass found three engine bugs that every test, fuzzer, and mutant had
|
||||
missed: negative amounts accepted, and two crashes on oversized
|
||||
amounts. It also caught a soundness bug in the model checker itself.
|
||||
- Mutation testing via cargo-mutants, a local run (paused for development
|
||||
until the P12 max-testing pass, D-33; not a CI job, D-59); fuzzing via
|
||||
cargo-fuzz (11.5M+ executions, zero crashes), weekly in CI.
|
||||
- GnuCash is *a* behavioral cross-check, never the authority and never
|
||||
a code source (clean room, MIT OR Apache-2.0). Accounting principles,
|
||||
proved, are the spec. Where any other program disagrees, we work out
|
||||
from principles who is right.
|
||||
- 70 workspace tests: invariant tests encoding accounting law, golden-file
|
||||
report snapshots, property tests (proptest), end-to-end MCP stdio tests.
|
||||
- Mutation testing via cargo-mutants; fuzzing via cargo-fuzz (11.5M+
|
||||
executions, zero crashes).
|
||||
- GnuCash is the behavioral oracle (never a code source — clean room,
|
||||
MIT OR Apache-2.0).
|
||||
|
||||
## Status
|
||||
|
||||
Parity is 12 of 12 against the spreadsheet workflow it replaced
|
||||
(`docs/capability-parity.md`); the books cut over on 2026-10-20 and the
|
||||
Sheet is frozen as of 2026-10-11. The remaining work, its order and its
|
||||
hand-off are `plan/served.md` section 8.0; the destination is one
|
||||
installed app for Mac, Windows and Linux (P11), then the hosted service
|
||||
the plan describes and does not build. Today the agent path runs on
|
||||
Linux and macOS: tokens live in a 0600 file under the private data root
|
||||
(D-63); the rest of the host is Unix-only, so Windows is WSL2 only until
|
||||
its sockets and data root are ported.
|
||||
Parity is 12 of 12 against the spreadsheet workflow it replaces
|
||||
(`docs/capability-parity.md`). The parallel-run plan
|
||||
(`docs/parallel-run.md`) is written; the Google Sheet stays the system of
|
||||
record until a clean month and explicit cutover approval.
|
||||
|
||||
See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
|
||||
[`plan/roadmap.md`](plan/roadmap.md) for where it's going.
|
||||
@@ -77,69 +56,32 @@ See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
cargo test # whole suite
|
||||
cargo run -p ledger-gui -- --demo # the viewer, on a demo company
|
||||
cargo test # whole suite
|
||||
cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio
|
||||
cargo run -p ledger-gui -- --demo # the desktop view, on a demo company
|
||||
```
|
||||
New books, one command (installs Rust and wanderbooks if needed, makes the
|
||||
books' private folder, starts books called `acme` and lets Claude Code reach
|
||||
them; nothing needs sudo):
|
||||
books dir with the MCP server registered for it, and starts the agent):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme
|
||||
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/Acme
|
||||
```
|
||||
|
||||
(Linux and macOS. On Windows run it inside WSL2.)
|
||||
|
||||
Already installed, three commands: `wanderbooks setup`, `wanderbooks books
|
||||
create acme`, `wanderbooks connect claude --book acme` (or `connect codex`).
|
||||
Then talk to the agent; `wanderbooks gui acme` shows the books, and its
|
||||
Books menu backs them up, restores, starts new books and closes them. With
|
||||
no terminal (the AppImage, a menu entry), opening the app with no books
|
||||
shows a first-run page that does the same three steps (the private folder,
|
||||
an owner passphrase, the first books) and connects an agent; a panel along
|
||||
the bottom of the window then runs Claude Code or Codex in the books' own
|
||||
folder, `~/Documents/Wanderbooks/<id>` (optional: any agent can use the
|
||||
books).
|
||||
|
||||
Updating: an AppImage, or a `wanderbooks` you put somewhere yourself, brings
|
||||
itself to the newest release with `wanderbooks update` (it checks the
|
||||
download against our release key, keeps the version it replaces, and the
|
||||
open books restart onto the new one when idle); `wanderbooks rollback` goes
|
||||
back, unless a book has since moved to a newer journal format. A `.deb`,
|
||||
`.rpm` or AUR install is updated by its package manager and `update` says
|
||||
so. The Linux packages are built here with `just package-linux`
|
||||
(docs/packaging.md); none is published yet.
|
||||
|
||||
## Developing
|
||||
|
||||
Every tool the recipes use (Rust, just, Kani, Lean, cargo-mutants,
|
||||
cargo-fuzz, Node for deploys) is pinned in `mise.toml`:
|
||||
|
||||
```sh
|
||||
mise install
|
||||
just # lists the recipes: check, build, prove, demo, ...
|
||||
```
|
||||
Already installed: `wanderbooks new ~/Books/Acme`. In a books dir, `just`
|
||||
lists the shortcuts (`just gui`, `just agent`, `just backup`).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
crates/ledger-core/ # engine: money, accounts, journal, ledger, reports
|
||||
crates/ledger-store/ # journal + SQLite projection + blob store
|
||||
crates/ledger-mcp/ # MCP server: 129 tools, resources, handbook, receipt policy
|
||||
crates/ledger-mcp/ # MCP server: 64 tools, resources, receipt policy
|
||||
crates/ledger-import/ # QBO CSV import, IIF/XLSX export
|
||||
crates/ledger-config/ # wanderbooks.toml company settings
|
||||
crates/ledger-cli/ # `wanderbooks`: the one executable (every mode)
|
||||
crates/ledger-host/ # the host, the agents' bridge, the owner's commands
|
||||
crates/ledger-package/ # the shared read-only view: the CPA package, the invoice PDF, Needs attention
|
||||
crates/ledger-vault/ # the TIN vault (XChaCha20-Poly1305 under the book's key)
|
||||
crates/ledger-gui/ # the viewer: read-only, live (egui)
|
||||
verify/lean/ # the accounting laws as Lean theorems (`just prove`)
|
||||
# the formal accounting specification lives in its own repository, https://git.swaits.com/swaits/bookspec (pinned dev-dependency, P13)
|
||||
fuzz/, oracle/ # fuzz targets; the GnuCash differential (`just oracle`)
|
||||
deploy/ # the self-host recipe and system unit (P9a)
|
||||
site/ # wanderbooks.ai and install.sh
|
||||
docs/ # requirements, architecture, oracle, parity, testing, verification, spec, glossary
|
||||
plan/ # served.md (the plan), STATUS.md, roadmap.md, HANDOFF.md, gui.md
|
||||
crates/ledger-cli/ # `wanderbooks new`: one-command books dir + agent
|
||||
crates/ledger-gui/ # read-only live desktop view (egui)
|
||||
docs/ # requirements, architecture, oracle, parity, glossary
|
||||
plan/ # STATUS.md, roadmap.md
|
||||
AGENTS.md # operating manual for agents working here
|
||||
```
|
||||
|
||||
|
||||
@@ -4,61 +4,16 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "wanderbooks: the one executable (viewer, books server, host, agents' bridge, owner's commands)."
|
||||
description = "wanderbooks CLI: one command from nothing to a working books directory."
|
||||
|
||||
[[bin]]
|
||||
name = "wanderbooks"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["gui"]
|
||||
# The viewer. Off in the server-only build for another machine (plan
|
||||
# D-22, P9), so a headless machine carries no window libraries.
|
||||
gui = ["dep:ledger-gui"]
|
||||
|
||||
[dependencies]
|
||||
ledger-host = { path = "../ledger-host" }
|
||||
ledger-mcp = { path = "../ledger-mcp" }
|
||||
ledger-store = { path = "../ledger-store" }
|
||||
ledger-gui = { path = "../ledger-gui", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
base64 = "0.22"
|
||||
chrono = "0.4"
|
||||
# Every test that drives the books server's tools: the CPA package they
|
||||
# open, the outputSchemas they check against, the random cases, and the
|
||||
# database read_only_tools_change_nothing compares by its rows.
|
||||
jsonschema = { version = "0.58", default-features = false }
|
||||
libc = "0.2"
|
||||
proptest = "1"
|
||||
reqwest = "0.13"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
rust_decimal = "1"
|
||||
rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
ledger-config = { path = "../ledger-config" }
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
ledger-package = { path = "../ledger-package" }
|
||||
ledger-import = { path = "../ledger-import" }
|
||||
ledger-vault = { path = "../ledger-vault" }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
toml = "1"
|
||||
tempfile = "3"
|
||||
zip = "9.0.1"
|
||||
minisign = "0.10.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
# The .rpm (P11a step 3), built by cargo-generate-rpm from the release
|
||||
# program: `just package-linux`. Paths are relative to the workspace root.
|
||||
[package.metadata.generate-rpm]
|
||||
name = "wanderbooks"
|
||||
summary = "Double-entry bookkeeping your agent keeps and you can read"
|
||||
license = "MIT OR Apache-2.0"
|
||||
url = "https://wanderbooks.ai"
|
||||
assets = [
|
||||
{ source = "target/release/wanderbooks", dest = "/usr/bin/wanderbooks", mode = "0755" },
|
||||
{ source = "deploy/packaging/install-method-rpm", dest = "/usr/share/wanderbooks/install-method", mode = "0644" },
|
||||
{ source = "deploy/packaging/wanderbooks-static.desktop", dest = "/usr/share/applications/wanderbooks.desktop", mode = "0644" },
|
||||
]
|
||||
+475
-532
File diff suppressed because it is too large.
Load diff
@@ -1,202 +0,0 @@
|
||||
//! Agent evals (plan P5 step 7): scripted transcripts, no model. Each is a
|
||||
//! JSON file in `agent_evals/`, played against a real books server through
|
||||
//! the wire as an agent reaches it. The rule they enforce is the point of
|
||||
//! the result envelope: **an agent that begins with `books.start` can do
|
||||
//! its work by calling only what the previous result's `next_steps` named.**
|
||||
//!
|
||||
//! A transcript is `{ "setup": [..], "settings": "<toml>", "steps": [..] }`.
|
||||
//! `setup` calls are made directly (the chart and the books an agent finds
|
||||
//! when it arrives; they are not the agent's moves). A step is one of:
|
||||
//!
|
||||
//! - `{"call": "books.start", "args": {..}}`: only the first step, and
|
||||
//! only `books.start`.
|
||||
//! - `{"follow": "books.x", "with": {..}}`: the previous result's
|
||||
//! `next_steps` must name tool `books.x`; the call is made with that
|
||||
//! step's `args` and `with` laid over them (what the agent decides: an
|
||||
//! amount, a date).
|
||||
//! - `{"follow_prompt": "onboard"}`: the previous result's `next_steps`
|
||||
//! must name that prompt; it is fetched with `prompts/get`.
|
||||
//! - `{"expect_next_steps_contains": "books.x"}` and
|
||||
//! `{"expect": {"/json/pointer": value}}` on the previous result.
|
||||
//!
|
||||
//! In `with` and `args`, `{"$upload": {"name", "mime", "text"}}` is the
|
||||
//! upload_id of that file, uploaded first as an agent does, and
|
||||
//! `{"$var": "x"}` is a value a setup call saved with `"save": {"x":
|
||||
//! "/pointer"}`.
|
||||
//!
|
||||
//! Expected behavior comes from `docs/mcp-interface.md` ("The result
|
||||
//! envelope") and plan 4.6.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{Books, Wire, answer_of};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A value with `$upload` and `$var` laid in.
|
||||
fn resolve(w: &mut Wire, vars: &HashMap<String, Value>, v: &Value) -> Value {
|
||||
match v {
|
||||
Value::Object(o) if o.len() == 1 && o.contains_key("$upload") => {
|
||||
let u = &o["$upload"];
|
||||
let text = u["text"].as_str().expect("an upload has text");
|
||||
json!(w.upload(
|
||||
text.as_bytes(),
|
||||
u["name"].as_str().unwrap_or("file.pdf"),
|
||||
u["mime"].as_str().unwrap_or("application/pdf"),
|
||||
u["purpose"].as_str().unwrap_or("receipt"),
|
||||
))
|
||||
}
|
||||
Value::Object(o) if o.len() == 1 && o.contains_key("$var") => vars
|
||||
.get(o["$var"].as_str().unwrap())
|
||||
.unwrap_or_else(|| panic!("no saved value {}", o["$var"]))
|
||||
.clone(),
|
||||
Value::Object(o) => Value::Object(
|
||||
o.iter()
|
||||
.map(|(k, x)| (k.clone(), resolve(w, vars, x)))
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(a) => Value::Array(a.iter().map(|x| resolve(w, vars, x)).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Play one transcript; returns the tools it called after `books.start`.
|
||||
fn play(name: &str, script: &Value) -> Vec<String> {
|
||||
let books = Books::with(&format!("eval-{name}"), "USD", script["settings"].as_str());
|
||||
let mut w = books.session("agent-evals");
|
||||
let mut vars: HashMap<String, Value> = HashMap::new();
|
||||
for s in script["setup"].as_array().into_iter().flatten() {
|
||||
let args = resolve(&mut w, &vars, &s["args"]);
|
||||
let tool = s["call"].as_str().unwrap();
|
||||
let out =
|
||||
answer_of(&w.call(tool, args)).unwrap_or_else(|e| panic!("{name}: setup {tool}: {e}"));
|
||||
for (var, pointer) in s["save"].as_object().into_iter().flatten() {
|
||||
vars.insert(
|
||||
var.clone(),
|
||||
out.pointer(pointer.as_str().unwrap())
|
||||
.unwrap_or_else(|| panic!("{name}: setup {tool} has no {pointer}: {out}"))
|
||||
.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut last: Option<Value> = None;
|
||||
let mut called = Vec::new();
|
||||
for (i, step) in script["steps"]
|
||||
.as_array()
|
||||
.expect("steps")
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let here = format!("{name} step {i}");
|
||||
if let Some(tool) = step["call"].as_str() {
|
||||
assert_eq!(
|
||||
i, 0,
|
||||
"{here}: only the first step may call without following"
|
||||
);
|
||||
assert_eq!(tool, "books.start", "{here}: the first call is books.start");
|
||||
let args = resolve(&mut w, &vars, &step["args"]);
|
||||
last =
|
||||
Some(answer_of(&w.call_raw(tool, args)).unwrap_or_else(|e| panic!("{here}: {e}")));
|
||||
} else if let Some(want) = step["follow"].as_str() {
|
||||
let prev = last
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("{here}: follow before start"));
|
||||
let offered = prev["next_steps"].as_array().cloned().unwrap_or_default();
|
||||
let named = offered
|
||||
.iter()
|
||||
.find(|s| s["tool"] == json!(want))
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{here}: {want} is not in the previous result's next_steps: {:?}",
|
||||
offered
|
||||
.iter()
|
||||
.map(|s| s["tool"].clone())
|
||||
.collect::<Vec<_>>()
|
||||
)
|
||||
});
|
||||
let mut args: Map<String, Value> =
|
||||
named["args"].as_object().cloned().unwrap_or_default();
|
||||
for (k, v) in step["with"].as_object().into_iter().flatten() {
|
||||
args.insert(k.clone(), resolve(&mut w, &vars, v));
|
||||
}
|
||||
let out = answer_of(&w.call_raw(want, Value::Object(args)))
|
||||
.unwrap_or_else(|e| panic!("{here}: {want} was refused: {e}"));
|
||||
called.push(want.to_string());
|
||||
last = Some(out);
|
||||
} else if let Some(want) = step["follow_prompt"].as_str() {
|
||||
let prev = last
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("{here}: follow before start"));
|
||||
assert!(
|
||||
prev["next_steps"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|s| s["prompt"] == json!(want)),
|
||||
"{here}: prompt {want} is not in next_steps"
|
||||
);
|
||||
let got = w.rpc("prompts/get", json!({"name": want, "arguments": {}}));
|
||||
let text = got["result"]["messages"][0]["content"]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{here}: the prompt did not render: {got}"));
|
||||
assert!(text.contains("books."), "{here}: {text}");
|
||||
} else if let Some(tool) = step["expect_next_steps_contains"].as_str() {
|
||||
let prev = last.as_ref().unwrap();
|
||||
assert!(
|
||||
prev["next_steps"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|s| s["tool"] == json!(tool)),
|
||||
"{here}: next_steps lack {tool}: {}",
|
||||
prev["next_steps"]
|
||||
);
|
||||
} else if let Some(expect) = step["expect"].as_object() {
|
||||
let prev = last.as_ref().unwrap();
|
||||
for (pointer, want) in expect {
|
||||
assert_eq!(
|
||||
prev.pointer(pointer),
|
||||
Some(want),
|
||||
"{here}: {pointer} in {prev}"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
panic!("{here}: not a step: {step}");
|
||||
}
|
||||
}
|
||||
called
|
||||
}
|
||||
|
||||
/// Every transcript follows only what it was told, and ends where it says.
|
||||
#[test]
|
||||
fn agent_evals_follow_only_next_steps() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/agent_evals");
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "json"))
|
||||
.collect();
|
||||
files.sort();
|
||||
let names: Vec<String> = files
|
||||
.iter()
|
||||
.map(|p| p.file_stem().unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"draft_awaiting_a_receipt",
|
||||
"month_end_close",
|
||||
"onboarding_to_first_post",
|
||||
"receipt_backed_post",
|
||||
"reconciliation",
|
||||
"reversal"
|
||||
],
|
||||
"the six evals the plan names"
|
||||
);
|
||||
for (name, path) in names.iter().zip(&files) {
|
||||
let script: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap())
|
||||
.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let called = play(name, &script);
|
||||
assert!(!called.is_empty(), "{name} calls nothing");
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
{
|
||||
"description": "A draft that waits for its receipt, and posts when it arrives.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.draft_entry",
|
||||
"with": {
|
||||
"date": "2026-01-10",
|
||||
"description": "Landlord, January",
|
||||
"no_party_reason": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "6000",
|
||||
"side": "debit",
|
||||
"amount": "40.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"side": "credit",
|
||||
"amount": "40.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.set_draft_state",
|
||||
"with": {
|
||||
"state": "awaiting_evidence",
|
||||
"note": "the landlord's receipt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/state": "awaiting_evidence"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.attach_receipt",
|
||||
"with": {
|
||||
"upload_id": {
|
||||
"$upload": {
|
||||
"name": "receipt.pdf",
|
||||
"mime": "application/pdf",
|
||||
"text": "%PDF-1.4 a receipt for rent",
|
||||
"purpose": "receipt"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"description": "A period that ended is closed: the open period is found, previewed, closed, and the books verified.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.create_period",
|
||||
"args": {
|
||||
"name": "2025-12",
|
||||
"start": "2025-12-01",
|
||||
"end": "2025-12-31"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {
|
||||
"focus": "close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.list_periods"
|
||||
},
|
||||
{
|
||||
"follow": "books.list_periods",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.close_period",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "preview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.close_period",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "closed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.verify"
|
||||
},
|
||||
{
|
||||
"follow": "books.verify",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "ok"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
{
|
||||
"description": "A new book: onboarding to the first posted entry, following only next_steps.",
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {
|
||||
"agent": {
|
||||
"model": "scripted",
|
||||
"reasoning_effort": "none",
|
||||
"harness": "agent_evals"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/state": "needs_onboarding"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.set_setting"
|
||||
},
|
||||
{
|
||||
"follow_prompt": "onboard"
|
||||
},
|
||||
{
|
||||
"follow": "books.set_setting",
|
||||
"with": {
|
||||
"changes": [
|
||||
{
|
||||
"key": "company.name",
|
||||
"value": "Eval Co"
|
||||
}
|
||||
],
|
||||
"decided_by": "Avery Quill"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.add_account"
|
||||
},
|
||||
{
|
||||
"follow": "books.add_account",
|
||||
"with": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_account",
|
||||
"with": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.draft_entry",
|
||||
"with": {
|
||||
"date": "2026-01-05",
|
||||
"description": "Opening capital",
|
||||
"no_party_reason": "opening balance"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"side": "debit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "3000",
|
||||
"side": "credit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "preview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.show"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
{
|
||||
"description": "A post the receipt policy covers: the preview asks for the receipt, the receipt is attached, the entry posts.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.draft_entry"
|
||||
},
|
||||
{
|
||||
"follow": "books.draft_entry",
|
||||
"with": {
|
||||
"date": "2026-01-10",
|
||||
"description": "January rent",
|
||||
"no_party_reason": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "6000",
|
||||
"side": "debit",
|
||||
"amount": "40.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"side": "credit",
|
||||
"amount": "40.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.attach_receipt"
|
||||
},
|
||||
{
|
||||
"follow": "books.attach_receipt",
|
||||
"with": {
|
||||
"upload_id": {
|
||||
"$upload": {
|
||||
"name": "receipt.pdf",
|
||||
"mime": "application/pdf",
|
||||
"text": "%PDF-1.4 a receipt for rent",
|
||||
"purpose": "receipt"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
{
|
||||
"description": "A cash account reconciled to its statement: record it, mark the lines, reconcile.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.draft_entry",
|
||||
"args": {
|
||||
"date": "2026-01-05",
|
||||
"description": "Opening capital",
|
||||
"no_party_reason": "opening balance"
|
||||
},
|
||||
"save": {
|
||||
"entry": "/entry_id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_line",
|
||||
"args": {
|
||||
"entry_id": {
|
||||
"$var": "entry"
|
||||
},
|
||||
"account": "1000",
|
||||
"side": "debit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_line",
|
||||
"args": {
|
||||
"entry_id": {
|
||||
"$var": "entry"
|
||||
},
|
||||
"account": "3000",
|
||||
"side": "credit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.post_entry",
|
||||
"args": {
|
||||
"entry_id": {
|
||||
"$var": "entry"
|
||||
},
|
||||
"confirmed": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.file_document",
|
||||
"args": {
|
||||
"category": "statement",
|
||||
"file_name": "jan.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"upload_id": {
|
||||
"$upload": {
|
||||
"name": "jan.pdf",
|
||||
"mime": "application/pdf",
|
||||
"text": "%PDF-1.4 January statement",
|
||||
"purpose": "document"
|
||||
}
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"statement": "/attachment_id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.record_statement"
|
||||
},
|
||||
{
|
||||
"follow": "books.record_statement",
|
||||
"with": {
|
||||
"attachment_id": {
|
||||
"$var": "statement"
|
||||
},
|
||||
"account": "1000",
|
||||
"period_start": "2026-01-01",
|
||||
"period_end": "2026-01-31",
|
||||
"opening_balance": "0.00",
|
||||
"closing_balance": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.mark_cleared",
|
||||
"with": {
|
||||
"entry_id": {
|
||||
"$var": "entry"
|
||||
},
|
||||
"line_index": 0,
|
||||
"cleared": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.reconcile",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"statement_date": "2026-01-31",
|
||||
"statement_balance": "10.00",
|
||||
"statement_attachment": {
|
||||
"$var": "statement"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "preview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.reconcile",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "reconciled"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
{
|
||||
"description": "A posted entry reversed: previewed, confirmed, never edited.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.draft_entry",
|
||||
"with": {
|
||||
"date": "2026-01-10",
|
||||
"description": "Coffee beans",
|
||||
"no_party_reason": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "6000",
|
||||
"side": "debit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"side": "credit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.reverse_entry",
|
||||
"with": {
|
||||
"date": "2026-01-11",
|
||||
"description": "Reverse: entered in error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "preview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.reverse_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,547 +0,0 @@
|
||||
//! The agent surface over the wire (plan P5): `books.start` v2, the
|
||||
//! journal in words, source corrections, the prompts and the resource
|
||||
//! templates. Every call goes through a real books server as an agent
|
||||
//! reaches it (`common.rs`).
|
||||
//!
|
||||
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — the
|
||||
//! journal in words (P5)", "The result envelope", "Prompts", "Resources")
|
||||
//! and plan 4.2, 4.4, 4.5.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{Books, answer_of};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
const SETTINGS: &str = "[company]\nname = \"Surface Co\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\ncash_accounts = [\"1000\"]\n";
|
||||
|
||||
fn chart(w: &mut common::Wire) {
|
||||
for (code, name, ty) in [
|
||||
("1000", "Cash", "asset"),
|
||||
("3000", "Capital", "equity"),
|
||||
("6000", "Rent", "expense"),
|
||||
] {
|
||||
answer_of(&w.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": ty}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A posted entry of `amount` rent paid from cash (under the receipt
|
||||
/// threshold); its id.
|
||||
fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
|
||||
let id = answer_of(&w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": date, "description": what, "no_party_reason": "test"}),
|
||||
))
|
||||
.unwrap()["entry_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// `books.journal_history` reads the journal in order, in words; filters
|
||||
/// by subject and type; pages with `more`; refuses a bad subject; and a
|
||||
/// refusal changes nothing.
|
||||
#[test]
|
||||
fn journal_history_traces_what_the_books_did() {
|
||||
let books = Books::with("p5-history", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p5");
|
||||
chart(&mut w);
|
||||
let first = post(&mut w, "2026-01-05", "January rent", "10.00");
|
||||
let second = post(&mut w, "2026-01-06", "Printer paper", "11.00");
|
||||
|
||||
let all = answer_of(&w.call("books.journal_history", json!({"limit": 200}))).unwrap();
|
||||
let lines = all["lines"].as_array().unwrap();
|
||||
let seqs: Vec<u64> = lines.iter().map(|l| l["seq"].as_u64().unwrap()).collect();
|
||||
assert!(
|
||||
seqs.windows(2).all(|p| p[0] < p[1]),
|
||||
"journal order: {seqs:?}"
|
||||
);
|
||||
assert_eq!(all["head_seq"], *seqs.last().unwrap());
|
||||
assert_eq!(all["more"], false);
|
||||
let posted = lines
|
||||
.iter()
|
||||
.find(|l| l["type"] == "entry.posted" && l["entry_id"] == json!(first))
|
||||
.unwrap_or_else(|| panic!("no entry.posted for {first}: {all}"));
|
||||
assert!(
|
||||
posted["summary"].as_str().unwrap().contains("January rent"),
|
||||
"{posted}"
|
||||
);
|
||||
|
||||
// A subject keeps the lines about it, and only those.
|
||||
let about = answer_of(&w.call(
|
||||
"books.journal_history",
|
||||
json!({"subject": format!("entry:{second}")}),
|
||||
))
|
||||
.unwrap();
|
||||
let about = about["lines"].as_array().unwrap();
|
||||
assert!(about.len() >= 3, "created, lines, posted: {about:?}");
|
||||
assert!(
|
||||
about.iter().all(|l| l["entry_id"] == json!(second)),
|
||||
"{about:?}"
|
||||
);
|
||||
|
||||
// A type group, and an exact type.
|
||||
let group = answer_of(&w.call("books.journal_history", json!({"types": ["entry."]}))).unwrap();
|
||||
assert!(
|
||||
group["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|l| l["type"].as_str().unwrap().starts_with("entry."))
|
||||
);
|
||||
let exact =
|
||||
answer_of(&w.call("books.journal_history", json!({"types": ["entry.posted"]}))).unwrap();
|
||||
assert_eq!(exact["lines"].as_array().unwrap().len(), 2);
|
||||
|
||||
// Paging: two at a time, from where the last page ended.
|
||||
let page = answer_of(&w.call("books.journal_history", json!({"limit": 2}))).unwrap();
|
||||
assert_eq!(page["lines"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(page["more"], true);
|
||||
let after = page["lines"][1]["seq"].as_u64().unwrap();
|
||||
let next = answer_of(&w.call(
|
||||
"books.journal_history",
|
||||
json!({"after_seq": after, "limit": 2}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(next["lines"][0]["seq"].as_u64().unwrap(), after + 1);
|
||||
|
||||
// A bad subject is refused with the shapes; the journal is unchanged.
|
||||
let before = books.journal();
|
||||
let err =
|
||||
answer_of(&w.call("books.journal_history", json!({"subject": "nonsense"}))).unwrap_err();
|
||||
assert!(err.contains("entry:12"), "{err}");
|
||||
assert_eq!(books.journal(), before);
|
||||
// It is a read: no head in the envelope.
|
||||
assert!(all.get("head").is_none(), "{all}");
|
||||
}
|
||||
|
||||
/// A source-only correction over the wire: the three tools take `reason`
|
||||
/// (or `revises` with it), the words stay, the journal says so, and a
|
||||
/// change of the words is not a correction.
|
||||
#[test]
|
||||
fn a_source_only_revision_over_the_wire() {
|
||||
let books = Books::with("p5-source", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p5");
|
||||
let convention = |by: &str, body: &str| {
|
||||
json!({"id": "close-checklist", "kind": "close_step", "title": "Close checklist",
|
||||
"body_md": body, "decided_by": by, "decided_on": "2026-10-20"})
|
||||
};
|
||||
answer_of(&w.call(
|
||||
"books.set_convention",
|
||||
convention("Owner", "Reconcile, then close."),
|
||||
))
|
||||
.unwrap();
|
||||
let same = answer_of(&w.call(
|
||||
"books.set_convention",
|
||||
convention("Pat Smith", "Reconcile, then close."),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
same.contains("already says exactly that") && same.contains("reason"),
|
||||
"{same}"
|
||||
);
|
||||
let mut fixed = convention("Pat Smith", "Reconcile, then close.");
|
||||
fixed["reason"] = json!("the CPA decided it");
|
||||
fixed["citation"] = json!("IRS Pub. 538");
|
||||
let out = answer_of(&w.call("books.set_convention", fixed.clone())).unwrap();
|
||||
assert_eq!(
|
||||
out["convention"]["source"]["decided_by"], "Pat Smith",
|
||||
"{out}"
|
||||
);
|
||||
assert_eq!(out["convention"]["revision"], 1, "the words did not change");
|
||||
let mut other = convention("Pat Smith", "Close first.");
|
||||
other["reason"] = json!("x");
|
||||
let err = answer_of(&w.call("books.set_convention", other)).unwrap_err();
|
||||
assert!(err.contains("more than its source"), "{err}");
|
||||
|
||||
// A decision and an open item.
|
||||
let decision = answer_of(&w.call(
|
||||
"books.record_decision",
|
||||
json!({"subject": "Materiality", "decision_md": "Five hundred dollars.",
|
||||
"decided_by": ["Owner"], "decided_on": "2026-10-20"}),
|
||||
))
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
let fixed_decision = json!({"revises": decision, "reason": "both decided it",
|
||||
"decided_by": ["Owner", "Pat Smith"], "decided_on": "2026-10-21"});
|
||||
answer_of(&w.call("books.record_decision", fixed_decision.clone())).unwrap();
|
||||
let d = answer_of(&w.call("books.decisions", json!({}))).unwrap();
|
||||
assert_eq!(
|
||||
d["decisions"][0]["decided_by"],
|
||||
json!(["Owner", "Pat Smith"]),
|
||||
"{d}"
|
||||
);
|
||||
assert_eq!(d["decisions"][0]["decision_md"], "Five hundred dollars.");
|
||||
let no_reason = answer_of(&w.call(
|
||||
"books.record_decision",
|
||||
json!({"revises": decision, "decided_by": ["Pat Smith"], "decided_on": "2026-10-22"}),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(no_reason.contains("reason"), "{no_reason}");
|
||||
let item = answer_of(&w.call(
|
||||
"books.raise_item",
|
||||
json!({"title": "Ask the CPA", "decided_by": "Owner", "decided_on": "2026-10-20"}),
|
||||
))
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
answer_of(&w.call(
|
||||
"books.raise_item",
|
||||
json!({"revises": item, "reason": "raised by the CPA", "decided_by": "Pat Smith",
|
||||
"decided_on": "2026-10-21"}),
|
||||
))
|
||||
.unwrap();
|
||||
let items = answer_of(&w.call("books.open_items", json!({}))).unwrap();
|
||||
assert_eq!(items["items"][0]["title"], "Ask the CPA");
|
||||
assert_eq!(
|
||||
items["items"][0]["raised"]["decided_by"], "Pat Smith",
|
||||
"{items}"
|
||||
);
|
||||
|
||||
// The journal says what happened, in order.
|
||||
let h = answer_of(&w.call(
|
||||
"books.journal_history",
|
||||
json!({"types": ["convention.", "decision.", "open_item."]}),
|
||||
))
|
||||
.unwrap();
|
||||
let types: Vec<&str> = h["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|l| l["type"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
types,
|
||||
[
|
||||
"convention.set",
|
||||
"convention.source_revised",
|
||||
"decision.recorded",
|
||||
"decision.source_revised",
|
||||
"open_item.raised",
|
||||
"open_item.source_revised"
|
||||
]
|
||||
);
|
||||
// A restart reads the same books.
|
||||
books.restart();
|
||||
let mut w = books.session("p5");
|
||||
let c = answer_of(&w.call("books.conventions", json!({}))).unwrap();
|
||||
assert_eq!(
|
||||
c["conventions"][0]["source"]["decided_by"], "Pat Smith",
|
||||
"{c}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Prompts: six, listed with their arguments, rendered with the company's
|
||||
/// name and the arguments; an argument is one token of the right shape.
|
||||
#[test]
|
||||
fn the_prompts_list_and_render() {
|
||||
let books = Books::with("p5-prompts", "USD", Some(SETTINGS));
|
||||
let mut w = books.wire();
|
||||
let init = w.initialize("p5");
|
||||
assert!(
|
||||
init["result"]["capabilities"]["prompts"].is_object(),
|
||||
"{init}"
|
||||
);
|
||||
let listed = w.rpc("prompts/list", json!({}));
|
||||
let names: Vec<&str> = listed["result"]["prompts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"onboard",
|
||||
"reconcile",
|
||||
"month_end_close",
|
||||
"cpa_handoff",
|
||||
"annual_compliance",
|
||||
"audit"
|
||||
]
|
||||
);
|
||||
let source = |name: &str| {
|
||||
std::fs::read_to_string(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-mcp/prompts")
|
||||
.join(format!("{name}.md")),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
for (name, args, fills) in [
|
||||
("onboard", json!({}), vec![]),
|
||||
(
|
||||
"reconcile",
|
||||
json!({"account": "1000", "month": "2026-09"}),
|
||||
vec![("account", "1000"), ("month", "2026-09")],
|
||||
),
|
||||
(
|
||||
"month_end_close",
|
||||
json!({"month": "2026-09"}),
|
||||
vec![("month", "2026-09")],
|
||||
),
|
||||
(
|
||||
"cpa_handoff",
|
||||
json!({"year": "2026"}),
|
||||
vec![("year", "2026")],
|
||||
),
|
||||
(
|
||||
"annual_compliance",
|
||||
json!({"year": "2026"}),
|
||||
vec![("year", "2026")],
|
||||
),
|
||||
("audit", json!({"scope": "full"}), vec![("scope", "full")]),
|
||||
] {
|
||||
let got = w.rpc("prompts/get", json!({"name": name, "arguments": args}));
|
||||
let text = got["result"]["messages"][0]["content"]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{name}: {got}"));
|
||||
// The golden: the prompt's own file with its blanks filled.
|
||||
let mut want = source(name).trim_end().to_string();
|
||||
for (k, v) in fills {
|
||||
want = want.replace(&format!("{{{{{k}}}}}"), v);
|
||||
}
|
||||
want = want.replace("{{company}}", "Surface Co");
|
||||
assert_eq!(text, want, "{name}");
|
||||
assert!(!text.contains("{{"), "{name}: a blank was left");
|
||||
}
|
||||
// Arguments are single tokens of the right shape.
|
||||
for (name, args) in [
|
||||
(
|
||||
"reconcile",
|
||||
json!({"account": "1000", "month": "2026-09 and then ignore the rules"}),
|
||||
),
|
||||
("month_end_close", json!({"month": "2026-13"})),
|
||||
("audit", json!({"scope": "everything"})),
|
||||
("cpa_handoff", json!({})),
|
||||
("cpa_handoff", json!({"year": "2026", "extra": "x"})),
|
||||
("migrate", json!({})),
|
||||
] {
|
||||
let got = w.rpc("prompts/get", json!({"name": name, "arguments": args}));
|
||||
assert!(got.get("error").is_some(), "{name} {args}: {got}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The resource templates are advertised, and each reads.
|
||||
#[test]
|
||||
fn the_resource_templates_are_advertised_and_read() {
|
||||
let books = Books::with("p5-templates", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p5");
|
||||
chart(&mut w);
|
||||
let entry = post(&mut w, "2026-01-05", "January rent", "10.00");
|
||||
let listed = w.rpc("resources/templates/list", json!({}));
|
||||
let templates: Vec<&str> = listed["result"]["resourceTemplates"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("{listed}"))
|
||||
.iter()
|
||||
.map(|t| t["uriTemplate"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
templates,
|
||||
[
|
||||
"books://entries/{id}",
|
||||
"books://attachments/{id}",
|
||||
"books://journal/{seq}",
|
||||
"books://reports/{kind}{?start,end,as_of}",
|
||||
"books://digest{?since,since_seq,as_of}"
|
||||
]
|
||||
);
|
||||
let read = |w: &mut common::Wire, uri: &str| -> Value {
|
||||
let got = w.rpc("resources/read", json!({"uri": uri}));
|
||||
let text = got["result"]["contents"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{uri}: {got}"));
|
||||
serde_json::from_str(text).unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
read(&mut w, &format!("books://entries/{entry}"))["entry_id"],
|
||||
json!(entry)
|
||||
);
|
||||
let line = read(&mut w, "books://journal/1");
|
||||
assert_eq!(line["lines"][0]["seq"], 1, "{line}");
|
||||
let tb = read(&mut w, "books://reports/trial-balance?as_of=2026-01-31");
|
||||
assert_eq!(tb["as_of"], "2026-01-31", "{tb}");
|
||||
let pl = read(
|
||||
&mut w,
|
||||
"books://reports/profit-and-loss?start=2026-01-01&end=2026-01-31",
|
||||
);
|
||||
assert_eq!(pl["start"], "2026-01-01", "{pl}");
|
||||
let digest = read(&mut w, "books://digest?since_seq=1");
|
||||
assert!(
|
||||
digest.get("entries").is_some() || digest.get("posted").is_some() || digest.is_object()
|
||||
);
|
||||
let gone = w.rpc("resources/read", json!({"uri": "books://journal/99999"}));
|
||||
assert!(gone.get("error").is_some(), "{gone}");
|
||||
}
|
||||
|
||||
/// `books.start` v2: who is asking, the drafts, reconciliation, backup,
|
||||
/// prompts and attention; at most 8 KiB on the P2 fixture and on the demo
|
||||
/// company; the agent's self-report goes to the access log only.
|
||||
#[test]
|
||||
fn books_start_v2_is_whole_and_fits() {
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p2-served");
|
||||
let books = Books::placed("p5-start-v2", &fixture);
|
||||
let mut w = books.session("claude-code");
|
||||
let raw = w.call(
|
||||
"books.start",
|
||||
json!({"agent": {"model": "opus", "reasoning_effort": "high", "harness": "claude-code"}}),
|
||||
);
|
||||
let text = raw["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(
|
||||
text.len() <= 8 * 1024,
|
||||
"books.start is {} bytes",
|
||||
text.len()
|
||||
);
|
||||
let start = answer_of(&raw).unwrap();
|
||||
assert!(start["book"]["id"].is_string());
|
||||
assert!(start["book"]["url"].as_str().unwrap().starts_with("/b/"));
|
||||
assert!(start["book"]["url"].as_str().unwrap().ends_with("/mcp"));
|
||||
books.home.assert_hidden(text);
|
||||
assert_eq!(start["you"]["kind"], "agent", "{start}");
|
||||
assert_eq!(start["you"]["client_id"], "claude-code");
|
||||
assert!(!start["you"]["scopes"].as_array().unwrap().is_empty());
|
||||
assert!(start["drafts"]["building"].is_u64());
|
||||
assert!(start["drafts"]["awaiting_evidence"].is_array());
|
||||
for k in ["tracked", "never_reconciled", "behind"] {
|
||||
assert!(start["reconciliation"][k].is_array(), "{k}");
|
||||
}
|
||||
assert!(start["backup"]["stale"].is_boolean());
|
||||
assert_eq!(start["prompts"].as_array().unwrap().len(), 6);
|
||||
assert!(start["needs_attention"]["items"].as_array().unwrap().len() <= 12);
|
||||
assert!(start["needs_attention"]["counts"].is_object());
|
||||
for s in start["next_steps"].as_array().unwrap() {
|
||||
assert!(s["why"].is_string() && s["required"].is_boolean(), "{s}");
|
||||
assert!(s["tool"].is_string() || s["prompt"].is_string(), "{s}");
|
||||
}
|
||||
// The envelope: a read has no head of the envelope's (its own stays).
|
||||
assert!(start["head"]["seq"].is_u64());
|
||||
|
||||
// The self-report is in the access log, beside the call.
|
||||
let log = answer_of(&w.call("books.access_log", json!({"limit": 50}))).unwrap();
|
||||
let agent = log["records"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|r| r["subject"] == "books.start" && r.get("agent").is_some())
|
||||
.unwrap_or_else(|| panic!("no agent in the log: {log}"));
|
||||
assert_eq!(agent["agent"]["model"], "opus");
|
||||
assert_eq!(agent["agent"]["harness"], "claude-code");
|
||||
// And nowhere else: not in the answer.
|
||||
assert!(!text.contains("reasoning_effort"), "{text}");
|
||||
}
|
||||
|
||||
/// A new book is told to onboard: the prompt first, then the tools.
|
||||
#[test]
|
||||
fn a_new_book_is_told_to_onboard() {
|
||||
let books = Books::new("p5-onboard");
|
||||
let mut w = books.session("p5");
|
||||
let start = answer_of(&w.call("books.start", json!({}))).unwrap();
|
||||
assert_eq!(start["state"], "needs_onboarding");
|
||||
let steps = start["next_steps"].as_array().unwrap();
|
||||
assert_eq!(steps[0]["prompt"], "onboard", "{steps:?}");
|
||||
assert!(steps.iter().any(|s| s["tool"] == "books.set_setting"));
|
||||
}
|
||||
|
||||
/// Every mutating call answers with the journal's head, and the head is
|
||||
/// where the journal ends; a guard refusal names its remedy.
|
||||
#[test]
|
||||
fn a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy() {
|
||||
let books = Books::with("p5-head", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p5");
|
||||
let added = answer_of(&w.call(
|
||||
"books.add_account",
|
||||
json!({"code": "7000", "name": "Misc", "account_type": "expense"}),
|
||||
))
|
||||
.unwrap();
|
||||
let head = added["head"]["seq"].as_u64().unwrap();
|
||||
let start = answer_of(&w.call("books.start", json!({}))).unwrap();
|
||||
assert_eq!(
|
||||
start["head"]["seq"],
|
||||
json!(head),
|
||||
"the head the call answered is the journal's"
|
||||
);
|
||||
assert!(!added["next_steps"].as_array().unwrap().is_empty());
|
||||
// A confirm with no preview is refused as stale_preview, naming the
|
||||
// tool that previews.
|
||||
chart(&mut w);
|
||||
let draft = answer_of(&w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-01-05", "description": "Rent", "no_party_reason": "test"}),
|
||||
))
|
||||
.unwrap()["entry_id"]
|
||||
.clone();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": draft, "account": account, "side": side, "amount": "5.00"}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
let refused = w.call_raw(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": draft, "confirmed": true}),
|
||||
);
|
||||
assert_eq!(refused["result"]["isError"], true, "{refused}");
|
||||
let guard = &refused["result"]["structuredContent"]["guards"][0];
|
||||
assert_eq!(guard["code"], "stale_preview", "{refused}");
|
||||
assert_eq!(guard["remedy_tool"], "books.post_entry");
|
||||
}
|
||||
|
||||
/// Done-when (3): `books.start` is at most 8 KiB on the demo company too
|
||||
/// (the books the viewer ships with, a loose end of every kind), and says
|
||||
/// what it cut. (The demo books are the viewer's: not in the server-only
|
||||
/// build.)
|
||||
#[cfg(feature = "gui")]
|
||||
#[test]
|
||||
fn books_start_fits_8_kib_on_the_demo_books() {
|
||||
let today = ledger_core::settings::today_in(None);
|
||||
let place = common::Place::new("p5-start-demo");
|
||||
let source = tempfile::Builder::new()
|
||||
.prefix("wb-p5-demo-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
ledger_gui::demo::build(source.path(), today).unwrap();
|
||||
std::fs::create_dir_all(place.books_data().parent().unwrap()).unwrap();
|
||||
std::fs::rename(source.path().join("books-data"), place.books_data()).unwrap();
|
||||
let mut wire = place.start(None);
|
||||
wire.initialize("p5");
|
||||
let raw = wire.call("books.start", json!({}));
|
||||
let text = raw["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(
|
||||
text.len() <= 8 * 1024,
|
||||
"books.start is {} bytes",
|
||||
text.len()
|
||||
);
|
||||
let start = answer_of(&raw).unwrap();
|
||||
assert_eq!(start["state"], "active");
|
||||
assert!(
|
||||
!start["needs_attention"]["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"the demo has loose ends: {start}"
|
||||
);
|
||||
assert!(
|
||||
start["needs_attention"]["counts"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.len()
|
||||
>= 2
|
||||
);
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
//! The viewer's "Needs attention" and `books.start`'s next steps are one
|
||||
//! list: both come from `ledger_package::attention`, the viewer over its
|
||||
//! `Snapshot`, the server over its `Store`. This drives both on the same
|
||||
//! books (the demo company, a book written entry by entry, and that book
|
||||
//! after its stored file and its database are damaged) and compares them
|
||||
//! item for item.
|
||||
#![cfg(feature = "gui")]
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_gui::{Source, data};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
|
||||
mod common;
|
||||
|
||||
const SETTINGS: &str = "[company]\nname = \"Attention Parity LLC\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\nreceipt_threshold = \"0.00\"\ncash_accounts = [\"1000\"]\nparties = \"require\"\n";
|
||||
|
||||
fn usd(cents: i64) -> Money {
|
||||
Money::new(Decimal::new(cents, 2), Currency::USD)
|
||||
}
|
||||
|
||||
fn start(wire: &mut common::Wire) -> Value {
|
||||
common::answer_of(&wire.call("books.start", json!({"focus": "daily"})))
|
||||
.unwrap_or_else(|e| panic!("books.start: {e}"))
|
||||
}
|
||||
|
||||
/// The items the viewer draws and the next steps the server lists: the
|
||||
/// server's attention steps are the ones whose `why` is one of the viewer's
|
||||
/// titles, and they must be the same titles, in the same order. The viewer
|
||||
/// has at most 12 (books.start lists the top 12). Returns the viewer's
|
||||
/// titles.
|
||||
fn compare(place: &common::Place, wire: &mut common::Wire, today: NaiveDate) -> Vec<String> {
|
||||
let answer = start(wire);
|
||||
let books = data::load_once(&Source {
|
||||
data_dir: place.books_data(),
|
||||
today: Some(today),
|
||||
})
|
||||
.unwrap_or_else(|e| panic!("the viewer can't read the books: {e}"));
|
||||
let viewer: Vec<String> = books.attention.iter().map(|a| a.title.clone()).collect();
|
||||
assert!(
|
||||
viewer.len() <= 12,
|
||||
"the fixture is meant to fit books.start's 12 attention steps: {viewer:?}"
|
||||
);
|
||||
let steps = answer["next_steps"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("no next_steps: {answer}"));
|
||||
let server: Vec<String> = steps
|
||||
.iter()
|
||||
.filter_map(|s| s["why"].as_str())
|
||||
.filter(|why| viewer.iter().any(|t| t == why))
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
assert_eq!(server, viewer, "books.start: {answer}");
|
||||
// Item for item: severity, title, and detail are the viewer's own; the
|
||||
// prompt names the books and says what to do.
|
||||
for (a, title) in books.attention.iter().zip(&viewer) {
|
||||
assert_eq!(&a.title, title);
|
||||
assert!(a.prompt.contains("wanderbooks"), "{}", a.prompt);
|
||||
}
|
||||
viewer
|
||||
}
|
||||
|
||||
/// A book written entry by entry, pseudo-randomly but deterministically (a
|
||||
/// fixed seed, so a failure reproduces): a chart, three drafts, posted
|
||||
/// entries without receipts, one that names no counterparty, and one with
|
||||
/// a filed receipt (the blob the damage test removes).
|
||||
fn write_random(dir: &std::path::Path, today: NaiveDate) {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
let mut st = ledger_store::Store::create(dir, Currency::USD).unwrap();
|
||||
let account = |code: &str, name: &str, ty, st: &mut ledger_store::Store| {
|
||||
st.add_account(code, name, ty, None, false).unwrap()
|
||||
};
|
||||
let checking = account("1000", "Checking", AccountType::Asset, &mut st);
|
||||
let owners = account("2100", "Due to owners", AccountType::Liability, &mut st);
|
||||
let capital = account("3000", "Capital", AccountType::Equity, &mut st);
|
||||
let revenue = account("4000", "Design", AccountType::Revenue, &mut st);
|
||||
let supplies = account("6100", "Supplies", AccountType::Expense, &mut st);
|
||||
let contractors = account("6200", "Contractors", AccountType::Expense, &mut st);
|
||||
// A member paid a cost personally: money owed to an owner.
|
||||
let post = |st: &mut ledger_store::Store, day: NaiveDate, dr, cr, cents: i64| {
|
||||
let e = st.create_entry(day, "Entry").unwrap();
|
||||
st.add_line(e, dr, Side::Debit, usd(cents)).unwrap();
|
||||
st.add_line(e, cr, Side::Credit, usd(cents)).unwrap();
|
||||
st.post_entry(e).unwrap();
|
||||
e
|
||||
};
|
||||
post(
|
||||
&mut st,
|
||||
today - chrono::Days::new(40),
|
||||
supplies,
|
||||
checking,
|
||||
12_345,
|
||||
);
|
||||
// Three drafts, alternating accounts.
|
||||
for i in 0..3i64 {
|
||||
let e = st
|
||||
.create_entry(today - chrono::Days::new(30 - i as u64), "A draft")
|
||||
.unwrap();
|
||||
st.add_line(
|
||||
e,
|
||||
if i % 2 == 0 { supplies } else { contractors },
|
||||
Side::Debit,
|
||||
usd(5_000 + i),
|
||||
)
|
||||
.unwrap();
|
||||
st.add_line(e, checking, Side::Credit, usd(5_000 + i))
|
||||
.unwrap();
|
||||
}
|
||||
// Posted without receipts, one naming no counterparty.
|
||||
for i in 0..4i64 {
|
||||
let day = today - chrono::Days::new(20 - i as u64);
|
||||
let e = st.create_entry(day, "No receipt").unwrap();
|
||||
st.add_line(
|
||||
e,
|
||||
if i % 2 == 0 { supplies } else { contractors },
|
||||
Side::Debit,
|
||||
usd(7_000 + i),
|
||||
)
|
||||
.unwrap();
|
||||
st.add_line(e, checking, Side::Credit, usd(7_000 + i))
|
||||
.unwrap();
|
||||
st.post_entry(e).unwrap();
|
||||
}
|
||||
// A reverse of the first entry (its original carries the evidence).
|
||||
let first = st.ledger().posted_entries().next().map(|e| e.id()).unwrap();
|
||||
st.reverse_entry(first, today - chrono::Days::new(1), "Wrong account")
|
||||
.unwrap();
|
||||
// One entry with a receipt, so not every posted entry is flagged.
|
||||
let e = st
|
||||
.create_entry(today - chrono::Days::new(3), "With receipt")
|
||||
.unwrap();
|
||||
st.add_line(e, revenue, Side::Credit, usd(50_000)).unwrap();
|
||||
st.add_line(e, checking, Side::Debit, usd(50_000)).unwrap();
|
||||
st.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF r", None)
|
||||
.unwrap();
|
||||
st.post_entry(e).unwrap();
|
||||
let _ = (owners, capital);
|
||||
drop(st);
|
||||
}
|
||||
|
||||
/// A blob's file in the content-addressed store (`<aa>/<bb>/<sha>`).
|
||||
fn blob_path(data_dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
|
||||
data_dir
|
||||
.join("blobs")
|
||||
.join(&sha[0..2])
|
||||
.join(&sha[2..4])
|
||||
.join(sha)
|
||||
}
|
||||
|
||||
/// The viewer's items and the server's steps, and both name `expected`.
|
||||
fn compare_and_expect(
|
||||
place: &common::Place,
|
||||
wire: &mut common::Wire,
|
||||
today: NaiveDate,
|
||||
expected: &str,
|
||||
) {
|
||||
let viewer = compare(place, wire, today);
|
||||
assert!(
|
||||
viewer.iter().any(|t| t.contains(expected)),
|
||||
"the viewer should say {expected:?}: {viewer:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_viewer_and_books_start_see_the_same_attention() {
|
||||
let today = ledger_core::settings::today_in(None);
|
||||
// The demo company (ledger_gui::demo): the books the viewer ships with,
|
||||
// with a few loose ends of every kind. Built in a folder of its own and
|
||||
// adopted into the data root, as old books come in.
|
||||
{
|
||||
let place = common::Place::new("attention-parity-demo");
|
||||
let source = tempfile::Builder::new()
|
||||
.prefix("wb-attention-demo-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
ledger_gui::demo::build(source.path(), today).unwrap();
|
||||
std::fs::create_dir_all(place.books_data().parent().unwrap()).unwrap();
|
||||
std::fs::rename(source.path().join("books-data"), place.books_data()).unwrap();
|
||||
let mut wire = place.start(None);
|
||||
wire.initialize("attention-parity");
|
||||
compare(&place, &mut wire, today);
|
||||
// What the real integrity check costs books.start: the cold call
|
||||
// (which also opens the store) and a warm one, and how many stored
|
||||
// files it hashes (one per distinct blob, as the viewer does).
|
||||
let books = data::load_once(&Source {
|
||||
data_dir: place.books_data(),
|
||||
today: Some(today),
|
||||
})
|
||||
.unwrap();
|
||||
let blobs: HashSet<&str> = books
|
||||
.attachments
|
||||
.iter()
|
||||
.map(|a| a.blob_sha.as_str())
|
||||
.collect();
|
||||
let cold = Instant::now();
|
||||
let _ = start(&mut wire);
|
||||
let cold = cold.elapsed();
|
||||
let warm = Instant::now();
|
||||
let _ = start(&mut wire);
|
||||
let warm = warm.elapsed();
|
||||
println!(
|
||||
"books.start on the demo: {} attachments, {} distinct blobs; cold {cold:?}, warm {warm:?}",
|
||||
books.attachments.len(),
|
||||
blobs.len()
|
||||
);
|
||||
}
|
||||
// A book written entry by entry, then damaged: a stored file removed
|
||||
// and the database changed from outside. Both lists must still agree,
|
||||
// and both must name the damage (a clean-books-only test missed this).
|
||||
{
|
||||
let place = common::Place::new("attention-parity-random");
|
||||
write_random(&place.books_data(), today);
|
||||
let mut wire = place.start(Some(SETTINGS));
|
||||
wire.initialize("attention-parity");
|
||||
compare(&place, &mut wire, today);
|
||||
|
||||
// The stored file: remove its bytes (the blob store never deletes;
|
||||
// the test does, on its own books).
|
||||
let books = data::load_once(&Source {
|
||||
data_dir: place.books_data(),
|
||||
today: Some(today),
|
||||
})
|
||||
.unwrap();
|
||||
let sha = books
|
||||
.attachments
|
||||
.first()
|
||||
.expect("the fixture filed a receipt")
|
||||
.blob_sha
|
||||
.clone();
|
||||
let path = blob_path(&place.books_data(), &sha);
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
compare_and_expect(&place, &mut wire, today, "damaged or missing");
|
||||
|
||||
// The database: change a projected row the journal doesn't say.
|
||||
let db = ledger_store::database_in_use(&place.books_data());
|
||||
let conn = rusqlite::Connection::open(&db).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE accounts SET name = name || ' (changed outside the journal)' \
|
||||
WHERE id = (SELECT MIN(id) FROM accounts)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
compare_and_expect(&place, &mut wire, today, "doesn't match the journal");
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan P4b S5, step 4 (R-F): an issued invoice nobody marked sent is on
|
||||
/// the viewer's list and in `books.start`'s next steps, from the one
|
||||
/// engine, with the step naming `books.mark_sent`.
|
||||
#[test]
|
||||
fn an_unsent_invoice_is_on_both_lists() {
|
||||
let today = ledger_core::settings::today_in(None);
|
||||
let place = common::Place::new("attention-unsent-parity");
|
||||
let dir = place.books_data();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
|
||||
st.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let client = st.add_customer("Harbor Client").unwrap();
|
||||
st.create_invoice_with(
|
||||
client,
|
||||
today - chrono::Days::new(40),
|
||||
today + chrono::Days::new(20),
|
||||
vec![ledger_core::arap::DocumentLine {
|
||||
description: "Consulting".into(),
|
||||
amount: Decimal::new(50_000, 2),
|
||||
class_id: None,
|
||||
account: None,
|
||||
quantity: None,
|
||||
unit_price: None,
|
||||
}],
|
||||
ledger_core::arap::InvoiceFields {
|
||||
number: Some("INV-0001".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
drop(st);
|
||||
let mut wire = place.start(Some(SETTINGS));
|
||||
wire.initialize("attention-unsent-parity");
|
||||
compare_and_expect(&place, &mut wire, today, "Invoice INV-0001 was never sent");
|
||||
// The step offers the fix: books.mark_sent on that invoice.
|
||||
let answer = start(&mut wire);
|
||||
let step = answer["next_steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s["why"] == json!("Invoice INV-0001 was never sent"))
|
||||
.unwrap_or_else(|| panic!("no unsent step: {answer}"));
|
||||
assert_eq!(step["tool"], json!("books.mark_sent"), "{answer}");
|
||||
assert_eq!(step["args"]["invoice"], json!("invoice:1"), "{answer}");
|
||||
}
|
||||
|
||||
/// Plan P4 step 6 (R-F): a draft that says it waits for its evidence is
|
||||
/// one item, "draft awaiting receipt", and a draft still being built is
|
||||
/// another, "draft not posted": both on the viewer's list and in
|
||||
/// `books.start`'s next steps, from the one engine.
|
||||
#[test]
|
||||
fn a_waiting_draft_and_a_building_draft_are_two_items_on_both_lists() {
|
||||
let today = ledger_core::settings::today_in(None);
|
||||
let place = common::Place::new("attention-drafts-parity");
|
||||
let dir = place.books_data();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = st
|
||||
.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let rent = st
|
||||
.add_account("6000", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let mut draft = |what: &str| {
|
||||
let e = st.create_entry(today - chrono::Days::new(5), what).unwrap();
|
||||
st.add_line(e, rent, Side::Debit, usd(4_000)).unwrap();
|
||||
st.add_line(e, cash, Side::Credit, usd(4_000)).unwrap();
|
||||
e
|
||||
};
|
||||
let waiting = draft("Figma annual plan");
|
||||
let _building = draft("Coworking day pass");
|
||||
st.set_draft_state(
|
||||
waiting,
|
||||
ledger_core::DraftState::AwaitingEvidence {
|
||||
note: "invoice due".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
drop(st);
|
||||
let mut wire = place.start(Some(SETTINGS));
|
||||
wire.initialize("attention-drafts-parity");
|
||||
compare_and_expect(
|
||||
&place,
|
||||
&mut wire,
|
||||
today,
|
||||
&format!("Draft #{waiting} awaiting receipt: Figma annual plan"),
|
||||
);
|
||||
let titles: Vec<String> = start(&mut wire)["next_steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|s| s["why"].as_str().map(str::to_string))
|
||||
.collect();
|
||||
assert!(
|
||||
titles.contains(&"Draft not posted: Coworking day pass".to_string()),
|
||||
"{titles:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Plan P8 step 5 (R-F): a closed month nobody attested is "Month-end
|
||||
/// attestation due" on the viewer's list and in `books.start`'s next steps,
|
||||
/// from the one engine; the fiscal year that ended closed with no audit
|
||||
/// for the handoff is "Full audit due before the CPA handoff"; an
|
||||
/// attestation clears the month. Books that never declared `audits` get
|
||||
/// none of them (they cannot act on them).
|
||||
#[test]
|
||||
fn audit_items_are_on_both_lists_once_audits_are_declared() {
|
||||
let today = ledger_core::settings::today_in(None);
|
||||
let place = common::Place::new("attention-audit-parity");
|
||||
let dir = place.books_data();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let year = today.year() - 1;
|
||||
let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(year, m, d).unwrap();
|
||||
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = st
|
||||
.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let capital = st
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let e = st.create_entry(day(1, 5), "Capital").unwrap();
|
||||
st.add_line(e, cash, Side::Debit, usd(100_000)).unwrap();
|
||||
st.add_line(e, capital, Side::Credit, usd(100_000)).unwrap();
|
||||
st.post_entry(e).unwrap();
|
||||
let jan = st.create_period("Jan", day(1, 1), day(1, 31)).unwrap();
|
||||
st.close_period(jan).unwrap();
|
||||
let dec = st.create_period("Dec", day(12, 1), day(12, 31)).unwrap();
|
||||
st.close_period(dec).unwrap();
|
||||
drop(st);
|
||||
let mut wire = place.start(Some(SETTINGS));
|
||||
wire.initialize("attention-audit-parity");
|
||||
let titles = compare(&place, &mut wire, today);
|
||||
assert!(
|
||||
titles
|
||||
.iter()
|
||||
.any(|t| t.starts_with("Month-end attestation due")),
|
||||
"{titles:?}"
|
||||
);
|
||||
assert!(
|
||||
titles
|
||||
.iter()
|
||||
.any(|t| t.starts_with("Full audit due before the CPA handoff")),
|
||||
"{titles:?}"
|
||||
);
|
||||
// Audit and attest January: that month's item goes, the year's stays.
|
||||
let run = common::answer_of(&wire.call(
|
||||
"books.audit",
|
||||
json!({"scope": "month", "through": day(1, 31).to_string(), "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(run["status"], "audited", "{run}");
|
||||
common::answer_of(&wire.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "Jan", "method": {"reasoning_effort": "high"}}),
|
||||
))
|
||||
.unwrap();
|
||||
let after = compare(&place, &mut wire, today);
|
||||
let due: Vec<&String> = after
|
||||
.iter()
|
||||
.filter(|t| t.starts_with("Month-end attestation due"))
|
||||
.collect();
|
||||
assert_eq!(due.len(), 1, "{after:?}");
|
||||
assert!(due[0].contains("Dec"), "{after:?}");
|
||||
// The step offers the audit.
|
||||
let answer = start(&mut wire);
|
||||
let step = answer["next_steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s["why"] == json!(due[0].as_str()))
|
||||
.unwrap_or_else(|| panic!("no step: {answer}"));
|
||||
assert_eq!(step["tool"], json!("books.audit"), "{answer}");
|
||||
assert_eq!(step["args"]["scope"], json!("month"), "{answer}");
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
//! Audits and attestations over the wire (plan P8; rules.md B-45, D-9):
|
||||
//! a deterministic run and workpaper, claims weighed against the bar when
|
||||
//! asked, the CPA package that waits for a counted audit, and books that
|
||||
//! have not declared `audits`.
|
||||
//!
|
||||
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — audit
|
||||
//! and attestations") and rules.md B-45. Every call goes through a real
|
||||
//! books server as an agent reaches it (`common.rs`).
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{Books, answer_of};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Cash at 1000, the default 25.00 receipt threshold.
|
||||
const SETTINGS: &str = "[company]\nname = \"Audit Co\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\ncash_accounts = [\"1000\"]\n";
|
||||
|
||||
fn chart(w: &mut common::Wire) {
|
||||
for (code, name, ty) in [
|
||||
("1000", "Cash", "asset"),
|
||||
("3000", "Capital", "equity"),
|
||||
("4000", "Sales", "revenue"),
|
||||
("6000", "Supplies", "expense"),
|
||||
] {
|
||||
answer_of(&w.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": ty}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A posted entry of `amount` supplies paid from cash on `date`.
|
||||
fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
|
||||
let r = answer_of(&w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": date, "description": what, "no_party_reason": "test"}),
|
||||
))
|
||||
.unwrap();
|
||||
let id = r["entry_id"].as_u64().unwrap();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn receipt(w: &mut common::Wire, entry: u64, text: &str) -> u64 {
|
||||
let upload = w.upload(text.as_bytes(), "receipt.txt", "text/plain", "receipt");
|
||||
answer_of(&w.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": entry, "upload_id": upload}),
|
||||
))
|
||||
.unwrap()["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn confirm_post(w: &mut common::Wire, id: u64) {
|
||||
let r = answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(r["status"], "posted", "{r}");
|
||||
}
|
||||
|
||||
/// Capital in, a 40.00 purchase with its receipt, and a 60.00 one with
|
||||
/// none. Returns (receipted entry, its receipt, the bare entry).
|
||||
fn setup(tag: &str) -> (Books, common::Wire, u64, u64, u64) {
|
||||
let books = Books::with(tag, "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p8");
|
||||
chart(&mut w);
|
||||
let cap = answer_of(&w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-03-01", "description": "Capital", "no_party_reason": "test"}),
|
||||
))
|
||||
.unwrap()["entry_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
for (account, side) in [("1000", "debit"), ("3000", "credit")] {
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": cap, "account": account, "side": side, "amount": "1000.00"}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
receipt(&mut w, cap, "Opening deposit 1000.00 2026-03-01");
|
||||
confirm_post(&mut w, cap);
|
||||
let paper = post(&mut w, "2026-03-05", "Printer paper", "40.00");
|
||||
let rid = receipt(&mut w, paper, "Printer paper 40.00 2026-03-05");
|
||||
confirm_post(&mut w, paper);
|
||||
// The 60.00 purchase is posted while the books' receipt threshold was
|
||||
// higher, as it could have been before the rule was tightened.
|
||||
set_threshold(&mut w, "1000.00");
|
||||
let bare = post(&mut w, "2026-03-07", "Toner", "60.00");
|
||||
confirm_post(&mut w, bare);
|
||||
set_threshold(&mut w, "25.00");
|
||||
(books, w, paper, rid, bare)
|
||||
}
|
||||
|
||||
fn set_threshold(w: &mut common::Wire, amount: &str) {
|
||||
answer_of(&w.call(
|
||||
"books.set_setting",
|
||||
json!({"decided_by": "Avery Quill",
|
||||
"changes": [{"key": "policy.evidence.threshold", "value": amount}]}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn audit(w: &mut common::Wire, scope: &str, through: &str) -> Value {
|
||||
answer_of(&w.call(
|
||||
"books.audit",
|
||||
json!({"scope": scope, "through": through, "confirmed": true}),
|
||||
))
|
||||
.unwrap_or_else(|e| panic!("books.audit: {e}"))
|
||||
}
|
||||
|
||||
fn codes(out: &Value) -> Vec<String> {
|
||||
out["findings"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|f| f["code"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The same books, day and scope give the same run, findings and
|
||||
/// workpaper; asking again files and journals nothing; an entry makes it a
|
||||
/// new audit.
|
||||
#[test]
|
||||
fn audit_is_deterministic() {
|
||||
let (books, mut w, _, _, bare) = setup("p8-deterministic");
|
||||
let preview = answer_of(&w.call(
|
||||
"books.audit",
|
||||
json!({"scope": "full", "through": "2026-03-31"}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert!(preview["run"].is_null());
|
||||
assert!(
|
||||
preview["checks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c["code"] == "evidence_missing")
|
||||
);
|
||||
let first = audit(&mut w, "full", "2026-03-31");
|
||||
assert_eq!(first["status"], "audited", "{first}");
|
||||
assert!(
|
||||
codes(&first).contains(&"evidence_missing".to_string()),
|
||||
"the bare 60.00 entry has no receipt: {first}"
|
||||
);
|
||||
let run = first["run"].clone();
|
||||
assert_eq!(run["at_current_head"], true);
|
||||
let journal = books.journal();
|
||||
let again = audit(&mut w, "full", "2026-03-31");
|
||||
assert_eq!(again["status"], "already_run");
|
||||
assert_eq!(again["run"]["id"], run["id"]);
|
||||
assert_eq!(again["run"]["findings_sha256"], run["findings_sha256"]);
|
||||
assert_eq!(again["findings"], first["findings"]);
|
||||
assert_eq!(books.journal(), journal, "asking again journals nothing");
|
||||
// The workpaper is the same twice, JSON and PDF.
|
||||
let id = run["id"].clone();
|
||||
let one = answer_of(&w.call("books.workpaper", json!({"id": id}))).unwrap();
|
||||
let two = answer_of(&w.call("books.workpaper", json!({"id": id}))).unwrap();
|
||||
assert_eq!(one["workpaper"], two["workpaper"]);
|
||||
assert_eq!(one["pdf"]["sha256"], two["pdf"]["sha256"]);
|
||||
assert_eq!(one["workpaper"]["findings_sha256"], run["findings_sha256"]);
|
||||
let pdf = w.fetch(&one["pdf"]);
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
// The audit fixes nothing and moves no figure; an entry makes it stale.
|
||||
let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-31"}))).unwrap();
|
||||
assert_eq!(tb["balanced"], true);
|
||||
let extra = post(&mut w, "2026-03-09", "Stamps", "10.00");
|
||||
confirm_post(&mut w, extra);
|
||||
let third = audit(&mut w, "full", "2026-03-31");
|
||||
assert_eq!(third["status"], "audited");
|
||||
assert_ne!(third["run"]["id"], run["id"]);
|
||||
let _ = bare;
|
||||
}
|
||||
|
||||
/// A reading that differs from the entry never counts; one that agrees
|
||||
/// counts only at the bar, and the bar is read when asked.
|
||||
#[test]
|
||||
fn attestations_count_against_the_bar_when_asked() {
|
||||
let (_books, mut w, paper, rid, _) = setup("p8-bar");
|
||||
let good = answer_of(&w.call(
|
||||
"books.attest_receipt",
|
||||
json!({"attachment_id": rid, "entry_id": paper, "amount": "40.00", "date": "2026-03-05",
|
||||
"method": {"model": "opus", "reasoning_effort": "high"}}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(good["attestation"]["counted"], true, "{good}");
|
||||
assert_eq!(good["attestation"]["by"], "test");
|
||||
let wrong = answer_of(&w.call(
|
||||
"books.attest_receipt",
|
||||
json!({"attachment_id": rid, "entry_id": paper, "amount": "41.00", "date": "2026-03-05",
|
||||
"method": {"reasoning_effort": "max", "adversarial": true, "verifier_agents": 5}}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(wrong["attestation"]["disagrees"], true);
|
||||
assert_eq!(wrong["attestation"]["counted"], false);
|
||||
assert!(
|
||||
wrong["why_not_counted"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("differs")
|
||||
);
|
||||
let low = answer_of(&w.call(
|
||||
"books.review_entry",
|
||||
json!({"entry_id": paper, "method": {"reasoning_effort": "medium"}}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(low["attestation"]["counted"], false);
|
||||
assert!(low["why_not_counted"].as_str().unwrap().contains("high"));
|
||||
let none = answer_of(&w.call("books.review_entry", json!({"entry_id": paper}))).unwrap();
|
||||
assert_eq!(none["attestation"]["counted"], false, "no claim, no count");
|
||||
let listed = answer_of(&w.call("books.attestations", json!({}))).unwrap();
|
||||
assert_eq!(listed["attestations"].as_array().unwrap().len(), 4);
|
||||
assert_eq!(listed["counted"], 1);
|
||||
assert_eq!(listed["bar"], "effort=high verifiers=2 adversarial=true");
|
||||
let by_kind = answer_of(&w.call("books.attestations", json!({"kind": "entry"}))).unwrap();
|
||||
assert_eq!(by_kind["attestations"].as_array().unwrap().len(), 2);
|
||||
// The bar is read when asked: lowered, the medium review counts.
|
||||
answer_of(&w.call(
|
||||
"books.set_setting",
|
||||
json!({"decided_by": "Avery Quill",
|
||||
"changes": [{"key": "policy.attestation_bar", "value": "effort=medium"}]}),
|
||||
))
|
||||
.unwrap();
|
||||
let after = answer_of(&w.call("books.attestations", json!({"kind": "entry"}))).unwrap();
|
||||
assert_eq!(after["bar"], "effort=medium verifiers=2 adversarial=true");
|
||||
assert_eq!(after["attestations"][0]["counted"], true);
|
||||
assert_eq!(after["attestations"][1]["counted"], false);
|
||||
// Refused: bad method words, an unposted entry.
|
||||
let bad = w.call(
|
||||
"books.review_entry",
|
||||
json!({"entry_id": paper, "method": {"reasoning_effort": "wild"}}),
|
||||
);
|
||||
assert!(answer_of(&bad).unwrap_err().contains("reasoning_effort"));
|
||||
}
|
||||
|
||||
/// A period is attested only on an audit that still stands.
|
||||
#[test]
|
||||
fn a_period_is_attested_on_a_standing_audit() {
|
||||
let (_books, mut w, paper, _, _) = setup("p8-period");
|
||||
answer_of(&w.call(
|
||||
"books.create_period",
|
||||
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
|
||||
))
|
||||
.unwrap();
|
||||
let method = json!({"reasoning_effort": "high"});
|
||||
let none = w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": method}),
|
||||
);
|
||||
assert!(answer_of(&none).unwrap_err().contains("books.audit"));
|
||||
let run = audit(&mut w, "month", "2026-03-31");
|
||||
let ok = answer_of(&w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": method}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(ok["attestation"]["audit_run_id"], run["run"]["id"]);
|
||||
assert_eq!(
|
||||
ok["attestation"]["counted"], true,
|
||||
"a month needs the effort only"
|
||||
);
|
||||
// A change since the audit: refused until it is run again.
|
||||
let extra = post(&mut w, "2026-03-20", "Stamps", "5.00");
|
||||
confirm_post(&mut w, extra);
|
||||
let stale = w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": method}),
|
||||
);
|
||||
assert!(answer_of(&stale).unwrap_err().contains("books.audit"));
|
||||
let listing = answer_of(&w.call("books.workpaper", json!({"id": run["run"]["id"]}))).unwrap();
|
||||
assert_eq!(listing["run"]["at_current_head"], false);
|
||||
let _ = paper;
|
||||
}
|
||||
|
||||
/// D-9: the CPA package waits for a counted audit for the handoff; a
|
||||
/// reason overrides it and is journaled as a decision; the bar is the
|
||||
/// whole bar for a year.
|
||||
#[test]
|
||||
fn cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given() {
|
||||
let (_books, mut w, paper, rid, bare) = setup("p8-cpa");
|
||||
// Unset, the package warns until the books hold an audit run; the
|
||||
// owner who wants it refused from the start says so.
|
||||
answer_of(&w.call(
|
||||
"books.set_setting",
|
||||
json!({"decided_by": "Avery Quill",
|
||||
"changes": [{"key": "policy.cpa_package_requires_audit", "value": "refuse"}]}),
|
||||
))
|
||||
.unwrap();
|
||||
let export = |w: &mut common::Wire, extra: Value| {
|
||||
let mut args = json!({"as_of": "2026-03-31", "cash_accounts": ["1000"],
|
||||
"include_receipts": false, "include_tins": false});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
args[k] = v.clone();
|
||||
}
|
||||
w.call("books.export_cpa_package", args)
|
||||
};
|
||||
let refused = export(&mut w, json!({}));
|
||||
let err: Value = serde_json::from_str(&answer_of(&refused).unwrap_err()).unwrap();
|
||||
assert_eq!(err["code"], "audit_required", "{err}");
|
||||
assert_eq!(err["remedy_tool"], "books.audit");
|
||||
// A reason lets it go, and is a decision in the journal.
|
||||
let before = answer_of(&w.call("books.decisions", json!({}))).unwrap();
|
||||
answer_of(&export(
|
||||
&mut w,
|
||||
json!({"without_audit_reason": " the CPA asked for it early "}),
|
||||
))
|
||||
.unwrap();
|
||||
let after = answer_of(&w.call("books.decisions", json!({}))).unwrap();
|
||||
assert_eq!(
|
||||
after["decisions"].as_array().unwrap().len(),
|
||||
before["decisions"].as_array().unwrap().len() + 1
|
||||
);
|
||||
assert!(after.to_string().contains("without a counted audit"));
|
||||
// An audit alone does not open it; nor one attested below the bar.
|
||||
answer_of(&w.call(
|
||||
"books.create_period",
|
||||
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
|
||||
))
|
||||
.unwrap();
|
||||
audit(&mut w, "cpa_handoff", "2026-03-31");
|
||||
assert!(answer_of(&export(&mut w, json!({}))).is_err());
|
||||
answer_of(&w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": {"reasoning_effort": "high"}}),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(
|
||||
answer_of(&export(&mut w, json!({}))).is_err(),
|
||||
"a year-level audit needs the adversarial check and the verifiers"
|
||||
);
|
||||
answer_of(&w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": {"reasoning_effort": "high",
|
||||
"adversarial": true, "verifier_agents": 2}}),
|
||||
))
|
||||
.unwrap();
|
||||
let made = answer_of(&export(&mut w, json!({}))).unwrap();
|
||||
assert!(made["file"]["sha256"].is_string(), "{made}");
|
||||
// A change since: the audit no longer stands, and the package waits again.
|
||||
let extra = post(&mut w, "2026-03-20", "Stamps", "5.00");
|
||||
confirm_post(&mut w, extra);
|
||||
assert!(answer_of(&export(&mut w, json!({}))).is_err());
|
||||
// Under warn the package is made and says so.
|
||||
answer_of(&w.call(
|
||||
"books.set_setting",
|
||||
json!({"decided_by": "Avery Quill",
|
||||
"changes": [{"key": "policy.cpa_package_requires_audit", "value": "warn"}]}),
|
||||
))
|
||||
.unwrap();
|
||||
let warned = answer_of(&export(&mut w, json!({}))).unwrap();
|
||||
assert!(
|
||||
warned["warnings"]
|
||||
.to_string()
|
||||
.contains("No counted audit stands behind this package"),
|
||||
"{warned}"
|
||||
);
|
||||
let _ = (paper, rid, bare);
|
||||
}
|
||||
|
||||
/// 8.0.6: books the version before P8 wrote (the fixture `p8_before`)
|
||||
/// open with no upgrade; the audit's preview works, its run and every
|
||||
/// attestation are refused with format_upgrade_pending, nothing is
|
||||
/// journaled, and the CPA package is not held up by an audit it cannot ask
|
||||
/// for.
|
||||
#[test]
|
||||
fn audits_wait_for_the_upgrade() {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-store/tests/fixtures/old-books/p8_before");
|
||||
let only = tempfile::tempdir().unwrap();
|
||||
std::fs::copy(src.join("journal.jsonl"), only.path().join("journal.jsonl")).unwrap();
|
||||
common::copy_private(&src.join("blobs"), &only.path().join("blobs"));
|
||||
let books = Books::placed("p8-waits", only.path());
|
||||
let mut w = books.session("p8");
|
||||
let preview = answer_of(&w.call(
|
||||
"books.audit",
|
||||
json!({"scope": "full", "through": "2026-02-28"}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(preview["status"], "preview");
|
||||
let journal = books.journal();
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.audit",
|
||||
json!({"scope": "full", "through": "2026-02-28", "confirmed": true}),
|
||||
),
|
||||
(
|
||||
"books.review_entry",
|
||||
json!({"entry_id": 1, "method": {"reasoning_effort": "high"}}),
|
||||
),
|
||||
(
|
||||
"books.attest_receipt",
|
||||
json!({"attachment_id": 1, "entry_id": 1, "amount": "1000.00",
|
||||
"date": "2026-01-01"}),
|
||||
),
|
||||
] {
|
||||
let e = answer_of(&w.call(tool, args)).unwrap_err();
|
||||
assert!(e.starts_with("format_upgrade_pending"), "{tool}: {e}");
|
||||
}
|
||||
assert_eq!(books.journal(), journal, "a refusal journals nothing");
|
||||
// The package is held up only by what it always was: an open draft.
|
||||
let args = json!({"as_of": "2026-01-31", "cash_accounts": ["1000"],
|
||||
"include_receipts": false, "include_tins": false});
|
||||
let made = answer_of(&w.call("books.export_cpa_package", args)).unwrap();
|
||||
assert!(made["file"]["sha256"].is_string(), "{made}");
|
||||
let listed = answer_of(&w.call("books.attestations", json!({}))).unwrap();
|
||||
assert!(listed["attestations"].as_array().unwrap().is_empty());
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,152 +0,0 @@
|
||||
//! `wanderbooks connect` makes the book's books home (P11b, D-27): a folder in
|
||||
//! the account's Documents holding the agent's pointer and nothing of the
|
||||
//! books. Run with a home of the test's own: nothing here touches the account's
|
||||
//! real Documents or data root.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
const KNOBS: &str =
|
||||
"close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300";
|
||||
|
||||
struct Home {
|
||||
dir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Home {
|
||||
fn new() -> Self {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-books-home-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||
.args(["setup", "--home"])
|
||||
.arg(dir.path())
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
fn root(&self) -> PathBuf {
|
||||
ledger_store::root::default_root(self.dir.path())
|
||||
}
|
||||
|
||||
fn cmd(&self, mode: &str, path: &Path) -> Command {
|
||||
let mut c = Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
|
||||
c.arg(mode)
|
||||
.arg("--root")
|
||||
.arg(self.root())
|
||||
.args(["--test-knobs", KNOBS])
|
||||
.env_clear()
|
||||
.env("PATH", format!("{}:/usr/bin:/bin", path.display()))
|
||||
.env("HOME", self.dir.path())
|
||||
.stdin(Stdio::null());
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
/// A stand-in `claude` that accepts whatever it is told.
|
||||
fn fake_agent(home: &Home) -> PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let bin = home.dir.path().join("agent-bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
let script = bin.join("claude");
|
||||
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
bin
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_makes_the_books_home_and_it_holds_no_books_data() {
|
||||
let home = Home::new();
|
||||
let out = home
|
||||
.cmd("books", Path::new("/nonexistent"))
|
||||
.args(["create", "beta"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let docs = home.dir.path().join("Documents/Wanderbooks/beta");
|
||||
|
||||
// An agent that isn't installed: said, and no folder made for nothing.
|
||||
let out = home
|
||||
.cmd("connect", Path::new("/nonexistent"))
|
||||
.args(["claude", "--book", "beta"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!out.status.success());
|
||||
assert!(!docs.exists(), "a home for an agent that isn't there");
|
||||
|
||||
// Connected: the user-scope entry as ever, and the books home beside it.
|
||||
let bin = fake_agent(&home);
|
||||
let out = home
|
||||
.cmd("connect", &bin)
|
||||
.args(["claude", "--book", "beta"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let said = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
said.contains("Claude Code reaches the books beta"),
|
||||
"{said}"
|
||||
);
|
||||
assert!(
|
||||
said.contains(&format!("The books home is {}", docs.display())),
|
||||
"{said}"
|
||||
);
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for dir in [docs.clone(), docs.join(".claude")] {
|
||||
for e in std::fs::read_dir(&dir).unwrap() {
|
||||
let p = e.unwrap().path();
|
||||
if p.is_file() {
|
||||
names.push(
|
||||
p.strip_prefix(&docs)
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
".claude/settings.json",
|
||||
".mcp.json",
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md"
|
||||
]
|
||||
);
|
||||
// Nothing in it names the data root, a token or a key.
|
||||
let root = home.root();
|
||||
for n in &names {
|
||||
let text = std::fs::read_to_string(docs.join(n)).unwrap();
|
||||
assert!(!text.contains(root.to_str().unwrap()), "{n}: {text}");
|
||||
assert!(!text.contains("wb_"), "{n} holds a token: {text}");
|
||||
assert!(!text.contains("--root"), "{n}: {text}");
|
||||
}
|
||||
let mcp: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(docs.join(".mcp.json")).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
mcp["mcpServers"]["wanderbooks-beta"]["args"],
|
||||
serde_json::json!(["mcp", "--book", "beta"])
|
||||
);
|
||||
// Run again: still four files, the rule once.
|
||||
let out = home
|
||||
.cmd("connect", &bin)
|
||||
.args(["claude", "--book", "beta"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(docs.join(".claude/settings.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
settings["permissions"]["allow"],
|
||||
serde_json::json!(["mcp__wanderbooks-beta__*"])
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,265 +0,0 @@
|
||||
//! The re-audit's engineering mirror (plan P10; `docs/runbooks/re-audit.md`):
|
||||
//! every defect the 2026-10-16 audit found (1 to 20), P0 (21, 22) and P3
|
||||
//! (23, 24) has a named regression test somewhere in the workspace, and the
|
||||
//! runbook names only tools and commands that exist.
|
||||
//!
|
||||
//! The list below is the single place that says which test stands guard for
|
||||
//! which defect. A test renamed or deleted fails here, so a defect's
|
||||
//! reproduction cannot be lost quietly. This proves the tests exist, are
|
||||
//! real `#[test]`s and are not ignored; that they pass is `cargo test`'s
|
||||
//! job (the runbook's step 4 runs them by name).
|
||||
//!
|
||||
//! SOURCE: plan/served.md 8.0 ("defect n") and P10 step 2.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn read(rel: &str) -> String {
|
||||
std::fs::read_to_string(root().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
|
||||
}
|
||||
|
||||
/// `(defect, test name, file)`. Defect 2 is closed by D-12 and D-47 (the
|
||||
/// rule it now shows is that a reversal needs no receipt of its own);
|
||||
/// defect 20 (the migration scripts) was retired at the cutover and is
|
||||
/// checked separately below.
|
||||
const DEFECTS: [(u32, &str, &str); 23] = [
|
||||
(
|
||||
1,
|
||||
"defect1_two_member_llc_first_year_regression",
|
||||
"crates/ledger-core/tests/spec_cash_flow.rs",
|
||||
),
|
||||
(
|
||||
2,
|
||||
"a_reversal_needs_no_receipt_of_its_own",
|
||||
"crates/ledger-cli/tests/drafts.rs",
|
||||
),
|
||||
(
|
||||
3,
|
||||
"get_receipt_default_has_text_and_no_bytes",
|
||||
"crates/ledger-cli/tests/evidence.rs",
|
||||
),
|
||||
(
|
||||
4,
|
||||
"same_bytes_one_record_many_links",
|
||||
"crates/ledger-store/tests/attachments.rs",
|
||||
),
|
||||
(
|
||||
5,
|
||||
"a_draft_waits_for_its_receipt_and_posts",
|
||||
"crates/ledger-cli/tests/drafts.rs",
|
||||
),
|
||||
(
|
||||
6,
|
||||
"digest_since_seq_is_posting_time",
|
||||
"crates/ledger-cli/tests/drafts.rs",
|
||||
),
|
||||
(
|
||||
7,
|
||||
"the_readme_lists_what_the_iif_writes_differently",
|
||||
"crates/ledger-package/tests/package.rs",
|
||||
),
|
||||
(
|
||||
8,
|
||||
"add_account_warns_without_tax_category_defect_8",
|
||||
"crates/ledger-cli/tests/spec_tax_policy.rs",
|
||||
),
|
||||
(
|
||||
9,
|
||||
"defect9_a_new_schedule_spreads_by_the_day",
|
||||
"crates/ledger-core/tests/schedules.rs",
|
||||
),
|
||||
(
|
||||
10,
|
||||
"annotations_replay_and_project",
|
||||
"crates/ledger-store/tests/attachments.rs",
|
||||
),
|
||||
(
|
||||
11,
|
||||
"defect11_the_directory_shows_a_members_tin_last4",
|
||||
"crates/ledger-cli/tests/spec_vault.rs",
|
||||
),
|
||||
(
|
||||
12,
|
||||
"payment_methods_round_trip_and_classify_payments",
|
||||
"crates/ledger-cli/tests/spec_1099.rs",
|
||||
),
|
||||
(
|
||||
13,
|
||||
"four_contributed_devices_each_take_the_lower_of_value_and_basis",
|
||||
"crates/ledger-core/tests/spec_asset_basis.rs",
|
||||
),
|
||||
(
|
||||
14,
|
||||
"defect14_party_totals_take_a_basis",
|
||||
"crates/ledger-cli/tests/spec_party_totals.rs",
|
||||
),
|
||||
(
|
||||
15,
|
||||
"rename_account_end_to_end_defect_15",
|
||||
"crates/ledger-cli/tests/mcp_journeys.rs",
|
||||
),
|
||||
(
|
||||
16,
|
||||
"one_default_period_everywhere_defect_16",
|
||||
"crates/ledger-cli/tests/mcp_journeys.rs",
|
||||
),
|
||||
(
|
||||
17,
|
||||
"the_audit_odd_cent_never_piles_up_across_closes",
|
||||
"crates/ledger-core/tests/odd_cent.rs",
|
||||
),
|
||||
(
|
||||
18,
|
||||
"undecided_is_not_not_reportable",
|
||||
"crates/ledger-core/tests/form1099.rs",
|
||||
),
|
||||
(
|
||||
19,
|
||||
"no_tool_takes_a_path",
|
||||
"crates/ledger-mcp/src/server_tests.rs",
|
||||
),
|
||||
(
|
||||
21,
|
||||
"as_of_package_iif_stops_at_as_of",
|
||||
"crates/ledger-package/tests/package.rs",
|
||||
),
|
||||
(
|
||||
22,
|
||||
"defect22_depreciation_through_mid_final_month_is_prorated_not_plugged",
|
||||
"crates/ledger-core/tests/spec_assets_payroll.rs",
|
||||
),
|
||||
(
|
||||
23,
|
||||
"a_vendor_marked_no_1099_is_not_undecided",
|
||||
"crates/ledger-cli/tests/spec_1099.rs",
|
||||
),
|
||||
(
|
||||
24,
|
||||
"defect24_an_accrual_through_another_liability_counts_once",
|
||||
"crates/ledger-core/tests/spec_party_totals.rs",
|
||||
),
|
||||
];
|
||||
|
||||
/// Defects 1 to 24 each have a named test that exists, is a test, and is
|
||||
/// not ignored (defect 20 is the retired scripts, below).
|
||||
#[test]
|
||||
fn every_defect_reproduction_is_a_named_test() {
|
||||
let mut covered: Vec<u32> = DEFECTS.iter().map(|d| d.0).collect();
|
||||
covered.push(20);
|
||||
covered.sort_unstable();
|
||||
assert_eq!(
|
||||
covered,
|
||||
(1..=24).collect::<Vec<u32>>(),
|
||||
"a defect is missing"
|
||||
);
|
||||
|
||||
let mut missing = Vec::new();
|
||||
for (defect, name, file) in DEFECTS {
|
||||
let text = read(file);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let needle = format!("fn {name}(");
|
||||
let Some(at) = lines.iter().position(|l| {
|
||||
l.trim_start().starts_with(&needle)
|
||||
|| l.trim_start().starts_with(&format!("pub {needle}"))
|
||||
}) else {
|
||||
missing.push(format!("defect {defect}: no `fn {name}` in {file}"));
|
||||
continue;
|
||||
};
|
||||
// The attributes and doc lines above the function, up to the
|
||||
// previous blank line or item.
|
||||
let above: String = lines[at.saturating_sub(8)..at].join("\n");
|
||||
let in_proptest = text.contains("proptest!");
|
||||
if !above.contains("#[test]") && !in_proptest {
|
||||
missing.push(format!(
|
||||
"defect {defect}: `{name}` in {file} is not a #[test]"
|
||||
));
|
||||
}
|
||||
if above.contains("#[ignore") {
|
||||
missing.push(format!("defect {defect}: `{name}` in {file} is ignored"));
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "{}", missing.join("\n"));
|
||||
}
|
||||
|
||||
/// Defect 20, the migration scripts, was retired at the cutover: there is
|
||||
/// no tool for them, and none may come back as a way past the journal.
|
||||
/// The defect 19 mirror (`import_local_file` and every path parameter) is
|
||||
/// `no_tool_takes_a_path` above.
|
||||
#[test]
|
||||
fn defect_20_no_migration_tool_exists() {
|
||||
let scopes = read("crates/ledger-mcp/src/scopes.rs");
|
||||
for line in scopes.lines().filter(|l| l.contains("(\"books.")) {
|
||||
for banned in [
|
||||
"migrate",
|
||||
"import_local_file",
|
||||
"import_sheet",
|
||||
"verify_sheet",
|
||||
] {
|
||||
assert!(!line.contains(banned), "a retired tool is back: {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The runbook names only tools and commands that exist: each
|
||||
/// `books.<name>` is in the scopes table (the 8.0.3 list of tools), each
|
||||
/// `wanderbooks <command>` is a command `main.rs` dispatches, and each
|
||||
/// test file it names is a file.
|
||||
#[test]
|
||||
fn the_re_audit_runbook_names_only_what_exists() {
|
||||
let runbook = read("docs/runbooks/re-audit.md");
|
||||
let scopes = read("crates/ledger-mcp/src/scopes.rs");
|
||||
let main = read("crates/ledger-cli/src/main.rs");
|
||||
|
||||
let mut tools = Vec::new();
|
||||
let mut rest = runbook.as_str();
|
||||
while let Some(i) = rest.find("books.") {
|
||||
let tail = &rest[i..];
|
||||
let end = tail
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
|
||||
.unwrap_or(tail.len());
|
||||
let name = tail[..end].trim_end_matches('.');
|
||||
// Prose such as "books.start's" ends at the apostrophe; a bare
|
||||
// "books." (the company's books followed by a full stop) is not a name.
|
||||
if name.len() > "books.".len() && !tools.contains(&name.to_string()) {
|
||||
tools.push(name.to_string());
|
||||
}
|
||||
rest = &tail[end.max(1)..];
|
||||
}
|
||||
assert!(
|
||||
tools.len() > 20,
|
||||
"the runbook should name its tools: {tools:?}"
|
||||
);
|
||||
let unknown: Vec<&String> = tools
|
||||
.iter()
|
||||
.filter(|t| !scopes.contains(&format!("(\"{t}\"")))
|
||||
.collect();
|
||||
assert!(
|
||||
unknown.is_empty(),
|
||||
"tools the runbook names that do not exist: {unknown:?}"
|
||||
);
|
||||
|
||||
for cmd in ["gui", "connect"] {
|
||||
if runbook.contains(&format!("wanderbooks {cmd}")) {
|
||||
assert!(main.contains(&format!("\"{cmd}\"")), "no command {cmd}");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
runbook.contains("wanderbooks gui"),
|
||||
"step 6 names the viewer"
|
||||
);
|
||||
|
||||
for step in 1..=8 {
|
||||
assert!(
|
||||
runbook.contains(&format!("## Step {step}.")),
|
||||
"the runbook lost step {step}"
|
||||
);
|
||||
}
|
||||
// The runbook must not point at the data root or tell anyone to read it.
|
||||
assert!(!runbook.contains("wanderbooks.v"), "names a database file");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,779 +0,0 @@
|
||||
//! Drafts, the evidence gate and the preview digest over the wire (plan
|
||||
//! P4; rules.md B-43, D-47, D-50): a draft waits for its receipt and
|
||||
//! posts when it arrives, a discard unblocks a close, a report and the CPA
|
||||
//! package say when drafts are open, a reversal needs no receipt of its
|
||||
//! own, and every tool with `confirmed` takes the `preview` it returned
|
||||
//! and refuses a stale one.
|
||||
//!
|
||||
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — drafts",
|
||||
//! "The preview digest") and rules.md B-43. Every call goes through a real
|
||||
//! books server as an agent reaches it (`common.rs`).
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{Books, answer_of};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Cash at 1000, the default 25.00 receipt threshold.
|
||||
const SETTINGS: &str = "[company]\nname = \"Drafts Co\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\ncash_accounts = [\"1000\"]\n";
|
||||
|
||||
fn chart(w: &mut common::Wire) {
|
||||
for (code, name, ty) in [
|
||||
("1000", "Cash", "asset"),
|
||||
("3000", "Capital", "equity"),
|
||||
("4000", "Sales", "revenue"),
|
||||
("6000", "Rent", "expense"),
|
||||
] {
|
||||
answer_of(&w.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": ty}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A draft of `amount` rent paid from cash on `date`.
|
||||
fn draft(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
|
||||
let r = answer_of(&w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": date, "description": what, "no_party_reason": "test"}),
|
||||
))
|
||||
.unwrap();
|
||||
let id = r["entry_id"].as_u64().unwrap();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn a_receipt(w: &mut common::Wire, entry: u64) -> u64 {
|
||||
let bytes = b"%PDF-1.4 a receipt";
|
||||
let upload = w.upload(bytes, "receipt.pdf", "application/pdf", "receipt");
|
||||
answer_of(&w.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": entry, "upload_id": upload}),
|
||||
))
|
||||
.unwrap()["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn setup(tag: &str) -> (Books, common::Wire) {
|
||||
let books = Books::with(tag, "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p4");
|
||||
chart(&mut w);
|
||||
(books, w)
|
||||
}
|
||||
|
||||
/// The refusal a call met, parsed when it is structured.
|
||||
fn refusal(r: &Value) -> Value {
|
||||
let text = answer_of(r).expect_err("expected a refusal");
|
||||
serde_json::from_str(&text).unwrap_or(Value::String(text))
|
||||
}
|
||||
|
||||
/// A draft finished but waiting for its receipt says so, is listed as
|
||||
/// missing its evidence, is refused at the post, and posts the moment the
|
||||
/// receipt is attached.
|
||||
#[test]
|
||||
fn a_draft_waits_for_its_receipt_and_posts() {
|
||||
let (_books, mut w) = setup("p4-waits");
|
||||
let id = draft(&mut w, "2026-03-01", "Figma annual plan", "100.00");
|
||||
let set = answer_of(&w.call(
|
||||
"books.set_draft_state",
|
||||
json!({"entry_id": id, "state": "awaiting_evidence", "note": "Figma invoice due"}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(set["state"], "awaiting_evidence");
|
||||
let listed = answer_of(&w.call("books.list_drafts", json!({}))).unwrap();
|
||||
let d = &listed["drafts"][0];
|
||||
assert_eq!(d["entry_id"], id);
|
||||
assert_eq!(d["state"], "awaiting_evidence");
|
||||
assert_eq!(d["note"], "Figma invoice due");
|
||||
assert_eq!(d["total"], "100.00");
|
||||
assert_eq!(d["missing_evidence"], true);
|
||||
let waiting = answer_of(&w.call(
|
||||
"books.list_drafts",
|
||||
json!({"state": "awaiting_evidence", "as_of": "2026-03-31"}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(waiting["drafts"].as_array().unwrap().len(), 1);
|
||||
let building = answer_of(&w.call("books.list_drafts", json!({"state": "building"}))).unwrap();
|
||||
assert!(building["drafts"].as_array().unwrap().is_empty());
|
||||
// The preview names the need; the confirm is refused with the error the
|
||||
// MCP always gave.
|
||||
let p = answer_of(&w.call("books.preview_entry", json!({"entry_id": id}))).unwrap();
|
||||
assert_eq!(p["status"], "preview");
|
||||
assert_eq!(p["receipt_required"], true);
|
||||
assert_eq!(p["receipt_missing"], true);
|
||||
let digest = p["preview"].as_str().unwrap().to_string();
|
||||
let r = w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true, "preview": digest}),
|
||||
);
|
||||
let err = refusal(&r);
|
||||
assert_eq!(err["code"], "receipt_required", "{err}");
|
||||
// The receipt arrives; the same draft posts.
|
||||
a_receipt(&mut w, id);
|
||||
let r = w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
let posted = answer_of(&r).unwrap();
|
||||
assert_eq!(posted["status"], "posted");
|
||||
let listed = answer_of(&w.call("books.list_drafts", json!({}))).unwrap();
|
||||
assert!(listed["drafts"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// B-43: the structured `receipt_required` error is the one the MCP built
|
||||
/// before the gate moved into the store.
|
||||
#[test]
|
||||
fn the_gate_moved_and_the_mcp_error_is_unchanged() {
|
||||
let (_books, mut w) = setup("p4-error");
|
||||
let id = draft(&mut w, "2026-03-01", "Rent", "100.00");
|
||||
let r = w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
let err = refusal(&r);
|
||||
assert_eq!(err["code"], "receipt_required");
|
||||
assert_eq!(err["entry_id"], id);
|
||||
assert_eq!(err["debits"], "100.00");
|
||||
assert_eq!(
|
||||
err["policy"],
|
||||
"receipts required when entry debits are strictly greater than 25.00"
|
||||
);
|
||||
assert_eq!(err["remedy_tool"], "books.attach_receipt");
|
||||
assert!(
|
||||
err["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with(&format!("entry {id} moves 100.00 in debits ("))
|
||||
);
|
||||
assert!(
|
||||
err["remedy_hint"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("then retry books.post_entry with confirmed=true")
|
||||
);
|
||||
assert!(err.get("split_group").is_none());
|
||||
}
|
||||
|
||||
/// D-47: a reversal posts at once with no receipt of its own; its evidence
|
||||
/// is its original's.
|
||||
#[test]
|
||||
fn a_reversal_needs_no_receipt_of_its_own() {
|
||||
let (_books, mut w) = setup("p4-reversal");
|
||||
let id = draft(&mut w, "2026-03-01", "Rent", "100.00");
|
||||
a_receipt(&mut w, id);
|
||||
answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
let r = answer_of(&w.call(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": id, "date": "2026-03-05", "description": "Undo", "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(r["status"], "posted");
|
||||
let d = answer_of(&w.call("books.digest", json!({"since": "2026-03-01"}))).unwrap();
|
||||
for e in d["entries"].as_array().unwrap() {
|
||||
if e["reversal_of"].is_number() {
|
||||
assert_eq!(e["receipt_required"], false, "{e}");
|
||||
assert_eq!(e["receipt_missing"], false, "{e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A period holding an open draft cannot close; discarded (previewed, then
|
||||
/// confirmed with its digest), it can.
|
||||
#[test]
|
||||
fn discard_unblocks_the_close_over_the_wire() {
|
||||
let (_books, mut w) = setup("p4-discard");
|
||||
let seed = draft(&mut w, "2026-03-01", "Seed", "10.00");
|
||||
answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": seed, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
let per = answer_of(&w.call(
|
||||
"books.create_period",
|
||||
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
|
||||
))
|
||||
.unwrap()["period_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let id = draft(&mut w, "2026-03-20", "Entered twice", "40.00");
|
||||
let r = w.call(
|
||||
"books.close_period",
|
||||
json!({"period_id": per, "confirmed": true}),
|
||||
);
|
||||
assert!(
|
||||
answer_of(&r).is_err(),
|
||||
"a close is refused with a draft open"
|
||||
);
|
||||
let p = answer_of(&w.call(
|
||||
"books.discard_draft",
|
||||
json!({"entry_id": id, "reason": "entered twice"}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(p["status"], "preview");
|
||||
assert_eq!(p["holds_open"], json!(["2026-03"]));
|
||||
let digest = p["preview"].as_str().unwrap().to_string();
|
||||
// A reason is the preview's; a different one is a different preview.
|
||||
let r = w.call_raw(
|
||||
"books.discard_draft",
|
||||
json!({"entry_id": id, "reason": "something else", "confirmed": true, "preview": digest}),
|
||||
);
|
||||
assert_eq!(refusal(&r)["code"], "stale_preview");
|
||||
let done = answer_of(&w.call_raw(
|
||||
"books.discard_draft",
|
||||
json!({"entry_id": id, "reason": "entered twice", "confirmed": true, "preview": digest}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(done["status"], "discarded");
|
||||
assert!(
|
||||
answer_of(&w.call("books.list_drafts", json!({}))).unwrap()["drafts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let closed = answer_of(&w.call(
|
||||
"books.close_period",
|
||||
json!({"period_id": per, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(closed["status"], "closed");
|
||||
// Only a draft: the posted seed is never discarded.
|
||||
let r = w.call(
|
||||
"books.discard_draft",
|
||||
json!({"entry_id": seed, "reason": "no"}),
|
||||
);
|
||||
assert!(answer_of(&r).unwrap_err().contains("posted"));
|
||||
}
|
||||
|
||||
/// A report says what is open as of its end; strict refuses instead.
|
||||
#[test]
|
||||
fn report_warns_on_draft_before_as_of() {
|
||||
let (_books, mut w) = setup("p4-report");
|
||||
let id = draft(&mut w, "2026-03-10", "Open", "40.00");
|
||||
let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-31"}))).unwrap();
|
||||
let open = &tb["as_of_status"]["drafts_open_on_or_before"];
|
||||
assert_eq!(open[0]["entry_id"], id);
|
||||
assert_eq!(open[0]["state"], "building");
|
||||
let early = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-01"}))).unwrap();
|
||||
assert!(
|
||||
early["as_of_status"]["drafts_open_on_or_before"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"a draft dated after the report's end is not open in it"
|
||||
);
|
||||
for tool in [
|
||||
"books.trial_balance",
|
||||
"books.balance_sheet",
|
||||
"books.profit_and_loss",
|
||||
"books.cash_flow",
|
||||
] {
|
||||
let args = match tool {
|
||||
"books.trial_balance" | "books.balance_sheet" => {
|
||||
json!({"as_of": "2026-03-31", "strict": true})
|
||||
}
|
||||
_ => json!({"start": "2026-03-01", "end": "2026-03-31", "strict": true}),
|
||||
};
|
||||
let r = w.call(tool, args);
|
||||
let why = answer_of(&r).expect_err(tool);
|
||||
assert!(
|
||||
why.contains("strict") && why.contains("draft"),
|
||||
"{tool}: {why}"
|
||||
);
|
||||
}
|
||||
// Posted, the report is clean and strict gives it.
|
||||
a_receipt(&mut w, id);
|
||||
answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
let ok = answer_of(&w.call(
|
||||
"books.trial_balance",
|
||||
json!({"as_of": "2026-03-31", "strict": true}),
|
||||
));
|
||||
// Cash is not reconciled, so strict still names it: the refusal says
|
||||
// so and not "draft".
|
||||
if let Err(why) = ok {
|
||||
assert!(!why.contains("draft"), "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPA package is refused while a draft dated in it is open, unless
|
||||
/// the human says why; the reason is journaled as a decision.
|
||||
#[test]
|
||||
fn cpa_package_refuses_open_draft() {
|
||||
let (books, mut w) = setup("p4-cpa");
|
||||
let id = draft(&mut w, "2026-03-10", "Open", "40.00");
|
||||
let args = json!({"as_of": "2026-03-31", "cash_accounts": ["1000"]});
|
||||
let err = refusal(&w.call("books.export_cpa_package", args.clone()));
|
||||
assert_eq!(err["code"], "open_drafts", "{err}");
|
||||
assert!(err["message"].as_str().unwrap().contains(&format!("#{id}")));
|
||||
// A draft dated after as_of does not stop it.
|
||||
let before = books.journal().len();
|
||||
let mut allowed = args.clone();
|
||||
allowed["allow_open_drafts"] = json!({"reason": "the CPA wants it before the receipt comes"});
|
||||
let out = answer_of(&w.call("books.export_cpa_package", allowed)).unwrap();
|
||||
assert!(out["file"].is_object(), "{out}");
|
||||
let journal = String::from_utf8(books.journal()).unwrap();
|
||||
assert!(books.journal().len() > before);
|
||||
assert!(journal.contains("decision.recorded"));
|
||||
assert!(journal.contains("the CPA wants it before the receipt comes"));
|
||||
}
|
||||
|
||||
/// D-50: a confirm takes the preview it was shown; without it, or after a
|
||||
/// changed argument or a write by another session, it is refused with
|
||||
/// `stale_preview` before anything is written.
|
||||
#[test]
|
||||
fn stale_preview_refused() {
|
||||
let (books, mut w) = setup("p4-stale");
|
||||
let id = draft(&mut w, "2026-03-01", "Rent", "10.00");
|
||||
// A draft's preview is the draft's state: a line added since is stale.
|
||||
let p = answer_of(&w.call("books.preview_entry", json!({"entry_id": id}))).unwrap();
|
||||
let digest = p["preview"].as_str().unwrap().to_string();
|
||||
// The same preview from post_entry itself.
|
||||
let p2 = answer_of(&w.call("books.post_entry", json!({"entry_id": id}))).unwrap();
|
||||
assert_eq!(p2["preview"], p["preview"]);
|
||||
// No preview at all.
|
||||
let r = w.call_raw(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
assert_eq!(refusal(&r)["code"], "stale_preview");
|
||||
// A line added since the preview.
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": "6000", "side": "debit", "amount": "1.00"}),
|
||||
))
|
||||
.unwrap();
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": "1000", "side": "credit", "amount": "1.00"}),
|
||||
))
|
||||
.unwrap();
|
||||
let before = books.journal();
|
||||
let r = w.call_raw(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true, "preview": digest}),
|
||||
);
|
||||
let err = refusal(&r);
|
||||
assert_eq!(err["code"], "stale_preview");
|
||||
assert_eq!(err["remedy_tool"], "books.post_entry");
|
||||
assert_eq!(books.journal(), before, "a stale confirm writes nothing");
|
||||
// An operation's preview moves with the journal's head: a post by
|
||||
// another session in between forces a re-preview.
|
||||
let other = draft(&mut w, "2026-03-02", "Other", "5.00");
|
||||
let first = answer_of(&w.call(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": other, "date": "2026-03-03", "description": "x"}),
|
||||
));
|
||||
assert!(first.is_err(), "a draft is not reversible");
|
||||
answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": other, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
let p = answer_of(&w.call(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": other, "date": "2026-03-03", "description": "x"}),
|
||||
))
|
||||
.unwrap();
|
||||
let digest = p["preview"].as_str().unwrap().to_string();
|
||||
// The same call with another description is another preview...
|
||||
let r = w.call_raw(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": other, "date": "2026-03-03", "description": "y",
|
||||
"confirmed": true, "preview": digest}),
|
||||
);
|
||||
assert_eq!(refusal(&r)["code"], "stale_preview");
|
||||
// ...and so is a write between preview and confirm.
|
||||
answer_of(&w.call(
|
||||
"books.add_account",
|
||||
json!({"code": "6100", "name": "Software", "account_type": "expense"}),
|
||||
))
|
||||
.unwrap();
|
||||
let r = w.call_raw(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": other, "date": "2026-03-03", "description": "x",
|
||||
"confirmed": true, "preview": digest}),
|
||||
);
|
||||
assert_eq!(refusal(&r)["code"], "stale_preview");
|
||||
// Re-previewed, it confirms.
|
||||
let r = w.call(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": other, "date": "2026-03-03", "description": "x",
|
||||
"confirmed": true}),
|
||||
);
|
||||
assert_eq!(answer_of(&r).unwrap()["status"], "posted");
|
||||
}
|
||||
|
||||
/// Every tool that takes `confirmed` takes `preview` (D-50, from P4 on).
|
||||
#[test]
|
||||
fn every_confirm_tool_takes_a_preview() {
|
||||
let books = Books::new("p4-schema");
|
||||
let mut w = books.session("p4");
|
||||
let tools = w.rpc("tools/list", json!({}))["result"]["tools"].clone();
|
||||
let mut confirm = Vec::new();
|
||||
for t in tools.as_array().expect("tools") {
|
||||
let props = &t["inputSchema"]["properties"];
|
||||
if props.get("confirmed").is_some() {
|
||||
confirm.push(t["name"].as_str().unwrap().to_string());
|
||||
assert!(
|
||||
props.get("preview").is_some(),
|
||||
"{} takes confirmed but not preview",
|
||||
t["name"]
|
||||
);
|
||||
}
|
||||
}
|
||||
for tool in [
|
||||
"books.post_entry",
|
||||
"books.reverse_entry",
|
||||
"books.run_schedules",
|
||||
"books.dispose_asset",
|
||||
"books.reconcile",
|
||||
"books.close_period",
|
||||
"books.create_invoice",
|
||||
"books.create_bill",
|
||||
"books.receive_payment",
|
||||
"books.pay_bill",
|
||||
"books.write_off_invoice",
|
||||
"books.discard_draft",
|
||||
] {
|
||||
assert!(confirm.iter().any(|t| t == tool), "{tool} has no confirmed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Defect 6: an entry dated in the past but posted just now is in the
|
||||
/// digest asked for by the journal sequence number, and not in the one
|
||||
/// asked for by date.
|
||||
#[test]
|
||||
fn digest_since_seq_is_posting_time() {
|
||||
let (_books, mut w) = setup("p4-digest");
|
||||
let early = draft(&mut w, "2026-01-05", "Backdated", "20.00");
|
||||
let head =
|
||||
answer_of(&w.call("books.digest", json!({"since": "2026-01-01"}))).unwrap()["head_seq"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": early, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
let by_seq = answer_of(&w.call("books.digest", json!({"since_seq": head}))).unwrap();
|
||||
let ids: Vec<u64> = by_seq["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e["entry_id"].as_u64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(ids, vec![early]);
|
||||
assert!(by_seq["head_seq"].as_u64().unwrap() > head);
|
||||
let by_date = answer_of(&w.call("books.digest", json!({"since": "2026-02-01"}))).unwrap();
|
||||
assert!(by_date["entries"].as_array().unwrap().is_empty());
|
||||
// Nothing after the new head.
|
||||
let none =
|
||||
answer_of(&w.call("books.digest", json!({"since_seq": by_seq["head_seq"]}))).unwrap();
|
||||
assert!(none["entries"].as_array().unwrap().is_empty());
|
||||
// An account touched whose net movement is zero is listed, not lost.
|
||||
let second = draft(&mut w, "2026-01-06", "Refund", "20.00");
|
||||
// (reverse the sign on the same accounts: debit cash, credit rent)
|
||||
answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": second, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
let rev = answer_of(&w.call(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": second, "date": "2026-01-07", "description": "Undo",
|
||||
"confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(rev["status"], "posted");
|
||||
let d = answer_of(&w.call("books.digest", json!({"since_seq": head}))).unwrap();
|
||||
let zero: Vec<&str> = d["zero_net_accounts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
assert!(
|
||||
zero.is_empty(),
|
||||
"the first entry still moves rent: {zero:?}"
|
||||
);
|
||||
let only_pair = answer_of(&w.call(
|
||||
"books.digest",
|
||||
json!({"since": "2026-01-06", "as_of": "2026-01-31"}),
|
||||
))
|
||||
.unwrap();
|
||||
let zero: Vec<&str> = only_pair["zero_net_accounts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
assert_eq!(zero, vec!["1000 Cash", "6000 Rent"]);
|
||||
assert_eq!(only_pair["drafts_by_state"], json!({}));
|
||||
assert_eq!(only_pair["never_reconciled"], json!(["1000"]));
|
||||
}
|
||||
|
||||
/// The viewer and the server ask one function whether an entry lacks its
|
||||
/// evidence (plan P4 step 2, I-5): on random books under three policies
|
||||
/// the viewer's missing-evidence set equals the server's, and both equal
|
||||
/// what the viewer decided before the gate moved (an entry's origin read
|
||||
/// from the journal's events: a hand entry or a recurring draft is
|
||||
/// covered; a document's, a payment's, a close, a schedule month's, a
|
||||
/// depreciation's, a payroll's and a reversal are not).
|
||||
#[cfg(feature = "gui")]
|
||||
#[test]
|
||||
fn gui_and_server_gate_agree() {
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::settings::{Change, Source};
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_gui::data;
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
|
||||
let usd = |c: i64| Money::new(Decimal::new(c, 2), Currency::USD);
|
||||
for (n, (threshold, required, exempt)) in [
|
||||
("0.00", vec![], vec![]),
|
||||
("25.00", vec!["6200"], vec!["6300"]),
|
||||
("100.00", vec![], vec!["6300", "6100"]),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let root = tempfile::Builder::new()
|
||||
.prefix(&format!("wb-p4-gate-{n}-"))
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let dir = root.path().join("book");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
|
||||
st.change_settings(
|
||||
&[
|
||||
Change {
|
||||
key: "policy.evidence.threshold".into(),
|
||||
value: Some(json!(threshold)),
|
||||
},
|
||||
Change {
|
||||
key: "policy.evidence.required_accounts".into(),
|
||||
value: Some(json!(required)),
|
||||
},
|
||||
Change {
|
||||
key: "policy.evidence.exempt_accounts".into(),
|
||||
value: Some(json!(exempt)),
|
||||
},
|
||||
],
|
||||
&Source {
|
||||
decided_by: "test".into(),
|
||||
decided_on: day(1, 1),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut acct =
|
||||
|code: &str, name: &str, ty| st.add_account(code, name, ty, None, false).unwrap();
|
||||
let bank = acct("1000", "Bank", AccountType::Asset);
|
||||
let prepaid = acct("1300", "Prepaid", AccountType::Asset);
|
||||
let capital = acct("3000", "Capital", AccountType::Equity);
|
||||
let sales = acct("4000", "Sales", AccountType::Revenue);
|
||||
let expenses = [
|
||||
acct("6100", "Supplies", AccountType::Expense),
|
||||
acct("6200", "Contractors", AccountType::Expense),
|
||||
acct("6300", "Fees", AccountType::Expense),
|
||||
];
|
||||
let seed = st.create_entry(day(1, 1), "Capital").unwrap();
|
||||
st.add_line(seed, bank, Side::Debit, usd(10_000_000))
|
||||
.unwrap();
|
||||
st.add_line(seed, capital, Side::Credit, usd(10_000_000))
|
||||
.unwrap();
|
||||
st.post_entry(seed).unwrap();
|
||||
// A deterministic pseudo-random run of hand entries.
|
||||
let mut x: u64 = 0x2545F4914F6CDD1D ^ (n as u64);
|
||||
let mut next = move || {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
x
|
||||
};
|
||||
let mut posted = Vec::new();
|
||||
for i in 0..36u32 {
|
||||
let e = st
|
||||
.create_entry(day(2, 1 + (i % 27)), &format!("Entry {i}"))
|
||||
.unwrap();
|
||||
let cents = 100 + (next() % 30_000) as i64;
|
||||
let ex = expenses[(next() % 3) as usize];
|
||||
st.add_line(e, ex, Side::Debit, usd(cents)).unwrap();
|
||||
st.add_line(e, bank, Side::Credit, usd(cents)).unwrap();
|
||||
if next() % 3 == 0 {
|
||||
st.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF r", None)
|
||||
.unwrap();
|
||||
}
|
||||
// Some stay drafts.
|
||||
if next() % 6 != 0 {
|
||||
st.post_entry(e).unwrap();
|
||||
posted.push(e);
|
||||
}
|
||||
}
|
||||
for e in posted.iter().take(5) {
|
||||
st.reverse_entry(*e, day(3, 2), "Reversed").unwrap();
|
||||
}
|
||||
let customer = st.add_customer("Fernhill Labs").unwrap();
|
||||
let inv = st
|
||||
.create_invoice(
|
||||
customer,
|
||||
day(3, 3),
|
||||
day(4, 2),
|
||||
vec![ledger_core::arap::DocumentLine {
|
||||
description: "Design".into(),
|
||||
amount: Decimal::new(250_000, 2),
|
||||
class_id: None,
|
||||
account: None,
|
||||
quantity: None,
|
||||
unit_price: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
st.receive_payment(inv, day(3, 20), Decimal::new(100_000, 2), bank)
|
||||
.unwrap();
|
||||
let sched = st
|
||||
.register_schedule(ledger_core::NewSchedule {
|
||||
description: "Insurance".into(),
|
||||
kind: ledger_core::ScheduleKind::PrepaidExpense,
|
||||
total: usd(120_000),
|
||||
start: day(1, 1),
|
||||
months: 12,
|
||||
balance_account: prepaid,
|
||||
pl_account: expenses[0],
|
||||
convention: ledger_core::ScheduleConvention::MonthlyEven,
|
||||
})
|
||||
.unwrap();
|
||||
st.post_schedule_month(sched, day(1, 31)).unwrap();
|
||||
let _ = sales;
|
||||
let per = st.create_period("2026-01", day(1, 1), day(1, 31)).unwrap();
|
||||
let _ = st.close_period(per);
|
||||
st.register_recurring(ledger_core::NewRecurring {
|
||||
description: "Figma".into(),
|
||||
lines: vec![
|
||||
(expenses[0], Side::Debit, usd(4_000)),
|
||||
(bank, Side::Credit, usd(4_000)),
|
||||
],
|
||||
frequency: ledger_core::Frequency::Monthly,
|
||||
start: day(5, 10),
|
||||
end: None,
|
||||
})
|
||||
.unwrap();
|
||||
st.run_recurring(day(5, 31)).unwrap();
|
||||
// What the server decides: the engine, with the receipts filed.
|
||||
let server: BTreeSet<u64> = (1..st.ledger().next_entry_id())
|
||||
.filter(|id| {
|
||||
let Some(e) = st.ledger().get_entry(*id) else {
|
||||
return false;
|
||||
};
|
||||
ledger_core::evidence::requirement(st.ledger(), e, st.ledger().settings())
|
||||
.is_required()
|
||||
&& st.list_attachments(Some(*id)).unwrap().is_empty()
|
||||
})
|
||||
.collect();
|
||||
// What the viewer decided before: origin from the journal's events.
|
||||
let policy = ledger_core::evidence::Policy::from_settings(st.ledger().settings());
|
||||
let journal = String::from_utf8(std::fs::read(dir.join("journal.jsonl")).unwrap()).unwrap();
|
||||
let mut refs: BTreeMap<u64, Vec<String>> = BTreeMap::new();
|
||||
for line in journal.lines() {
|
||||
let v: Value = serde_json::from_str(line).unwrap();
|
||||
let ty = v["type"].as_str().unwrap().to_string();
|
||||
let p = &v["payload"];
|
||||
let mut ids = Vec::new();
|
||||
if ty.starts_with("entry.") {
|
||||
ids.extend(p["id"].as_u64());
|
||||
}
|
||||
ids.extend(p["entry_id"].as_u64());
|
||||
ids.extend(p["closing_entry"].as_u64());
|
||||
ids.extend(p["entry"]["id"].as_u64());
|
||||
for id in ids {
|
||||
refs.entry(id).or_default().push(ty.clone());
|
||||
}
|
||||
}
|
||||
let closings = st.ledger().closing_entry_ids();
|
||||
let old: BTreeSet<u64> = (1..st.ledger().next_entry_id())
|
||||
.filter(|id| {
|
||||
let Some(e) = st.ledger().get_entry(*id) else {
|
||||
return false;
|
||||
};
|
||||
let has = |t: &str| refs.get(id).is_some_and(|v| v.iter().any(|x| x == t));
|
||||
let by_hand = !closings.contains(id)
|
||||
&& !has("entry.reversed")
|
||||
&& !has("schedule.posted")
|
||||
&& !has("asset.depreciation_posted")
|
||||
&& !has("asset.disposed")
|
||||
&& !has("bill.created")
|
||||
&& !has("bill.paid")
|
||||
&& !has("invoice.created")
|
||||
&& !has("invoice.payment_received")
|
||||
&& !has("payroll.run")
|
||||
&& !has("payroll.taxes_remitted");
|
||||
by_hand
|
||||
&& e.reversal_of().is_none()
|
||||
&& st.list_attachments(Some(*id)).unwrap().is_empty()
|
||||
&& policy.receipt_required(st.ledger(), e)
|
||||
})
|
||||
.collect();
|
||||
drop(st);
|
||||
let books = data::load_once(&data::Source {
|
||||
data_dir: dir.clone(),
|
||||
today: Some(day(6, 1)),
|
||||
})
|
||||
.unwrap();
|
||||
let viewer: BTreeSet<u64> = (1..=books.ledger.next_entry_id())
|
||||
.filter(|id| books.entry(*id).is_some_and(|e| e.missing_receipt))
|
||||
.collect();
|
||||
assert!(!server.is_empty(), "policy {threshold}: nothing to compare");
|
||||
assert_eq!(viewer, server, "policy {threshold}: viewer vs server");
|
||||
assert_eq!(server, old, "policy {threshold}: engine vs the old reading");
|
||||
}
|
||||
}
|
||||
|
||||
/// Books an older version wrote hold a reversal and drafts (the fixture
|
||||
/// `p4_before`): opened under this version, with no upgrade, the figures
|
||||
/// are the writer's, the reversal needs no receipt of its own, and the
|
||||
/// drafts are open and building.
|
||||
#[test]
|
||||
fn legacy_reversed_replays_identically() {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-store/tests/fixtures/old-books/p4_before");
|
||||
let only = tempfile::tempdir().unwrap();
|
||||
std::fs::copy(src.join("journal.jsonl"), only.path().join("journal.jsonl")).unwrap();
|
||||
let books = Books::placed("p4-legacy", only.path());
|
||||
let mut w = books.session("p4");
|
||||
let d = answer_of(&w.call("books.digest", json!({"since": "2026-01-01"}))).unwrap();
|
||||
let reversals: Vec<&Value> = d["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|e| e["reversal_of"].is_number())
|
||||
.collect();
|
||||
assert_eq!(reversals.len(), 1);
|
||||
assert_eq!(reversals[0]["receipt_required"], false);
|
||||
assert_eq!(reversals[0]["receipt_missing"], false);
|
||||
let drafts = answer_of(&w.call("books.list_drafts", json!({}))).unwrap();
|
||||
let open: Vec<&Value> = drafts["drafts"].as_array().unwrap().iter().collect();
|
||||
assert_eq!(open.len(), 3, "{drafts}");
|
||||
assert!(open.iter().all(|d| d["state"] == "building"));
|
||||
assert_eq!(d["drafts_by_state"], json!({"building": 3}));
|
||||
let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-12-31"}))).unwrap();
|
||||
assert_eq!(tb["balanced"], true);
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
//! The evidence pipeline over the wire (plan P6): a receipt's text and
|
||||
//! pages, annotations, finding a file, consolidating duplicates, and the
|
||||
//! backfill command.
|
||||
//!
|
||||
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools —
|
||||
//! evidence", "Tools — receipts & documents") and rules.md B-44. Every
|
||||
//! call goes through a real books server as an agent reaches it
|
||||
//! (`common.rs`).
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{Books, answer_of};
|
||||
use serde_json::json;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The settings a test's books start with: a high receipt threshold (so a
|
||||
/// simple posting needs no receipt) and cash at 1000.
|
||||
const SETTINGS: &str = "[company]\nname = \"Evidence Co\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\ncash_accounts = [\"1000\"]\nreceipt_threshold = \"1000.00\"\n";
|
||||
|
||||
/// A PDF with one page that says the word "Rent" (the extractor's own
|
||||
/// typesetter, so the text is real and the page renders).
|
||||
fn a_pdf() -> Vec<u8> {
|
||||
use ledger_import::report::{ReportMeta, Row, RowKind, statement};
|
||||
use rust_decimal::Decimal;
|
||||
let st = statement(
|
||||
"Receipt",
|
||||
"March 2026".to_string(),
|
||||
&["Total"],
|
||||
vec![Row {
|
||||
kind: RowKind::Line,
|
||||
label: "Rent".into(),
|
||||
amounts: vec![Some(Decimal::new(10000, 2))],
|
||||
currency: true,
|
||||
rule_above: false,
|
||||
}],
|
||||
);
|
||||
let meta = ReportMeta {
|
||||
company: "Evidence Co".into(),
|
||||
basis: "Accrual".into(),
|
||||
prepared_on: chrono::NaiveDate::from_ymd_opt(2026, 3, 2).unwrap(),
|
||||
journal_seq: 1,
|
||||
};
|
||||
ledger_import::statement_pdf(&st, &meta).unwrap()
|
||||
}
|
||||
|
||||
/// A draft with two lines, and the accounts made for it.
|
||||
fn a_draft(w: &mut common::Wire) -> u64 {
|
||||
for (code, name, ty) in [("1000", "Cash", "asset"), ("6000", "Rent", "expense")] {
|
||||
let r = w.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": ty}),
|
||||
);
|
||||
if let Err(e) = answer_of(&r) {
|
||||
assert!(e.contains("already"), "{e}");
|
||||
}
|
||||
}
|
||||
let r = w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-03-01", "description": "March rent"}),
|
||||
);
|
||||
let id = answer_of(&r).unwrap()["entry_id"].as_u64().unwrap();
|
||||
for (account, side, amount) in [("6000", "debit", "100.00"), ("1000", "credit", "100.00")] {
|
||||
let r = w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
|
||||
);
|
||||
answer_of(&r).unwrap();
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// File `bytes` on `entry` through an upload, as an agent does (D-46).
|
||||
fn file(w: &mut common::Wire, entry: u64, bytes: &[u8], name: &str, mime: &str) -> u64 {
|
||||
let upload = w.upload(bytes, name, mime, "receipt");
|
||||
let r = w.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": entry, "upload_id": upload}),
|
||||
);
|
||||
answer_of(&r).unwrap()["attachment_id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// A PNG's width and height, from its IHDR.
|
||||
fn png_size(bytes: &[u8]) -> (u32, u32) {
|
||||
assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "not a PNG");
|
||||
let w = u32::from_be_bytes(bytes[16..20].try_into().unwrap());
|
||||
let h = u32::from_be_bytes(bytes[20..24].try_into().unwrap());
|
||||
(w, h)
|
||||
}
|
||||
|
||||
/// A book folder with two live receipts of one file's bytes, filed by an
|
||||
/// older version on two entries (plan P6 step 4: legacy duplicates). With
|
||||
/// `withdraw_first`, the first is already withdrawn, as a duplicate of the
|
||||
/// second, so the second is its entry's only evidence.
|
||||
fn legacy_duplicates(only: &str, withdraw_first: bool) -> (tempfile::TempDir, PathBuf) {
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
let root = tempfile::Builder::new()
|
||||
.prefix(&format!("wb-p6-{only}-"))
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let dir = root.path().join("book");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = ledger_store::Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = s
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let expense = s
|
||||
.add_account("6000", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let mut entries = Vec::new();
|
||||
for (d, what) in [("2026-03-01", "Rent one"), ("2026-03-02", "Rent two")] {
|
||||
let e = s
|
||||
.create_entry(
|
||||
chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap(),
|
||||
what,
|
||||
)
|
||||
.unwrap();
|
||||
let m = Money::new("100.00".parse().unwrap(), Currency::USD);
|
||||
s.add_line(e, expense, Side::Debit, m).unwrap();
|
||||
s.add_line(e, cash, Side::Credit, m).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
entries.push(e);
|
||||
}
|
||||
drop(s);
|
||||
let bytes = b"one receipt, filed twice by an older version";
|
||||
let (sha, _) = ledger_store::BlobStore::open(&dir.join("blobs"))
|
||||
.unwrap()
|
||||
.put(bytes)
|
||||
.unwrap();
|
||||
let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
for (id, entry, name) in [(1u64, entries[0], "one.txt"), (2, entries[1], "two.txt")] {
|
||||
w.append(
|
||||
"receipt.attached",
|
||||
json!({
|
||||
"attachment_id": id, "blob_sha": sha, "entry_id": entry,
|
||||
"category": "receipts", "file_name": name,
|
||||
"mime_type": "text/plain", "size_bytes": bytes.len() as u64,
|
||||
"description": null,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
if withdraw_first {
|
||||
w.append(
|
||||
"receipt.unlinked",
|
||||
json!({"attachment_id": 1, "entry_id": entries[0], "reason": "duplicate"}),
|
||||
)
|
||||
.unwrap();
|
||||
w.append(
|
||||
"attachment.withdrawn",
|
||||
json!({"attachment_id": 1, "reason": "duplicate of #2", "duplicate_of": 2}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(w);
|
||||
for f in [
|
||||
ledger_store::db_file(),
|
||||
ledger_store::LOCK_FILE.to_string(),
|
||||
format!("{}-wal", ledger_store::db_file()),
|
||||
format!("{}-shm", ledger_store::db_file()),
|
||||
] {
|
||||
let _ = std::fs::remove_file(dir.join(f));
|
||||
}
|
||||
(root, dir)
|
||||
}
|
||||
|
||||
/// Plan P6 step 3: the default answer is what the file is, the first 8 KiB
|
||||
/// of its text and its pages; never its bytes, and small enough to read.
|
||||
#[test]
|
||||
fn get_receipt_default_has_text_and_no_bytes() {
|
||||
let books = Books::with("p6-meta", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("evidence");
|
||||
let entry = a_draft(&mut w);
|
||||
let pdf = a_pdf();
|
||||
let id = file(&mut w, entry, &pdf, "receipt.pdf", "application/pdf");
|
||||
let r = w.call("books.get_receipt", json!({"attachment_id": id}));
|
||||
let out = answer_of(&r).unwrap();
|
||||
assert_eq!(out["text_status"], "ok", "{out}");
|
||||
assert_eq!(out["pages"], 1, "{out}");
|
||||
let text = out["text"].as_str().unwrap();
|
||||
assert!(text.contains("Rent"), "{text}");
|
||||
assert!(text.len() <= 8 * 1024);
|
||||
assert_eq!(out["text_truncated"], false);
|
||||
assert!(out.get("file").is_none(), "no file in the default answer");
|
||||
assert!(
|
||||
serde_json::to_string(&out).unwrap().len() < 8 * 1024,
|
||||
"the default answer is under 8 KiB"
|
||||
);
|
||||
assert!(!serde_json::to_string(&out).unwrap().contains("base64"));
|
||||
// view text: the whole text, no file.
|
||||
let r = w.call(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": id, "view": "text"}),
|
||||
);
|
||||
let out = answer_of(&r).unwrap();
|
||||
assert_eq!(out["text"].as_str().unwrap(), text);
|
||||
// The resource reads the same: metadata plus text.
|
||||
let r = w.rpc(
|
||||
"resources/read",
|
||||
json!({"uri": format!("books://attachments/{id}")}),
|
||||
);
|
||||
let text = r["result"]["contents"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("\"attachment_id\""), "{text}");
|
||||
assert!(text.contains("Rent"), "{text}");
|
||||
}
|
||||
|
||||
/// Plan P6 step 3: one page as a PNG, handed over; page numbers start at
|
||||
/// 1; a page past the last is refused.
|
||||
#[test]
|
||||
fn page_image_is_a_png_of_the_page() {
|
||||
let books = Books::with("p6-page", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("evidence");
|
||||
let entry = a_draft(&mut w);
|
||||
let id = file(&mut w, entry, &a_pdf(), "receipt.pdf", "application/pdf");
|
||||
let r = w.call(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": id, "view": "page_image", "page": 1, "max_px": 300}),
|
||||
);
|
||||
let out = answer_of(&r).unwrap();
|
||||
let file = &out["file"];
|
||||
assert_eq!(file["mime_type"], "image/png");
|
||||
let png = w.fetch(file);
|
||||
let (width, height) = png_size(&png);
|
||||
assert!(width.max(height) <= 300, "{width}x{height}");
|
||||
assert!(width > 0 && height > 0);
|
||||
// The page resource gives a PNG of the page too (its size its own:
|
||||
// a resource takes no max_px).
|
||||
let r = w.rpc(
|
||||
"resources/read",
|
||||
json!({"uri": format!("books://attachments/{id}/page/1")}),
|
||||
);
|
||||
let contents = &r["result"]["contents"][0];
|
||||
assert_eq!(contents["mimeType"], "image/png", "{contents}");
|
||||
let encoded = contents["blob"].as_str().unwrap();
|
||||
use base64::Engine;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.unwrap();
|
||||
png_size(&bytes);
|
||||
// Past the last page: refused.
|
||||
let r = w.call(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": id, "view": "page_image", "page": 2}),
|
||||
);
|
||||
assert!(answer_of(&r).is_err());
|
||||
// bytes still hands the file over, unchanged.
|
||||
let r = w.call(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": id, "view": "bytes"}),
|
||||
);
|
||||
let out = answer_of(&r).unwrap();
|
||||
assert_eq!(w.fetch(&out["file"]), a_pdf());
|
||||
}
|
||||
|
||||
/// Plan P6 steps 4 and 5: annotations, the engine's numbers, the three
|
||||
/// ways to find a file, and a settlement recorded.
|
||||
#[test]
|
||||
fn annotations_and_searches() {
|
||||
let books = Books::with("p6-find", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("evidence");
|
||||
let entry = a_draft(&mut w);
|
||||
let text = b"Harbor Design LLC\nInvoice H-1042\nHosting, 42.00, 2026-03-01\n";
|
||||
let id = file(&mut w, entry, text, "harbor.txt", "text/plain");
|
||||
let r = w.call(
|
||||
"books.annotate_attachment",
|
||||
json!({"attachment_id": id, "fields": {
|
||||
"receipt_number": 7, "payee": "Harbor Design LLC", "amount": "42.00",
|
||||
"date": "2026-03-01", "instrument": "Check 1042",
|
||||
"source": "the owner's email", "reimbursable_to": "Avery Quill"
|
||||
}, "note": "read on 2026-03-02"}),
|
||||
);
|
||||
let out = answer_of(&r).unwrap();
|
||||
assert_eq!(out["fields"]["receipt_number"], 7);
|
||||
assert_eq!(out["fields"]["amount"], "42.00");
|
||||
assert_eq!(out["next_receipt_number"], 8);
|
||||
// Meta carries the annotation and the number.
|
||||
let r = w.call("books.get_receipt", json!({"attachment_id": id}));
|
||||
let out = answer_of(&r).unwrap();
|
||||
assert_eq!(out["receipt_number"], 7);
|
||||
assert_eq!(out["annotation"]["payee"], "Harbor Design LLC");
|
||||
// Found three ways.
|
||||
for args in [
|
||||
json!({"receipt_number": 7}),
|
||||
json!({"sha256": ledger_store::journal::sha256_hex(text)}),
|
||||
json!({"text": "harbor design"}),
|
||||
] {
|
||||
let r = w.call("books.find_attachment", args.clone());
|
||||
let out = answer_of(&r).unwrap();
|
||||
assert!(
|
||||
out["attachments"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|a| a["attachment_id"] == id),
|
||||
"{args}: {out}"
|
||||
);
|
||||
}
|
||||
// Exactly one selector.
|
||||
let r = w.call(
|
||||
"books.find_attachment",
|
||||
json!({"sha256": ledger_store::journal::sha256_hex(text), "text": "x"}),
|
||||
);
|
||||
assert!(answer_of(&r).is_err());
|
||||
// A settlement is recorded on the paying entry and read back.
|
||||
let pay = a_draft(&mut w);
|
||||
let r = w.call(
|
||||
"books.set_entry_settles",
|
||||
json!({"entry_id": pay, "settles": [entry]}),
|
||||
);
|
||||
let out = answer_of(&r).unwrap();
|
||||
assert_eq!(out["settles"], json!([entry]));
|
||||
let r = w.call(
|
||||
"books.set_entry_settles",
|
||||
json!({"entry_id": pay, "settles": [pay]}),
|
||||
);
|
||||
assert!(answer_of(&r).is_err(), "an entry settles not itself");
|
||||
let r = w.call("books.verify", json!({}));
|
||||
assert_eq!(answer_of(&r).unwrap()["status"], "ok");
|
||||
}
|
||||
|
||||
/// Plan P6 step 4 and rules.md B-44 item 5: consolidation never leaves an
|
||||
/// entry the receipt policy covers with no evidence.
|
||||
#[test]
|
||||
fn consolidation_never_removes_an_entrys_only_evidence() {
|
||||
let (_root, fixture) = legacy_duplicates("dupes-only", true);
|
||||
let books = Books::placed("p6-dupes-only", &fixture);
|
||||
let mut w = books.session("evidence");
|
||||
let before = books.journal();
|
||||
let r = w.call("books.consolidate_duplicates", json!({"confirmed": true}));
|
||||
let why = answer_of(&r).unwrap_err();
|
||||
assert!(why.contains("receipt_required"), "{why}");
|
||||
assert_eq!(books.journal(), before, "a refusal journals nothing");
|
||||
}
|
||||
|
||||
/// Plan P6 step 4: the kept file takes every subject the duplicates had;
|
||||
/// the others are withdrawn as duplicates of it, and nothing is deleted.
|
||||
#[test]
|
||||
fn consolidation_links_the_kept_file_to_every_subject() {
|
||||
let (_root, fixture) = legacy_duplicates("dupes-live", false);
|
||||
let books = Books::placed("p6-dupes-live", &fixture);
|
||||
let mut w = books.session("evidence");
|
||||
let sha = {
|
||||
let r = w.call("books.get_receipt", json!({"attachment_id": 2}));
|
||||
answer_of(&r).unwrap()["sha256"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
let r = w.call("books.consolidate_duplicates", json!({}));
|
||||
let preview = answer_of(&r).unwrap();
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert_eq!(preview["groups"][0]["keep"], 1);
|
||||
assert_eq!(preview["groups"][0]["withdraw"], json!([2]));
|
||||
let before = books.journal();
|
||||
assert_eq!(before, books.journal(), "a preview journals nothing");
|
||||
let r = w.call("books.consolidate_duplicates", json!({"confirmed": true}));
|
||||
let out = answer_of(&r).unwrap();
|
||||
assert_eq!(out["status"], "consolidated", "{out}");
|
||||
assert_eq!(out["withdrawn"], 1);
|
||||
// The second entry is still evidenced, by the kept file.
|
||||
let r = w.call("books.list_receipts", json!({"entry_id": 2}));
|
||||
let out = answer_of(&r).unwrap();
|
||||
assert_eq!(out["receipts"].as_array().unwrap().len(), 1, "{out}");
|
||||
assert_eq!(out["receipts"][0]["attachment_id"], 1);
|
||||
// The withdrawn one is found, marked, and names what it duplicates.
|
||||
let r = w.call("books.find_attachment", json!({"sha256": sha}));
|
||||
let out = answer_of(&r).unwrap();
|
||||
let found = out["attachments"].as_array().unwrap();
|
||||
assert_eq!(found.len(), 2, "{out}");
|
||||
let gone = found.iter().find(|a| a["attachment_id"] == 2).unwrap();
|
||||
assert_eq!(gone["withdrawn"], true);
|
||||
let r = w.call("books.verify", json!({}));
|
||||
assert_eq!(answer_of(&r).unwrap()["status"], "ok");
|
||||
}
|
||||
|
||||
/// Done when (1): every attachment of the `p2-served` fixture gives a
|
||||
/// default `books.get_receipt` answer under 8 KiB.
|
||||
#[test]
|
||||
fn every_p2_served_attachment_has_a_small_default_answer() {
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p2-served");
|
||||
let books = Books::placed("p6-p2-served", &fixture);
|
||||
let mut w = books.session("evidence");
|
||||
let mut seen = 0;
|
||||
for id in 1..=64u64 {
|
||||
let r = w.call("books.get_receipt", json!({"attachment_id": id}));
|
||||
let Ok(out) = answer_of(&r) else { continue };
|
||||
seen += 1;
|
||||
let size = serde_json::to_string(&out).unwrap().len();
|
||||
assert!(size < 8 * 1024, "attachment {id}: {size} bytes");
|
||||
assert_eq!(out["text_truncated"], false, "attachment {id}");
|
||||
assert!(out.get("file").is_none(), "attachment {id}");
|
||||
}
|
||||
assert!(seen >= 60, "the fixture's attachments: {seen}");
|
||||
}
|
||||
|
||||
/// Done when (4): the backfill reads an older book's files, one
|
||||
/// attachment at a time, and a second run journals nothing.
|
||||
#[test]
|
||||
fn the_backfill_reads_an_old_fixtures_files_once() {
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-store/tests/fixtures/old-books/p6_evidence");
|
||||
let books = Books::placed("p6-backfill", &fixture);
|
||||
// The fixture is from before P6: upgrade it (the owner's step), then
|
||||
// the backfill can journal extraction records.
|
||||
books.stop();
|
||||
{
|
||||
let mut s = ledger_store::Store::open(&books.dir()).unwrap();
|
||||
s.upgrade_format().unwrap();
|
||||
s.verify().unwrap();
|
||||
}
|
||||
books.start();
|
||||
let mut w = books.session("evidence");
|
||||
// Attachment 1 is a PDF (one page with text), 2 a text file, 3 a
|
||||
// Markdown document.
|
||||
for id in 1..=2 {
|
||||
let before = books.journal();
|
||||
let r = w.call("books.get_receipt", json!({"attachment_id": id}));
|
||||
let out = answer_of(&r).unwrap();
|
||||
assert_eq!(out["text_status"], "not_extracted", "{out}");
|
||||
let answer = books.home.admin(ledger_mcp::admin::Request::Extract {
|
||||
id: books.id.clone(),
|
||||
attachment_id: id,
|
||||
});
|
||||
match answer {
|
||||
ledger_mcp::admin::Response::Extracted { status, .. } => assert_eq!(status, "ok"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert_ne!(books.journal(), before, "the backfill journals a record");
|
||||
}
|
||||
// The PDF's text is readable now, and its pages render.
|
||||
let r = w.call("books.get_receipt", json!({"attachment_id": 1}));
|
||||
let out = answer_of(&r).unwrap();
|
||||
assert_eq!(out["text_status"], "ok", "{out}");
|
||||
assert_eq!(out["pages"], 1, "{out}");
|
||||
assert!(out["text"].as_str().unwrap().contains("Rent"), "{out}");
|
||||
// The command itself, on the Markdown document (attachment 3), and
|
||||
// again: a second run journals nothing. The test's own server is
|
||||
// stopped first: the command opens the books and closes them as the
|
||||
// host does.
|
||||
books.stop();
|
||||
let run = || {
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||
.args(["books", "extract", &books.id, "3", "--root"])
|
||||
.arg(books.home.root())
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("HOME", books.home.dir.path())
|
||||
.output()
|
||||
.unwrap()
|
||||
};
|
||||
let before = books.journal();
|
||||
let out = run();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).contains("Read attachment 3"),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_ne!(books.journal(), before, "the command journals a record");
|
||||
let after = books.journal();
|
||||
let out = run();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert_eq!(books.journal(), after, "an extract journals once");
|
||||
books.start();
|
||||
let mut w = books.session("evidence");
|
||||
let r = w.call("books.verify", json!({}));
|
||||
assert_eq!(answer_of(&r).unwrap()["status"], "ok");
|
||||
let r = w.call("books.get_receipt", json!({"attachment_id": 3}));
|
||||
assert_eq!(answer_of(&r).unwrap()["text_status"], "ok");
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 46 Software paid by member 9 2025-09-18
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 39 Design work 8 2025-08-08
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 42 Bank fee 8 2025-08-23
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 18 Consulting 4 2025-04-03
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 27 Bank fee 5 2025-05-23
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 44 Design work 9 2025-09-08
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 6 Software paid by member 1 2025-01-18
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 20 Supplies 4 2025-04-13
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 36 Software paid by member 7 2025-07-18
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 50 Supplies 10 2025-10-13
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 2 Capital in B 2025-01-02
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 41 Software paid by member 8 2025-08-18
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 47 Bank fee 9 2025-09-23
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 61 Software paid by member 12 2025-12-18
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 60 Supplies 12 2025-12-13
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 38 Consulting 8 2025-08-03
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 10 Supplies 2 2025-02-13
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 31 Software paid by member 6 2025-06-18
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 52 Bank fee 10 2025-10-23
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 34 Design work 7 2025-07-08
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 48 Consulting 10 2025-10-03
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 64 Insurance year 2025-02-01
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 9 Design work 2 2025-02-08
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 32 Bank fee 6 2025-06-23
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 15 Supplies 3 2025-03-13
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 14 Design work 3 2025-03-08
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 51 Software paid by member 10 2025-10-18
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 21 Software paid by member 4 2025-04-18
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 43 Consulting 9 2025-09-03
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 8 Consulting 2 2025-02-03
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 57 Bank fee 11 2025-11-23
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 53 Consulting 11 2025-11-03
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 23 Consulting 5 2025-05-03
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 17 Bank fee 3 2025-03-23
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 19 Design work 4 2025-04-08
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 7 Bank fee 1 2025-01-23
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 45 Supplies 9 2025-09-13
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 58 Consulting 12 2025-12-03
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 30 Supplies 6 2025-06-13
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 56 Software paid by member 11 2025-11-18
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 63 Laptop 2025-03-01
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 62 Bank fee 12 2025-12-23
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 12 Bank fee 2 2025-02-23
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 25 Supplies 5 2025-05-13
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 4 Design work 1 2025-01-08
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 49 Design work 10 2025-10-08
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 22 Bank fee 4 2025-04-23
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 3 Consulting 1 2025-01-03
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 5 Supplies 1 2025-01-13
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 28 Consulting 6 2025-06-03
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 11 Software paid by member 2 2025-02-18
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 1 Capital in A 2025-01-02
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 37 Bank fee 7 2025-07-23
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 33 Consulting 7 2025-07-03
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 29 Design work 6 2025-06-08
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 13 Consulting 3 2025-03-03
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 35 Supplies 7 2025-07-13
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 26 Software paid by member 5 2025-05-18
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 54 Design work 11 2025-11-08
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 16 Software paid by member 3 2025-03-18
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 55 Supplies 11 2025-11-13
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 24 Design work 5 2025-05-08
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 59 Design work 12 2025-12-08
|
||||
-1
@@ -1 +0,0 @@
|
||||
%PDF-1.4 fictional receipt 40 Supplies 8 2025-08-13
|
||||
@@ -1,470 +0,0 @@
|
||||
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-21T21:28:50Z","type":"store.created"}
|
||||
{"payload":{"features":[],"format":2},"prev":"6f671350b51dcf342c70a96d74fe2e7a51201bf076f8fdf9f56e91ae713b1b93","seq":2,"ts":"2026-10-21T21:28:50Z","type":"journal.format_set"}
|
||||
{"payload":{"changes":[{"key":"company.name","value":"Fernhill Labs LLC"},{"key":"company.entity_type","value":"multi_member_llc"},{"key":"company.fiscal_year_start","value":"01-01"}],"source":{"decided_by":"Avery Quill","decided_on":"2026-10-21"}},"prev":"0553dd22c23902d6585f2bbaed018c059a9aea58df7406d404bf4137d5ecede1","seq":3,"ts":"2026-10-21T21:28:50Z","type":"settings.set"}
|
||||
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"108d387a4869358c663077990d942b4ffb5644635236c93a8ad344a298a50227","seq":4,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Asset","code":"1300","currency":"USD","id":2,"name":"Prepaid","parent":null,"placeholder":false},"prev":"44a88cabb5d1cf52f7d20efd8faf2ffaac5b2bd65a54988899e27e05cc4deecf","seq":5,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Asset","code":"1500","currency":"USD","id":3,"name":"Equipment","parent":null,"placeholder":false},"prev":"e3a5983c198273610292fd01475cf9b94597fad49941dc143ff78c156226cc36","seq":6,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Asset","code":"1510","currency":"USD","id":4,"name":"Accumulated depreciation","parent":null,"placeholder":false},"prev":"7776e448d58efadfdb096e404dd3eec87310b250b25fc97630679346f6b4172f","seq":7,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":5,"name":"Accounts receivable","parent":null,"placeholder":false},"prev":"58ca33a2fb8230622c6df46c491accbbe82e52d8be43a49a96f599dee1c9460d","seq":8,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Liability","code":"2000","currency":"USD","id":6,"name":"Accounts payable","parent":null,"placeholder":false},"prev":"630e419ad2afbcb3171177430d8701e4931a571ac10f0d5f635c4f37e8bbd965","seq":9,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Liability","code":"2050","currency":"USD","id":7,"name":"Company card","parent":null,"placeholder":false},"prev":"dca477176282fc4b6f9adbf3ad5450198c24647eec5ece472127faa0d256f012","seq":10,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Liability","code":"2100","currency":"USD","id":8,"name":"Due to members","parent":null,"placeholder":false},"prev":"d635bc76135ad344c1b6151ecff322849af89e0ea159124704af70dfdeaadff2","seq":11,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":9,"name":"Capital A","parent":null,"placeholder":false},"prev":"ebe9968feff05860f2da8a2e3cd4bc07d7d3f0c3922f037e238fcedfc10f00d7","seq":12,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Equity","code":"3001","currency":"USD","id":10,"name":"Capital B","parent":null,"placeholder":false},"prev":"3ae241ef0c699d888c8c50f5bcf6b141ff10963cb4ed3880b6b02eec5268cbdc","seq":13,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Equity","code":"3900","currency":"USD","id":11,"name":"Retained earnings","parent":null,"placeholder":false},"prev":"6d13b74f8e9ef3b15f1ea75751fa8070ec0f83f073fc091be0c154a019f030c6","seq":14,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":12,"name":"Consulting revenue","parent":null,"placeholder":false},"prev":"a0db3c9413975175f75fe1c37e6831c4758d331f52fb9378863180162c5ca8ad","seq":15,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":13,"name":"Supplies","parent":null,"placeholder":false},"prev":"78a4ebd277a2f9e3ea76414a0bf8f7406cc3e30dbfa48754366752f940b15075","seq":16,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Expense","code":"6100","currency":"USD","id":14,"name":"Software","parent":null,"placeholder":false},"prev":"5b6f921b6731bddc951abd2912231e2282111b9ad2e1ddb12e108846cd396d44","seq":17,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Expense","code":"6200","currency":"USD","id":15,"name":"Depreciation","parent":null,"placeholder":false},"prev":"41b76a17cf130608e358df070a5917f86eeb3c229ba02a7e06322355a3bfaaea","seq":18,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Expense","code":"6300","currency":"USD","id":16,"name":"Insurance","parent":null,"placeholder":false},"prev":"2d13667fdebc469d0d59bee761e650325d7a402186dbb84890168adf8d741f7d","seq":19,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Expense","code":"6400","currency":"USD","id":17,"name":"Contract labor","parent":null,"placeholder":false},"prev":"819cfd940a1b72d4e4a6e3c9283579cc8c06cf5ff4339a28f88f2c16b24fe88f","seq":20,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
|
||||
{"payload":{"changes":[{"key":"company.members","value":[{"capital_account":"3000","name":"Avery Quill","share":"0.5"},{"capital_account":"3001","name":"Rowan Pike","share":"0.5"}]},{"key":"books.cash_accounts","value":["1000"]},{"key":"books.financing_accounts","value":["2100"]},{"key":"backup.git_remote","value":"file:///home/swaits/.cache/wanderbooks/p3-rev-data-e2e/p2home/remote.git"},{"key":"backup.folder","value":"/home/swaits/.cache/wanderbooks/p3-rev-data-e2e/p2home/usb"}],"source":{"decided_by":"Avery Quill","decided_on":"2026-10-21"}},"prev":"92acb98bfa80e1e722c6158bbe6e9ed4c8c2fc31ad1adb14b9f61d304043ca6d","seq":21,"ts":"2026-10-21T21:28:50Z","type":"settings.set"}
|
||||
{"payload":{"id":1,"name":"Avery Quill","roles":["member"]},"prev":"a182ca8fabcebddef6818143575d3749d0e8fc036baf6e312bd0a849f78436cf","seq":22,"ts":"2026-10-21T21:28:50Z","type":"party.added"}
|
||||
{"payload":{"id":2,"name":"Rowan Pike","roles":["member"]},"prev":"fa48b3d8f4372ee6ae38661251659ce4b4374f54fa51329e974a09ba16815448","seq":23,"ts":"2026-10-21T21:28:50Z","type":"party.added"}
|
||||
{"payload":{"id":1,"name":"Harbor Design LLC"},"prev":"596325a7614846296dd6d073baf2f543107b665c35328447459012c74855c461","seq":24,"ts":"2026-10-21T21:28:50Z","type":"vendor.added"}
|
||||
{"payload":{"id":2,"name":"Bigco Supply"},"prev":"c424a9910d582b5f0359b5b6705e7d96dc5bcb1914ced459a4276f27d0e36f01","seq":25,"ts":"2026-10-21T21:28:50Z","type":"vendor.added"}
|
||||
{"payload":{"id":1,"name":"Cedar Client Co"},"prev":"46c8d1500320b40b9666a5a42d931822f05cf054165d410c5cf5732250bed40f","seq":26,"ts":"2026-10-21T21:28:51Z","type":"customer.added"}
|
||||
{"payload":{"facts":{"last4":"6780","purged":null,"tin_type":"ein","vault_record":"69788eb06e09d55ca50e3ec73dd403baa06fc5818ce36d34547aa8de3563df43"},"id":3},"prev":"80907e1ddb276655db6cdea50222daa46b589455b4a4d5394bc53d62dc48fcd0","seq":27,"ts":"2026-10-21T21:28:51Z","type":"party.tin_set"}
|
||||
{"payload":{"facts":{"last4":"3456","purged":null,"tin_type":"ssn","vault_record":"18a2ba28ac3346b30598fb86d645d75b756bc43116cf0c5a78b3ffbd168e537a"},"id":1},"prev":"6be2d55137906b67c54034f40be01ad0d7736c7057ff4b4285a2f0494e920a63","seq":28,"ts":"2026-10-21T21:28:51Z","type":"party.tin_set"}
|
||||
{"payload":{"form_1099":"nec","tin_on_file":true,"vendor_id":1},"prev":"1607945e13e8fa9b142a7a089d5679a544384d5e44e051042690bac65fdc4f7f","seq":29,"ts":"2026-10-21T21:28:51Z","type":"vendor.1099_set"}
|
||||
{"payload":{"form_1099":null,"tin_on_file":false,"vendor_id":2},"prev":"8d815aad8481d577c28fd177765464119020cd3223240c3d8a9449eca05763e8","seq":30,"ts":"2026-10-21T21:28:51Z","type":"vendor.1099_set"}
|
||||
{"payload":{"date":"2025-01-02","description":"Capital in A","id":1},"prev":"e21b02f65b2d01347d2280a81d5c684947acf795a902bc6329c63b48fb636468","seq":31,"ts":"2026-10-21T21:28:51Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":1,"party_id":1},"prev":"4ada99e48cccb19f70a89f57cc2fe28c9d8a38a7b7c204b2c64c8511304e4dc0","seq":32,"ts":"2026-10-21T21:28:51Z","type":"entry.party_set"}
|
||||
{"payload":{"account":1,"amount":"5000.00","currency":"USD","id":1,"side":"debit"},"prev":"89bd45cc0937030d7d7cfb81d816d341b000bfb020d7bfda50b8b84f89536675","seq":33,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
|
||||
{"payload":{"account":9,"amount":"5000.00","currency":"USD","id":1,"side":"credit"},"prev":"2dd8bcd4901d1c419d862293a52c42a42c4e77d415df5f623db0d4b4a0d4e484","seq":34,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":1,"blob_sha":"aeb484e66061b3e49eb809f0ddf792afdee2da15cb69885b91ae8c12a4a1e59b","category":"receipts","description":null,"entry_id":1,"file_name":"r1.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"441c5859752bfa9639bef24b417d2b43a7c99d5a755e4820fd987664e3b93910","seq":35,"ts":"2026-10-21T21:28:51Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-01-02","description":"Capital in A","id":1,"lines":[{"account":1,"amount":"5000.00","currency":"USD","side":"debit"},{"account":9,"amount":"5000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"dc86cc21e1409de4aa1fe4b966d96e7de7dbb065eaad7852d6782d02d2f8310c","seq":36,"ts":"2026-10-21T21:28:51Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-01-02","description":"Capital in B","id":2},"prev":"45e788b2c6173364749d188be75732039813e67ea27ce66d2203cb10906e1832","seq":37,"ts":"2026-10-21T21:28:51Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":2,"party_id":2},"prev":"78bf42bcd02c0fa6e5f028baa69f13b3490be06db837734b944a2c63f3acf4c9","seq":38,"ts":"2026-10-21T21:28:51Z","type":"entry.party_set"}
|
||||
{"payload":{"account":1,"amount":"5000.00","currency":"USD","id":2,"side":"debit"},"prev":"5d56e32e035b1305e9aa7bcef55f2fcaf6958f25869c99b958d1f8e82a4f7520","seq":39,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
|
||||
{"payload":{"account":10,"amount":"5000.00","currency":"USD","id":2,"side":"credit"},"prev":"8d722949f628f0e20e87aaa8b024cbf9987e773b4f7113a37a0ba17d4386d6d1","seq":40,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":2,"blob_sha":"23191deb4999b3f1726e6d382a6c0d020a3eb9ffb2e4f06e6c4208120a2b1dc7","category":"receipts","description":null,"entry_id":2,"file_name":"r2.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"7298f2f2b297b9025d4203a7a07dde44fb99ae473d5d7f0fbab1967a585a133b","seq":41,"ts":"2026-10-21T21:28:51Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-01-02","description":"Capital in B","id":2,"lines":[{"account":1,"amount":"5000.00","currency":"USD","side":"debit"},{"account":10,"amount":"5000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"f1b98f8e16280ce39da64198a583957caa67585c96db16a30d5dc697c1549457","seq":42,"ts":"2026-10-21T21:28:51Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-01-03","description":"Consulting 1","id":3},"prev":"6816748843a8a19a8327aa5756c751706dee3f93e41a5c13816e5642072f8896","seq":43,"ts":"2026-10-21T21:28:51Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":3,"party_id":5},"prev":"2e9fb36852fca6384a4eed6289e66659453943c0d7c2988d3cdae83abb3352ce","seq":44,"ts":"2026-10-21T21:28:51Z","type":"entry.party_set"}
|
||||
{"payload":{"account":1,"amount":"425.45","currency":"USD","id":3,"side":"debit"},"prev":"a55a24cf00fcfd9097f868cfaa432a4b1d274ffc4c777d8b460e01a71a714aac","seq":45,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
|
||||
{"payload":{"account":12,"amount":"425.45","currency":"USD","id":3,"side":"credit"},"prev":"d5fe8b6df89d5df23335d2292cac76e3200d3e96020a3be40cb819ce71565e95","seq":46,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":3,"blob_sha":"a1fd86e4db54256d3cbcc42c003499e77bea22309d3ca22936c9a3119f951eb8","category":"receipts","description":null,"entry_id":3,"file_name":"r3.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"b0f2ab24fac7904b1c1cd1f98d8043fe94f2300259476149fabdfe728a31f8b3","seq":47,"ts":"2026-10-21T21:28:51Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-01-03","description":"Consulting 1","id":3,"lines":[{"account":1,"amount":"425.45","currency":"USD","side":"debit"},{"account":12,"amount":"425.45","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"9299ff9a6d7d14512b050e9e37bbf6fd0880c8bd4211f0f1c2579236e9a585b0","seq":48,"ts":"2026-10-21T21:28:51Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-01-08","description":"Design work 1","id":4},"prev":"dabd38384f06af507b8b6440966c7591ec779586137a07d0af17dc49288bf3a1","seq":49,"ts":"2026-10-21T21:28:51Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":4,"party_id":3},"prev":"741fa40bc0e48646e50d6223d5051bf77d472834f462464f57e8560365a21904","seq":50,"ts":"2026-10-21T21:28:51Z","type":"entry.party_set"}
|
||||
{"payload":{"account":17,"amount":"198.72","currency":"USD","id":4,"side":"debit"},"prev":"3d1fc1b51c92da1e773b9c45f83102ca73b79bdb29b39346ff298e50da7f83cf","seq":51,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"198.72","currency":"USD","id":4,"side":"credit"},"prev":"a5e24ac9d61aa1c8f077b1dc2afa4d54d2193035c61d6a5afb909ee0c3d09e84","seq":52,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":4,"blob_sha":"967db8ecef54a42031f76d14ef0493b998d08c941bf8a739edf1237f903d4649","category":"receipts","description":null,"entry_id":4,"file_name":"r4.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"5dd3bf41b20fa2d306e946df90d519ec2e187dbc22d3f007206fee08fce75c94","seq":53,"ts":"2026-10-21T21:28:51Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-01-08","description":"Design work 1","id":4,"lines":[{"account":17,"amount":"198.72","currency":"USD","side":"debit"},{"account":1,"amount":"198.72","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"052111cbc65ecee4c26cd5b628101afbe96dc7cdcfd8e6401515f4f5435cf242","seq":54,"ts":"2026-10-21T21:28:51Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-01-13","description":"Supplies 1","id":5},"prev":"edd15c9fc1945f0b8cce5cd7a595433830fd1eef2788b6b9b0f09e7e2e0b9555","seq":55,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":5,"party_id":4},"prev":"314ccb3da1e29adfde995b4aa48ba4de31ec4bdb5bb905abaa4307017865b6f7","seq":56,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
|
||||
{"payload":{"account":13,"amount":"518.50","currency":"USD","id":5,"side":"debit"},"prev":"329b80dad0d3ddfbf70b48903836835e85b908ec9374fd0843f04b2216dddfdb","seq":57,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
|
||||
{"payload":{"account":7,"amount":"518.50","currency":"USD","id":5,"side":"credit"},"prev":"693c0aa1c33cb0c1bfec75eca43bf0ce7fe078110dc8387b9e8daee72246c698","seq":58,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":5,"blob_sha":"a38f2f89b4c09962c99eca9b842a2382f2bda3726357af210626543cc52f1e7c","category":"receipts","description":null,"entry_id":5,"file_name":"r5.pdf","mime_type":"application/pdf","size_bytes":51},"prev":"794a8c3d0292339a0b8cacd6fa57e14dad135466963c2ed994341edac2f4dccd","seq":59,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-01-13","description":"Supplies 1","id":5,"lines":[{"account":13,"amount":"518.50","currency":"USD","side":"debit"},{"account":7,"amount":"518.50","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b66e18803ac268c5f2ddd31f076ec3198a4d29375619a8b40a2d7b0edb5992d3","seq":60,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-01-18","description":"Software paid by member 1","id":6},"prev":"d52c34b2f167deb3492e8a7d2e2b50ed8a592e47c5b7a8b1f7cf41d263b15b10","seq":61,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":6,"party_id":1},"prev":"12dd3a4a341d12ec13b2735e91081655de1d76c45b350cbdb1b25e0743aab0e8","seq":62,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
|
||||
{"payload":{"account":14,"amount":"854.19","currency":"USD","id":6,"side":"debit"},"prev":"58a015bb07e9ce1d353eef832298587ffe4d0ef5cfd70b80b25888137ae4e5f4","seq":63,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
|
||||
{"payload":{"account":8,"amount":"854.19","currency":"USD","id":6,"side":"credit"},"prev":"e58d9f1c7e5b0533a7a82455b0cb7a8d60d7eb1ce87c9b03ecf1695517d7c63f","seq":64,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":6,"blob_sha":"17ecf1539551d918be6e6ef9f3decc93c3e093a4985b1153f08d2c18da545b7b","category":"receipts","description":null,"entry_id":6,"file_name":"r6.pdf","mime_type":"application/pdf","size_bytes":66},"prev":"21934f4937d4bf5ccdaca3c967d8ae75d9f03379a9afbe158ffa7f7d12a0a4e0","seq":65,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-01-18","description":"Software paid by member 1","id":6,"lines":[{"account":14,"amount":"854.19","currency":"USD","side":"debit"},{"account":8,"amount":"854.19","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"51a777f27ddcf09ad862b622bd53e185907af1338799a4d06cb412b3845c8deb","seq":66,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-01-23","description":"Bank fee 1","id":7},"prev":"197bde02ee7ed2a3a12a378984865819548cdb778b6a33c57dac74552f5522c5","seq":67,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":7,"reason":"bank fee"},"prev":"e50d6c5f0b1c2c497dc2f37dd889365e9fc2d7b0a00a35a89c66c9d39b332c3e","seq":68,"ts":"2026-10-21T21:28:52Z","type":"entry.no_party"}
|
||||
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":7,"side":"debit"},"prev":"c95c400f054caae9bd95457abb2f534d6ec693e8a826dddc3136d4b25ba37851","seq":69,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":7,"side":"credit"},"prev":"9e6be2d5eea5fbc795e421c3e4f35275ee5d1e8a68d9d18e99eb1492ffac343f","seq":70,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":7,"blob_sha":"7d85565514a409a2d8bbacdfc2c68a2260ad0e311328cd0bda91800600098ed4","category":"receipts","description":null,"entry_id":7,"file_name":"r7.pdf","mime_type":"application/pdf","size_bytes":51},"prev":"ce4ff33af6791a860889ff147bb7725b3e256925f421a9d98d29cd105771dc61","seq":71,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-01-23","description":"Bank fee 1","id":7,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4416f011c27bcc9dafb26d68f4a989df6973c49b86aa396277c990994a29aa19","seq":72,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-02-03","description":"Consulting 2","id":8},"prev":"86f329829a794b9337f0a756d6dcc8fab39ba4ae96fc3d30201693a39ba050c4","seq":73,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":8,"party_id":5},"prev":"e1ce616977d845dab6738b3753469116c3f773a510389b84fff4f7879a223283","seq":74,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
|
||||
{"payload":{"account":1,"amount":"95.94","currency":"USD","id":8,"side":"debit"},"prev":"8e54df62ea15c54668a59912517daf3655c0d095ddc8ef7d64b2fd13dcef9892","seq":75,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
|
||||
{"payload":{"account":12,"amount":"95.94","currency":"USD","id":8,"side":"credit"},"prev":"245ec39fe29eb8be7ca72c83a0dc45f88bb58dbc0a75a7aa2e8cf0de973697ed","seq":76,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":8,"blob_sha":"57d707ac4c8f4987ad0de50f710d25d20729d276e5bb57963191e07c29c59b7a","category":"receipts","description":null,"entry_id":8,"file_name":"r8.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"a4d90d26d8c94e3d6e8c4861e8b78ad02aca3305fbd1ea4ff3ccef611f9851a4","seq":77,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-02-03","description":"Consulting 2","id":8,"lines":[{"account":1,"amount":"95.94","currency":"USD","side":"debit"},{"account":12,"amount":"95.94","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"aa51bf5a3c0b3b357a3c9b27619f538c5efe9cf1415c402279b23f4dc930133e","seq":78,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-02-08","description":"Design work 2","id":9},"prev":"515e776c06c84b658bd6775a915942c25c35d5b5dbc85085647f17bcb8bfe14f","seq":79,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":9,"party_id":3},"prev":"01e212769b1b8a6ff8a60bb62f2315b651a888d095cc91b7e9328743901dc71f","seq":80,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
|
||||
{"payload":{"account":17,"amount":"703.39","currency":"USD","id":9,"side":"debit"},"prev":"7ba725f7b5db6995c5388ffe70ade057eb195cbefa6f849a7dd0b2657ed2ab26","seq":81,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"703.39","currency":"USD","id":9,"side":"credit"},"prev":"99228fcdcfc3dfccfdf6692ca4474c05ecd9b479ad6dbf1dab58c7b8b58e1b35","seq":82,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":9,"blob_sha":"44ecd22cf54e2cfe9331fb015aa011affd23aad6788939c86a85de028a34df14","category":"receipts","description":null,"entry_id":9,"file_name":"r9.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"6d944d78bb57b1556b6b8e07b5f7f2a3286e408efad2ff116b28f1e48514fe29","seq":83,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-02-08","description":"Design work 2","id":9,"lines":[{"account":17,"amount":"703.39","currency":"USD","side":"debit"},{"account":1,"amount":"703.39","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8ecdbd55959498b2ac506e96ce3cd31f0c4bd715bf78decc97fb300b6bc3d9fb","seq":84,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-02-13","description":"Supplies 2","id":10},"prev":"eb4c97113391df5c0a12a8ad1b745b7bb40b528c87dae70b235aa873034d46b6","seq":85,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":10,"party_id":4},"prev":"0ff6a2037bfc1949862708059d124a80c1fd8863a7822593ecd51bcc8ff0ec10","seq":86,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
|
||||
{"payload":{"account":13,"amount":"124.37","currency":"USD","id":10,"side":"debit"},"prev":"4f596d265ad21904214fe768fa6d813b5b91684bb1f57e21e68a7a2aac4ea597","seq":87,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
|
||||
{"payload":{"account":7,"amount":"124.37","currency":"USD","id":10,"side":"credit"},"prev":"c21eb19cd2ac7da02f3e563c2e43470cf6243d0526bac2c31051990f6a1a7cc7","seq":88,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":10,"blob_sha":"36ffc77eeb9f51878977e69d554148bec710772fcdac8a85291d1cc6d85fc1a5","category":"receipts","description":null,"entry_id":10,"file_name":"r10.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"45bedecd2fd71a2d2f6e28c04ebe9b07bd69e90a19551f15ef589229c23c92e5","seq":89,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-02-13","description":"Supplies 2","id":10,"lines":[{"account":13,"amount":"124.37","currency":"USD","side":"debit"},{"account":7,"amount":"124.37","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c73b6e6219916b20250550d10023467b2608ed6a871294e5ffd19f81589f6638","seq":90,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-02-18","description":"Software paid by member 2","id":11},"prev":"3bcea4d211bccb6c3f965b63ed9926e0a2ff8e03e524671e5d16bccbf8c7f2b2","seq":91,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":11,"party_id":1},"prev":"593b7a10014a3aae4db791dc88f01059de5dba30e0c3d8d81d8981919426fad0","seq":92,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
|
||||
{"payload":{"account":14,"amount":"480.31","currency":"USD","id":11,"side":"debit"},"prev":"0d0c93ffcf4737d6b2aed0f6ac106b168731acf3ac9c8dfe1ebe58a07b500ee4","seq":93,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
|
||||
{"payload":{"account":8,"amount":"480.31","currency":"USD","id":11,"side":"credit"},"prev":"c62930589fd64363d19044508091db6f1a7a31bc2d6229392d293175c6dd43b6","seq":94,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":11,"blob_sha":"adb51e4f7d428927236d250e88465a36f56a842d7878a62222fe2bd29f4d73fd","category":"receipts","description":null,"entry_id":11,"file_name":"r11.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"9e909f641d42f42854e4d2569d10aca5210336d4a266416fab450c2b9ad2d328","seq":95,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-02-18","description":"Software paid by member 2","id":11,"lines":[{"account":14,"amount":"480.31","currency":"USD","side":"debit"},{"account":8,"amount":"480.31","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"e3ea21147baef2177949040be10c4a4a2ce12f82c1e1f1c9e7ed4a0fae93c655","seq":96,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-02-23","description":"Bank fee 2","id":12},"prev":"1f1994c6bb186155ff6c1e452247eff373ce903fcb04bd0a2c670160c5a639f7","seq":97,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":12,"reason":"bank fee"},"prev":"5b275e93057427c467c4790c51432a25180f6b02ec28db4fd3ea516bbccff9d8","seq":98,"ts":"2026-10-21T21:28:53Z","type":"entry.no_party"}
|
||||
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":12,"side":"debit"},"prev":"3b0e4b89a80cc27b5d471c5db255b01ea8941f335d47d0355361f4162828c806","seq":99,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":12,"side":"credit"},"prev":"4847abf2d56ce8584c9f9b9ff581d8fdbe76b4f7931ac4f03a1a059c224c187d","seq":100,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":12,"blob_sha":"8dc13ceaede8f28843e0cce29d7361079db7db662dddcad5363206973983a5ac","category":"receipts","description":null,"entry_id":12,"file_name":"r12.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"8fbd785163460476a7f249b8a186388ace63db4126ce98b251fcb85f8446b6c6","seq":101,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-02-23","description":"Bank fee 2","id":12,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"741e73614fc19a61c148919eadaac97feccfe63e10dd033a37e1a4278749dff2","seq":102,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-03-03","description":"Consulting 3","id":13},"prev":"bcded4e6928cb1d98cfecad9418c88868173a31715d9c7258fd1ac43f47eeb50","seq":103,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":13,"party_id":5},"prev":"4ae7dbfad307311ffd842c853b5c38e490f1cf90337feadf063a1f8a11094d01","seq":104,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
|
||||
{"payload":{"account":1,"amount":"77.02","currency":"USD","id":13,"side":"debit"},"prev":"4a5a7d56822cefdfa32825eeea6649f8d5fe417bd6af61bf25572c3321c4dc8a","seq":105,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
|
||||
{"payload":{"account":12,"amount":"77.02","currency":"USD","id":13,"side":"credit"},"prev":"f480211077e50b34ed8d410b94a2a896e3aa41e5e976ac23d026956c925f5846","seq":106,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":13,"blob_sha":"bdd80cf4f88b7072fe957a747868541c636fd110390c977807cbc27ece7fc3f5","category":"receipts","description":null,"entry_id":13,"file_name":"r13.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"37e9bdee99d55fbca6ee1ed3a5c7b5930a07f63726b25e01e4f5d3f8f6332d39","seq":107,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-03-03","description":"Consulting 3","id":13,"lines":[{"account":1,"amount":"77.02","currency":"USD","side":"debit"},{"account":12,"amount":"77.02","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"75b43dc62ba4de561993dac61fa66b6cb36f25db4ee76f9c44b37186b20799dc","seq":108,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-03-08","description":"Design work 3","id":14},"prev":"6787e29c68214763e273f8c51a03f6eb7e9e8325beab276dcf057a5d2e1c5257","seq":109,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":14,"party_id":3},"prev":"fd970f288cd034209f01fe016b5c14d6fdb63059b7db0fcf90a064d1274f0b41","seq":110,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
|
||||
{"payload":{"account":17,"amount":"666.10","currency":"USD","id":14,"side":"debit"},"prev":"361e3e012906190157032b7f1e266f4ffbb3a44c3220dccbd42bca8fc8acf43e","seq":111,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"666.10","currency":"USD","id":14,"side":"credit"},"prev":"31d5b169b81aa9feb3df1c3b9f736601e1c82de28be4c54723fc0d50cb3f68d8","seq":112,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":14,"blob_sha":"53d0a291ee3782d571157ef0c84f5862c9fe3ea33964edc3c4bd6d5a966d91e5","category":"receipts","description":null,"entry_id":14,"file_name":"r14.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"48d9582f794a39423947fe63c4bc1f954413f8aa0d8b0a20d7e7ca1a7945dd2c","seq":113,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-03-08","description":"Design work 3","id":14,"lines":[{"account":17,"amount":"666.10","currency":"USD","side":"debit"},{"account":1,"amount":"666.10","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"65233a70ad796f6819b5f3b142db86baee2f534df196c647ed9d64524815e651","seq":114,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-03-13","description":"Supplies 3","id":15},"prev":"fef2b0e474a659625d01ae2eb4546bd2e320331e47a5493c07a453f7e7f2e60f","seq":115,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":15,"party_id":4},"prev":"d232fa4453f161809c08271b5f2da9ef6abe5a88658309e6eb874630cd8d815d","seq":116,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
|
||||
{"payload":{"account":13,"amount":"282.40","currency":"USD","id":15,"side":"debit"},"prev":"329b0488d4a9da17b537ca08c02848943e31cd33634cb15ae2ded821ca026936","seq":117,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
|
||||
{"payload":{"account":7,"amount":"282.40","currency":"USD","id":15,"side":"credit"},"prev":"c7951f1954727979a4b4f22a21b3582c889324a17257a16e30dc5ca51bf1fb2f","seq":118,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":15,"blob_sha":"50362975c484841d345e5b431ff1de0c91a38a3a609a10b446e92214b12b89c7","category":"receipts","description":null,"entry_id":15,"file_name":"r15.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"3c47ea24e1c379899e0959dedbea581010b89520066efda366a503c42b9ea429","seq":119,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-03-13","description":"Supplies 3","id":15,"lines":[{"account":13,"amount":"282.40","currency":"USD","side":"debit"},{"account":7,"amount":"282.40","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5be04d392a8bb37c8f46eaf40b9475596780b133c089fbf2c55611aa514e881b","seq":120,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-03-18","description":"Software paid by member 3","id":16},"prev":"e144d0ead638ccef5dbb3515ce9fcf44847ff1572b51f4e0333db8d6ed9b742b","seq":121,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":16,"party_id":1},"prev":"13fc8211de63981e31fe6636cb8cbfd133c330f63233fc72378425677c1463fb","seq":122,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
|
||||
{"payload":{"account":14,"amount":"50.14","currency":"USD","id":16,"side":"debit"},"prev":"e1a1ded0ba49a9db6cde6792e1b714366a00671549adf4324a4598b1d74a5c55","seq":123,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
|
||||
{"payload":{"account":8,"amount":"50.14","currency":"USD","id":16,"side":"credit"},"prev":"c6e25c989a0b701eb022c2ba61ca3fb87889035ec3a41504f1f0c05202a4e882","seq":124,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":16,"blob_sha":"d362d36ef90e3eaa91a0c8dda829b78c6af7bbaf6d780a4c6ccb2b5fc3847677","category":"receipts","description":null,"entry_id":16,"file_name":"r16.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"8d896806acde9e0d97d7858b61e300f6952744ddb6d35605ed64dac5254e140a","seq":125,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-03-18","description":"Software paid by member 3","id":16,"lines":[{"account":14,"amount":"50.14","currency":"USD","side":"debit"},{"account":8,"amount":"50.14","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"3e65cb179dde4a39b10434b8c68b4de5afe0e31de2427367b37b72472d852e61","seq":126,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-03-23","description":"Bank fee 3","id":17},"prev":"01d5f1f87c958891490e89de23951f85d0e60ad048d9057be9c843ddf2a8624d","seq":127,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":17,"reason":"bank fee"},"prev":"4d765897ebb1b5caa54ccc9da98db76071dba2d990a3ad508d3d35c10e4842de","seq":128,"ts":"2026-10-21T21:28:54Z","type":"entry.no_party"}
|
||||
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":17,"side":"debit"},"prev":"d900fa39a22dc0af4519b5b24d271a874058d348a19ab91edc7c5b978bd34774","seq":129,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":17,"side":"credit"},"prev":"54c9aa19896f483d6611034b2fabd50368127f65b3f5b14fa7b859d5afad739f","seq":130,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":17,"blob_sha":"697a5b6d8f74f341972c78207e471c30d07ced60ef8a00827be79dadd9e8948b","category":"receipts","description":null,"entry_id":17,"file_name":"r17.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"898692b09d5bcce584af16f0e0dc2897481796bc7a607558c3e71da57fdef6a6","seq":131,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-03-23","description":"Bank fee 3","id":17,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"117202b822b0090e7c45baabdcbf9cace96d6d64760776fcef1d71e815b2c8bc","seq":132,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-04-03","description":"Consulting 4","id":18},"prev":"98b756a3cf8dc05c724889a67a011f587dd0f528547fb085ee40d23fe92c9b83","seq":133,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":18,"party_id":5},"prev":"b460b4e56a6c09995d0765b0f728acd5252808587aaab153af0980ed9eb29bdd","seq":134,"ts":"2026-10-21T21:28:54Z","type":"entry.party_set"}
|
||||
{"payload":{"account":1,"amount":"569.38","currency":"USD","id":18,"side":"debit"},"prev":"f25e935d9606f16026377498d191ee8fb6fc84e06e7b1acbf308b46d4465ddf0","seq":135,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
|
||||
{"payload":{"account":12,"amount":"569.38","currency":"USD","id":18,"side":"credit"},"prev":"405ea8804c75f2a21bce3abe2c5ae46c74a833c04c32df66fa05976586757e7e","seq":136,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":18,"blob_sha":"135feedb9b3f3931a9af46640e942d497cd2c7eedd46cc49c31116107d63e1af","category":"receipts","description":null,"entry_id":18,"file_name":"r18.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"10aa42e5c28659af0088f3f2bdfb76f9cf16f7f96697409c43ee35e79e0eb31e","seq":137,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-04-03","description":"Consulting 4","id":18,"lines":[{"account":1,"amount":"569.38","currency":"USD","side":"debit"},{"account":12,"amount":"569.38","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"9cd8f1fc5f278e051552cac6a067bfdea4462c2a16998c87d4ee9fb229f5a9b1","seq":138,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-04-08","description":"Design work 4","id":19},"prev":"81b61a711735d100665af0f90f66427fa58b12c2609a4366c92eb595aa54bfe7","seq":139,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":19,"party_id":3},"prev":"0b06a1d6e8db2b3d1541c41653104843e37a5e9c9403869f850b3b23ce4ff318","seq":140,"ts":"2026-10-21T21:28:54Z","type":"entry.party_set"}
|
||||
{"payload":{"account":17,"amount":"549.10","currency":"USD","id":19,"side":"debit"},"prev":"de1602a62459badaca7c86d11a80fb8fb077efbb4c0e0a96aa5f57fa4d46e69b","seq":141,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"549.10","currency":"USD","id":19,"side":"credit"},"prev":"c4fd94a05cec6453bda42d40960b8ab1648faa17a73f85298aff2d02f9d674cd","seq":142,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":19,"blob_sha":"7c621c63ab0b77d026b903714442e0cb16d786386abc15873937a05304dad04a","category":"receipts","description":null,"entry_id":19,"file_name":"r19.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"78d0f1a293a59f415181156b936e46d37a2d9d445f5692eb308b615990fbcb27","seq":143,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-04-08","description":"Design work 4","id":19,"lines":[{"account":17,"amount":"549.10","currency":"USD","side":"debit"},{"account":1,"amount":"549.10","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"155d95bc1fe3bb903e700a0ee45a1a78be072e67df5a3a83ecbda62413526b54","seq":144,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-04-13","description":"Supplies 4","id":20},"prev":"bebb19d5b7bb2ff595ec01c3fd236202e0317d9173b54cef1d3539ec6efbae54","seq":145,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":20,"party_id":4},"prev":"c5a504b1fab56ae93e77357100fe56af642434409293f5bacaa281e19befc344","seq":146,"ts":"2026-10-21T21:28:54Z","type":"entry.party_set"}
|
||||
{"payload":{"account":13,"amount":"92.56","currency":"USD","id":20,"side":"debit"},"prev":"542ee36d9463ffa01d276fcc5479b656a8617393d1c61f53fecf78ccb2632da8","seq":147,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
|
||||
{"payload":{"account":7,"amount":"92.56","currency":"USD","id":20,"side":"credit"},"prev":"b03b1f3cb6cb871a4da589887226b65415677c75b92ba0d0e8aace71bb0a7940","seq":148,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":20,"blob_sha":"1ccdb1535622cb9fd70388cead476435d5a0bf48cb0f5486569d072b79702e3a","category":"receipts","description":null,"entry_id":20,"file_name":"r20.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"b0561a03b062d4b99c583d3bde744eccb66df3b8bbfff0ffd00a8151a92563ca","seq":149,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-04-13","description":"Supplies 4","id":20,"lines":[{"account":13,"amount":"92.56","currency":"USD","side":"debit"},{"account":7,"amount":"92.56","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"17df72a2ff4d8ad8e7b53ebcae37547075dd6e867974a331b90de12af50d9678","seq":150,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-04-18","description":"Software paid by member 4","id":21},"prev":"aa98755989b66c1168da2ebf9ef2f667bf17dbfb090b0feb228abd47ada4145d","seq":151,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":21,"party_id":1},"prev":"08c220fb3aa840582e44d05113bcf17ef393044a1ee9a13d8bd1628efc543454","seq":152,"ts":"2026-10-21T21:28:54Z","type":"entry.party_set"}
|
||||
{"payload":{"account":14,"amount":"316.44","currency":"USD","id":21,"side":"debit"},"prev":"b7b2e135f947b02ce7b13bd5397fe0ad6ee6c5953365384b3c975024f0a35c1e","seq":153,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
|
||||
{"payload":{"account":8,"amount":"316.44","currency":"USD","id":21,"side":"credit"},"prev":"5b78e258d4718d45fb43f8295d70ee1b5e29f97a1404e53287355ddbbdf63596","seq":154,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":21,"blob_sha":"5484fb84ff7718440c626ab6f5ea214c193f00a84079b51d811df2cdf35af58c","category":"receipts","description":null,"entry_id":21,"file_name":"r21.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"615867565e9b02e72a0e8d5180e5d43a0c5a4c0fdd8e8d1f4d98535c14c92f3e","seq":155,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-04-18","description":"Software paid by member 4","id":21,"lines":[{"account":14,"amount":"316.44","currency":"USD","side":"debit"},{"account":8,"amount":"316.44","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"61daf7a1e6e75e7f3e1d4adb1b69e07997ab36225fd1add8f7a238af7adfc8d3","seq":156,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-04-23","description":"Bank fee 4","id":22},"prev":"210ff2e8a7172d687a06e850e0421355f0f142eae1844e3b841c19a83866e492","seq":157,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":22,"reason":"bank fee"},"prev":"c31826899ed4c4775b47f6d0fcb0877caa7d8e52eac8dc1e82c5dff809789713","seq":158,"ts":"2026-10-21T21:28:54Z","type":"entry.no_party"}
|
||||
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":22,"side":"debit"},"prev":"e1a92cfbcbee238dc149050bb0116a091bd9a49730eef6b32991b4c462012752","seq":159,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":22,"side":"credit"},"prev":"eaf21d71f261786cb6aa58a92577bc8c282bb889255593560d29670947e6d5a3","seq":160,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":22,"blob_sha":"a1cfdbd7df3bd87d040f3f88ba9fd852d57c9e308a8fd8884b5872f5ab65d955","category":"receipts","description":null,"entry_id":22,"file_name":"r22.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"df36dae34a74a00382ef386d1275e9cfc125dd24d0e8ebccf23628ccf9063b5d","seq":161,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-04-23","description":"Bank fee 4","id":22,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4aea0b2d4309babbd6be12d741a242b394b86b74b67c0cc124f148318710c3ff","seq":162,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-05-03","description":"Consulting 5","id":23},"prev":"6ef10f9bad3e4e57d67e5af36eeeca16cd685409a31578ab3e145472c91efbe9","seq":163,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":23,"party_id":5},"prev":"0185d788c816eac9251c2011ab63ea1314e2053a29f914540e84e0c1f9a5b51e","seq":164,"ts":"2026-10-21T21:28:55Z","type":"entry.party_set"}
|
||||
{"payload":{"account":1,"amount":"723.26","currency":"USD","id":23,"side":"debit"},"prev":"2bed4193fe6f8e6a79b6f3a26d257520c203d25d21f88700b2b795eb38cf2640","seq":165,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
|
||||
{"payload":{"account":12,"amount":"723.26","currency":"USD","id":23,"side":"credit"},"prev":"f1d76570adf1ca7b0cd93e478ae8394b7639e545295b0a459bd1cf1426877245","seq":166,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":23,"blob_sha":"603f71cd317e709741b1c58e20690f48592dfc1e13c91b169d5207e8b3db45ca","category":"receipts","description":null,"entry_id":23,"file_name":"r23.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"9584359be3c76055e35579fe3cd88b8d0c0b5ad93deda34f8ea15bda937739b7","seq":167,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-05-03","description":"Consulting 5","id":23,"lines":[{"account":1,"amount":"723.26","currency":"USD","side":"debit"},{"account":12,"amount":"723.26","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"d6ad64035427d8ab4243493aa7c39c1207f62a8c815fcd98eb609a58cab5f7eb","seq":168,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-05-08","description":"Design work 5","id":24},"prev":"04d667574c83c71c34d17f508bf5d6f64ac549b7c03968cf645c03e0ea007236","seq":169,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":24,"party_id":3},"prev":"582fa7556c44d7d006428010ebdd06f6717fa3e4349e9d1da43286093fc2d804","seq":170,"ts":"2026-10-21T21:28:55Z","type":"entry.party_set"}
|
||||
{"payload":{"account":17,"amount":"557.42","currency":"USD","id":24,"side":"debit"},"prev":"4d7f750fe1197f5dc45cf3b36f0a8b5a8bbaefd59e0e70964e850468f15c806d","seq":171,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"557.42","currency":"USD","id":24,"side":"credit"},"prev":"b349beaeafe3b3a159d9e430afdf9b5c16245922ece79168e7374f4d0ac3c226","seq":172,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":24,"blob_sha":"e2150d5a433a641134c1c1e927a05dea2fddf44775f2b9dd2c23366e79d38389","category":"receipts","description":null,"entry_id":24,"file_name":"r24.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"40139e3503971af4374cf0b4b56c707d6525ac946e1ef2b1f35322f5b6b34850","seq":173,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-05-08","description":"Design work 5","id":24,"lines":[{"account":17,"amount":"557.42","currency":"USD","side":"debit"},{"account":1,"amount":"557.42","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b1966191ebf5b382aad792634f4c2c5daaaf5f84a739ac3c3507153c9031756c","seq":174,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-05-13","description":"Supplies 5","id":25},"prev":"1225dc7926558c39b65926da7e4e9cea942713a29451d670aa458c4411cdadf2","seq":175,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":25,"party_id":4},"prev":"0387af139832d42580c0d9d61990c7a0ed8f2c6e260bfb10b061882bb210885e","seq":176,"ts":"2026-10-21T21:28:55Z","type":"entry.party_set"}
|
||||
{"payload":{"account":13,"amount":"78.47","currency":"USD","id":25,"side":"debit"},"prev":"7217b65bae27528c5586423029b77c7d17bf039d993011221b46b91f6bad4adf","seq":177,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
|
||||
{"payload":{"account":7,"amount":"78.47","currency":"USD","id":25,"side":"credit"},"prev":"2ba1a67b7064dafbc8b921da91fe35e1dda7093eb9dc94ca1ebac7dbcf904c8f","seq":178,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":25,"blob_sha":"930a53802a91886ad895106907b55e56fad62a32d8933ecf10fa6ffda0ca4d99","category":"receipts","description":null,"entry_id":25,"file_name":"r25.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"341c0fe14ddff9e61aaff5b254088fdcdc22fe7b9569e8269c9b82e704d0c4fb","seq":179,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-05-13","description":"Supplies 5","id":25,"lines":[{"account":13,"amount":"78.47","currency":"USD","side":"debit"},{"account":7,"amount":"78.47","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b3b056c6c81e328b556a0e5f701d03cca27b0b535a3d6e5fa713c49038e17f35","seq":180,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-05-18","description":"Software paid by member 5","id":26},"prev":"589d6238947cb71db0fc7ca67fe191bd21815d6f2f73321866966cdcdc25cd5f","seq":181,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":26,"party_id":1},"prev":"e189d25bbbe2f1584aad41d5745394d64375193da201844a3a249d5107678894","seq":182,"ts":"2026-10-21T21:28:55Z","type":"entry.party_set"}
|
||||
{"payload":{"account":14,"amount":"742.15","currency":"USD","id":26,"side":"debit"},"prev":"5b631f9b461464590322911961ef79fed887a1d13f711698ccf8346641ad6caa","seq":183,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
|
||||
{"payload":{"account":8,"amount":"742.15","currency":"USD","id":26,"side":"credit"},"prev":"bb62bc83d88c4d51892ac46bea4973a9551a5eba47146590d4c1f64b93094001","seq":184,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":26,"blob_sha":"c5ec7ae0d321249ce2b1ca7ada6605a45e4afd8a09e8e3321b09427cf46fa876","category":"receipts","description":null,"entry_id":26,"file_name":"r26.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"55ef1c2620ed5e12ed6ebc4924d601d0b225a8597d33641a9b9cae40878008c2","seq":185,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-05-18","description":"Software paid by member 5","id":26,"lines":[{"account":14,"amount":"742.15","currency":"USD","side":"debit"},{"account":8,"amount":"742.15","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1561153fc0243c3fd1a7c4827e594f6989329425d8af48e8b400383b8ce8bfbf","seq":186,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-05-23","description":"Bank fee 5","id":27},"prev":"56423388ab6c87b857dda42ae3c223be85fd9f252ed6ab3c8e6e1263bf1de933","seq":187,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":27,"reason":"bank fee"},"prev":"191c29d74cfba04703fcce75bfe2bca04f2b55ec53fd59f648f0870167e27309","seq":188,"ts":"2026-10-21T21:28:55Z","type":"entry.no_party"}
|
||||
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":27,"side":"debit"},"prev":"b98709e04a040c95b35edda750ac20a2cbf12bbd1671750037dafc00d66a3b13","seq":189,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":27,"side":"credit"},"prev":"4409fdddbe25988a80846a91a889333ff58f3cabeb93013380c89e0043bf0ad4","seq":190,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":27,"blob_sha":"154a2bd5bd5bf103a7a5dbb129fcf44a3a6c2c4a3214cc0bb6a17311c82d8061","category":"receipts","description":null,"entry_id":27,"file_name":"r27.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"328edf40f496826dca364f529faadb44b22a1630307911a0d019be7870988e10","seq":191,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-05-23","description":"Bank fee 5","id":27,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"d80aa5223319bac0ada01e8e5f33d0277139a8d500539cf31131e2292390b787","seq":192,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-06-03","description":"Consulting 6","id":28},"prev":"b69840e0dca6e2119aa5009e125e962377b01649f8f3e283374d7073ef23e799","seq":193,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":28,"party_id":5},"prev":"1bdea236f3725c76feaf17f990e44450b488b4406d8c3ba327a3e8b284c7bb45","seq":194,"ts":"2026-10-21T21:28:56Z","type":"entry.party_set"}
|
||||
{"payload":{"account":1,"amount":"293.60","currency":"USD","id":28,"side":"debit"},"prev":"24f568bccbdea731f1d3d65a3b0a7b1ee71b3328d5ed98990460d4be384658fa","seq":195,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
|
||||
{"payload":{"account":12,"amount":"293.60","currency":"USD","id":28,"side":"credit"},"prev":"9d884d36fd35dcd0d94c02f7f9800c3132ad0c5754d77b89f3c184c442b405eb","seq":196,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":28,"blob_sha":"a8234e922d52bb6f585639bd99714794d6a62a089faedb24711f7f7b8ff24091","category":"receipts","description":null,"entry_id":28,"file_name":"r28.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"615f1b0c850ea082b2caf71aade572e0d98e8fa7c181077ec0c493685af02129","seq":197,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-06-03","description":"Consulting 6","id":28,"lines":[{"account":1,"amount":"293.60","currency":"USD","side":"debit"},{"account":12,"amount":"293.60","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"cdcc49409546f1fb63a7847dd8862e8ffc00eec2439b2700378cb67f1d935d36","seq":198,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-06-08","description":"Design work 6","id":29},"prev":"a45ca1c0c8ca330f2de5a0bfc150d3cc9a1f0dd4bd434347c23393638a2ec71f","seq":199,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":29,"party_id":3},"prev":"a23eb273a2b7a8423bd7fda205a7801d3b259ff9290fe4199d58b9a70bc3cd11","seq":200,"ts":"2026-10-21T21:28:56Z","type":"entry.party_set"}
|
||||
{"payload":{"account":17,"amount":"827.57","currency":"USD","id":29,"side":"debit"},"prev":"86a6323eec09275c1a2cafa1e570f479198b41d7d7d0fbb9901c43954718e070","seq":201,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"827.57","currency":"USD","id":29,"side":"credit"},"prev":"9cab5dc278d32771d1dfec9f002b4a8213cd2dfa341f6348cbc4b3db4d563af2","seq":202,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":29,"blob_sha":"bd7936f79f1b7e603feaa91d6e66a165bb5959bc91519f884f60dd63012b53eb","category":"receipts","description":null,"entry_id":29,"file_name":"r29.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"7f05ec8fd278f0c25556041310ca0ee32b6cf1d6d9101f4e336c1dae0bef6484","seq":203,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-06-08","description":"Design work 6","id":29,"lines":[{"account":17,"amount":"827.57","currency":"USD","side":"debit"},{"account":1,"amount":"827.57","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"a2a196019c9ca93a5ee9e6b7394d7d909cd213e2728cf4d18015f5d65cea2d54","seq":204,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-06-13","description":"Supplies 6","id":30},"prev":"b709f836b0dff6856f22fda0dea4990df172f62a354e25123609bd0174be8d66","seq":205,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":30,"party_id":4},"prev":"97f1b47f77f8c1197de24bb989fa8bb7e9083641fac69fba471b6545ab2e9687","seq":206,"ts":"2026-10-21T21:28:56Z","type":"entry.party_set"}
|
||||
{"payload":{"account":13,"amount":"823.38","currency":"USD","id":30,"side":"debit"},"prev":"6b625c1035f8054a7c2754a992fb590385b0c6ebe41518e16c207cc1322d63c8","seq":207,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
|
||||
{"payload":{"account":7,"amount":"823.38","currency":"USD","id":30,"side":"credit"},"prev":"83fcd89112f49407b564c85dc788120a63b8abd8df9822abdd35199aed997a9e","seq":208,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":30,"blob_sha":"80776469bdd56d9957eaef7735f9d603ef7d4b69a51fa500aa4f0b70d24fd95e","category":"receipts","description":null,"entry_id":30,"file_name":"r30.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"b585d23d0d3f25c96fc81432c71382825debd976de9862fb19541204abdf83a7","seq":209,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-06-13","description":"Supplies 6","id":30,"lines":[{"account":13,"amount":"823.38","currency":"USD","side":"debit"},{"account":7,"amount":"823.38","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4fa950b384f9c5307170c95c335c272362859e427eaf29402fead4f447fc2002","seq":210,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-06-18","description":"Software paid by member 6","id":31},"prev":"af902bae6a319f9efca635c501b5c005c68cf58c69ef6341e9db5a7f25a6c651","seq":211,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":31,"party_id":1},"prev":"17bd5f87e9abb49150365543faaf258265fa368fd005e551f16465575f9b38ba","seq":212,"ts":"2026-10-21T21:28:56Z","type":"entry.party_set"}
|
||||
{"payload":{"account":14,"amount":"765.14","currency":"USD","id":31,"side":"debit"},"prev":"830f09569349f9ae18605bf6f977294e4580d28d1511954347b4df606068d0e4","seq":213,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
|
||||
{"payload":{"account":8,"amount":"765.14","currency":"USD","id":31,"side":"credit"},"prev":"4d92bcf5c31923002854dc00bdccce819209ac05144d7f3cbe5a1f951788ec17","seq":214,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":31,"blob_sha":"372029955692c864bd5f8fc93da8a3512bcfa45099626c289eca75de84478f0c","category":"receipts","description":null,"entry_id":31,"file_name":"r31.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"22323b6e007830ba4fa0e701318ab0fb1a40764022fda8067b9076843fa938b6","seq":215,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-06-18","description":"Software paid by member 6","id":31,"lines":[{"account":14,"amount":"765.14","currency":"USD","side":"debit"},{"account":8,"amount":"765.14","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"07b9323c35fa23cd4e7a009ea458f095b71924fc00dfe24a9b0e6e7f5f163665","seq":216,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-06-23","description":"Bank fee 6","id":32},"prev":"070e5f13698f701be19ab710494ab4cedf78e46f94b6d8f3fad55a90405f0590","seq":217,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":32,"reason":"bank fee"},"prev":"09be96a0740db022c5bf2a3c466597811c18cc5ea62b09a2fe81ede6bf832a16","seq":218,"ts":"2026-10-21T21:28:56Z","type":"entry.no_party"}
|
||||
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":32,"side":"debit"},"prev":"1e79025529dca4144f1ca4a9b1a2466aca3ce101ece1a6ff57cbbee0d6c2d766","seq":219,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":32,"side":"credit"},"prev":"64c01758d90db85b0d33d743d2955b66e00041e8babda6a49139f47f0bf71828","seq":220,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":32,"blob_sha":"4e2d180772eae2ddf0596a5444f301e052f93f7266845eecf454bc31f053d36f","category":"receipts","description":null,"entry_id":32,"file_name":"r32.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"3a6f46683d9c4e507d0fd4268c4be69f372f779b43b8ab6ffb42ae79fc28869c","seq":221,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-06-23","description":"Bank fee 6","id":32,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"6c5b6d6badc42ae5dd9d5045c2ba54fac1e353bc99a87a6c3ff2186e369fadc9","seq":222,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-07-03","description":"Consulting 7","id":33},"prev":"f759dd462a9cb7019463699c98db1902a07c4caa63648830a693217df128dacf","seq":223,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":33,"party_id":5},"prev":"0c980e96236d23fa01a2a67fab09b71cca3068ccafef1c19c6e6287bd2dc65a1","seq":224,"ts":"2026-10-21T21:28:57Z","type":"entry.party_set"}
|
||||
{"payload":{"account":1,"amount":"757.42","currency":"USD","id":33,"side":"debit"},"prev":"0d721835811bdfa94d879f95cbd8ff64ad5c6b8c3c315a26918605d1a2833b30","seq":225,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
|
||||
{"payload":{"account":12,"amount":"757.42","currency":"USD","id":33,"side":"credit"},"prev":"68c5f21aef69766ec2494c0a6f5bfa670541ca15160d63613b3b37d93de96111","seq":226,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":33,"blob_sha":"b05c9555f4e49468970319698e33cab20430bd03a877183870c44f037aebd740","category":"receipts","description":null,"entry_id":33,"file_name":"r33.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"173360449b8d5b426a30c584b46b4ec9c86a86caae4ec52bdef7569657698d41","seq":227,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-07-03","description":"Consulting 7","id":33,"lines":[{"account":1,"amount":"757.42","currency":"USD","side":"debit"},{"account":12,"amount":"757.42","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8b92ffbf33a62e49a33d458ed9c40a051fa1fb7deb6db790588f7f1fef5e4c04","seq":228,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-07-08","description":"Design work 7","id":34},"prev":"9591c74c137256cf21719fa68c52044f8af84880de48c0630770cb410cb7cab0","seq":229,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":34,"party_id":3},"prev":"248bbe7571c71f650ccf9e71419e0375cf1aab0b9a526f7726e247109b7ebe8c","seq":230,"ts":"2026-10-21T21:28:57Z","type":"entry.party_set"}
|
||||
{"payload":{"account":17,"amount":"768.48","currency":"USD","id":34,"side":"debit"},"prev":"713137aed828f3a99c9a4e47084102d60ec3cce05a5b7583bfbe2d0cc3c45861","seq":231,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"768.48","currency":"USD","id":34,"side":"credit"},"prev":"dce879af18b9a6eaed664dc7fdbb4e72aced9e82993d8505b3ed0c31a14fe40a","seq":232,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":34,"blob_sha":"3acdde86472882410cc59ff61474bff5e8272eb4411654d5d57965b34d65673d","category":"receipts","description":null,"entry_id":34,"file_name":"r34.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"11c5349b0772892ed01eb3952297621029f63bc5766a5b5aaa56910cff860818","seq":233,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-07-08","description":"Design work 7","id":34,"lines":[{"account":17,"amount":"768.48","currency":"USD","side":"debit"},{"account":1,"amount":"768.48","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"e3aba3df02941358e8c2b4d3f1175d60d930b35d2538f164e95fe182abff6a0d","seq":234,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-07-13","description":"Supplies 7","id":35},"prev":"eabdfe6299076017215304052f4942730cd9dce056bd341c1901798d8c062107","seq":235,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":35,"party_id":4},"prev":"1293dd57ef8c2dec6095abed25e7477a9d8ab03ffc0e784559cb4ac82c765cad","seq":236,"ts":"2026-10-21T21:28:57Z","type":"entry.party_set"}
|
||||
{"payload":{"account":13,"amount":"520.93","currency":"USD","id":35,"side":"debit"},"prev":"6c85e1110a15121c335512824b0c90d3323aa81031d0ad067d981c5233b2e6be","seq":237,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
|
||||
{"payload":{"account":7,"amount":"520.93","currency":"USD","id":35,"side":"credit"},"prev":"ee3b23302c580618337aed8a4298786f6fc1b81b53f0b2b4000fa921fea7fb8b","seq":238,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":35,"blob_sha":"c090474697214b4507cdbb20ee2ba05be74d257a9d0f7ce06e0c16cf6d071dc3","category":"receipts","description":null,"entry_id":35,"file_name":"r35.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"9b10307ae7dd15778ad0d930f1e238728d3b9596fafa2131d0ef11b4a958c6d0","seq":239,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-07-13","description":"Supplies 7","id":35,"lines":[{"account":13,"amount":"520.93","currency":"USD","side":"debit"},{"account":7,"amount":"520.93","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c965d22e48395c8a99933fe3e80292fe503d41f37b708340400425eb6f6d2f42","seq":240,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-07-18","description":"Software paid by member 7","id":36},"prev":"cf4466aae68140545f41cc3baf77c3163ee7640b620d2624e77f7f76b73c7610","seq":241,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":36,"party_id":1},"prev":"945b101940418f2f4eb207ca138ea3f83d2aa9c01cd569037058948e7b697153","seq":242,"ts":"2026-10-21T21:28:57Z","type":"entry.party_set"}
|
||||
{"payload":{"account":14,"amount":"65.99","currency":"USD","id":36,"side":"debit"},"prev":"95c9efa06f94ed2770c960850c4dfabb63a7de6244346cf883c08bc2bdf36132","seq":243,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
|
||||
{"payload":{"account":8,"amount":"65.99","currency":"USD","id":36,"side":"credit"},"prev":"01426ffdbc7e438ce7a8952fff163c5551f40b070b200140ab9c0f78f78fbdfd","seq":244,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":36,"blob_sha":"210959e4644efeb3e0dec3b5da30d3882c2f6bf1dd05d36b81b3f9ab1d7cf8d5","category":"receipts","description":null,"entry_id":36,"file_name":"r36.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"1ba7cb63e6d6f1f1b6b31e48ae575fc85b58a3a4f70f8f92c054ad40a8e34f48","seq":245,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-07-18","description":"Software paid by member 7","id":36,"lines":[{"account":14,"amount":"65.99","currency":"USD","side":"debit"},{"account":8,"amount":"65.99","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1183a1094dac472704a51c0252b676b6763ee599348228a61766f4d062e625e5","seq":246,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-07-23","description":"Bank fee 7","id":37},"prev":"cd136c62a69f1b7cf15e5bb6038726abf06b9562118a38a0a34a8a0511ea6c9f","seq":247,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":37,"reason":"bank fee"},"prev":"f592fc409d09e4d968191cbdfff9385d0e6290f0a27b3cc058b30f8e71d1e793","seq":248,"ts":"2026-10-21T21:28:57Z","type":"entry.no_party"}
|
||||
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":37,"side":"debit"},"prev":"d0ddd4d22bd0478793c92fc15c9b4dff85a4bf937099c3198919d045617ba8db","seq":249,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":37,"side":"credit"},"prev":"b8526b55e451046fc8121ae3b975406e84268dd9fec5ebf2d77fec8977463a15","seq":250,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":37,"blob_sha":"af8c085ab8779b9ae9fcb3989f60a55719db4d91dbdf5c5e60f3ac39c6efd05b","category":"receipts","description":null,"entry_id":37,"file_name":"r37.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"3978dac72a1bf49c6c488d99ae9e2480ca1ff0dd5d0493b657c936ba2f467e5f","seq":251,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-07-23","description":"Bank fee 7","id":37,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4edb283721c38215f970a20de821fc6afd5e503c5fc5687c521f7d24f66dc016","seq":252,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-08-03","description":"Consulting 8","id":38},"prev":"b0a4871b1c83e596c62bdb9495bb00f48f2b4c7afcf55b324d0629aed0c7f81d","seq":253,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":38,"party_id":5},"prev":"fdd9ea92046c54ed28685e1ca916ab5059298e9c0c68e7c0ee2d7e9491ab2c67","seq":254,"ts":"2026-10-21T21:28:58Z","type":"entry.party_set"}
|
||||
{"payload":{"account":1,"amount":"62.05","currency":"USD","id":38,"side":"debit"},"prev":"5a21e496268f99275bbd57a3b1c031768d4734b28b3688485cb62fe4bb452f1c","seq":255,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
|
||||
{"payload":{"account":12,"amount":"62.05","currency":"USD","id":38,"side":"credit"},"prev":"6ac8d4eb51cb07c0ef399001f7075a3833f8efc20c8287c48ddda31e1be49b5d","seq":256,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":38,"blob_sha":"350c8d74e3937e0d444fae5eef32a5c1e10fb32fa5aced6d07405ab36aeae8a7","category":"receipts","description":null,"entry_id":38,"file_name":"r38.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"e1b62c3f89d8fced181f6f6ff01135f2aace75318a2e9f95bb91609cd9c516e2","seq":257,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-08-03","description":"Consulting 8","id":38,"lines":[{"account":1,"amount":"62.05","currency":"USD","side":"debit"},{"account":12,"amount":"62.05","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"2a17b77d74e6376166edf3dbdfc145da3fa4b25cbb202bf3f3244ca8bc1b1836","seq":258,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-08-08","description":"Design work 8","id":39},"prev":"3fa32a40fc5211fa6c9e2fff209b74841b8b85f75901e4e23be7902777022f21","seq":259,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":39,"party_id":3},"prev":"ce87e0b80d60bd26ba2c22f542bd6e1ff212ab868e445b12aa158b0213d54d17","seq":260,"ts":"2026-10-21T21:28:58Z","type":"entry.party_set"}
|
||||
{"payload":{"account":17,"amount":"730.63","currency":"USD","id":39,"side":"debit"},"prev":"87b250f95f256c3ada0bcdffc0a18e8d0334839735b198031591c9f0b2140112","seq":261,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"730.63","currency":"USD","id":39,"side":"credit"},"prev":"f63dd64c97b5195f8b14ea8ac55418e66f3609ed931cd64f318012431a42c716","seq":262,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":39,"blob_sha":"090a938b8299df0c5b93304381d24a65b7feb45a6ed923c5fe10c10d7118e336","category":"receipts","description":null,"entry_id":39,"file_name":"r39.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"86f9aaa2f67bfd2dde1f76b83d5c618aadaca66e781e0a6ccceb08b557e94821","seq":263,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-08-08","description":"Design work 8","id":39,"lines":[{"account":17,"amount":"730.63","currency":"USD","side":"debit"},{"account":1,"amount":"730.63","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7dbc46d17cedd252e198846fb543182ef4d8a8195c96239e396afb4e669043ca","seq":264,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-08-13","description":"Supplies 8","id":40},"prev":"63f3ad6ce777ce928bbd9eae7689c5e677bb2d37b0fb75dd2d0b382c7212a220","seq":265,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":40,"party_id":4},"prev":"10425e29f230394a89838ce3c41229d8f3e567766c13276740ea9e0855ae11a4","seq":266,"ts":"2026-10-21T21:28:58Z","type":"entry.party_set"}
|
||||
{"payload":{"account":13,"amount":"175.55","currency":"USD","id":40,"side":"debit"},"prev":"988a8fbd3403db7add3364ded562d4864774c19b7ded2cc8eb1ed429478e33d6","seq":267,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
|
||||
{"payload":{"account":7,"amount":"175.55","currency":"USD","id":40,"side":"credit"},"prev":"c8d9dbf34eb4052c69c4f2bfb5310e9d3517d14a4b39144d094ecf9c361cc22d","seq":268,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":40,"blob_sha":"f52f29e9a5eb89e4f0851f39f7ee9c25ffb63e7973bc4dfaf45c3359fe923f1e","category":"receipts","description":null,"entry_id":40,"file_name":"r40.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"cf459ec8aadf61e86f470d4c975401aedd639ecf7bfc4ab72f982c2905d1d37a","seq":269,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-08-13","description":"Supplies 8","id":40,"lines":[{"account":13,"amount":"175.55","currency":"USD","side":"debit"},{"account":7,"amount":"175.55","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"df91e170c107eb3ac37ceb43079954efb97d2b36b8fda8d9953e0592b9e6fc7e","seq":270,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-08-18","description":"Software paid by member 8","id":41},"prev":"28d8955805ea47de1856adcab5ea51f354e06a781e92407f4e7fd2dadc012655","seq":271,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":41,"party_id":1},"prev":"2ec6d9a3319a17a11e52838665efcfdde6050dcc73ef822718a70c41834c75ee","seq":272,"ts":"2026-10-21T21:28:58Z","type":"entry.party_set"}
|
||||
{"payload":{"account":14,"amount":"380.59","currency":"USD","id":41,"side":"debit"},"prev":"86cf71ad7ca19f8873609ee21fc01d7189cb41c62b773d612d22c7261cc3f483","seq":273,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
|
||||
{"payload":{"account":8,"amount":"380.59","currency":"USD","id":41,"side":"credit"},"prev":"01a14c8f086622be0bff9f8af0d50a540acf7b916b5d2428c20ec5f400e9b5c6","seq":274,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":41,"blob_sha":"266487055317ad4a9ec8504c20da90ea14fd7b101894b0c001c7106ead417c20","category":"receipts","description":null,"entry_id":41,"file_name":"r41.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"290b112f536d6d414aafd2ba484f337f3112e29f394696bbcf15b34b75ae0c9c","seq":275,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-08-18","description":"Software paid by member 8","id":41,"lines":[{"account":14,"amount":"380.59","currency":"USD","side":"debit"},{"account":8,"amount":"380.59","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5724ec3db36f52fd2076c0c15cf6207753056f9896cb822c410cbf1cff967731","seq":276,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-08-23","description":"Bank fee 8","id":42},"prev":"1f4cde58fd842e88ce9747b62094cd58db56f4e7106fc18b1d8696026367f3e6","seq":277,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":42,"reason":"bank fee"},"prev":"13d7dfd5202e9f27bd46870c7f342690e09a322d3669fa14b82c5ff65ac9787d","seq":278,"ts":"2026-10-21T21:28:58Z","type":"entry.no_party"}
|
||||
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":42,"side":"debit"},"prev":"1fddaed992fda0912cf718f8a9dcae48100a5daf9ed1cac2bbdac95ff75ee5db","seq":279,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":42,"side":"credit"},"prev":"2a815bee24fa4ad299249b6d18bfa4263a46236e4cf95c62e4b0013cb0bed4e0","seq":280,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":42,"blob_sha":"09d567fff5b3478f1c5e388f0b173dfb2f45eb553528c95b6723d5f13a6b0dba","category":"receipts","description":null,"entry_id":42,"file_name":"r42.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"40ad0df5d16cc355e28c6dba42ae850632367993a0bd6a356a01b1db4cab2d01","seq":281,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-08-23","description":"Bank fee 8","id":42,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1c0a55791cafa9e099b572f2b7e6abfc455dd77f89df4bdda91b62a97a682453","seq":282,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-09-03","description":"Consulting 9","id":43},"prev":"b5acb73ab3af2bf2efa177dcda6010d7fbe66626a86dc3589e601b4c5a82a91d","seq":283,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":43,"party_id":5},"prev":"d0a8d7e0443823abbc31649fa177d7f284b25884dcb11bad39a7a505b5405130","seq":284,"ts":"2026-10-21T21:28:59Z","type":"entry.party_set"}
|
||||
{"payload":{"account":1,"amount":"190.07","currency":"USD","id":43,"side":"debit"},"prev":"a896cb4223177131ab30faf764086948804bc85af2b0ca7ae052f77d5b28d138","seq":285,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
|
||||
{"payload":{"account":12,"amount":"190.07","currency":"USD","id":43,"side":"credit"},"prev":"36db1572ca560eee9633eff1d343258e96247f9fb05589c031aead4c0008c0de","seq":286,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":43,"blob_sha":"549225ddc356bd1a217220cc6681b76dba4ea9eff24a5f34ca6e832a1e7dfc97","category":"receipts","description":null,"entry_id":43,"file_name":"r43.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"0fb8e62c8d504fb5e6f4f849f8d6816c49be78cf68e77d8c3a9d921425fa7d59","seq":287,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-09-03","description":"Consulting 9","id":43,"lines":[{"account":1,"amount":"190.07","currency":"USD","side":"debit"},{"account":12,"amount":"190.07","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"33eba839f1657919df5693dee0d20385a562e96f25f66046ab591910f71e5802","seq":288,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-09-08","description":"Design work 9","id":44},"prev":"0185772912d7905d64b60989068f41fd2b61a2f19542d6d8663704e61e0b314c","seq":289,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":44,"party_id":3},"prev":"c81d8a7e54878101c1d36e4a120485b555f0b7d9476145dea2cef53b52831fc6","seq":290,"ts":"2026-10-21T21:28:59Z","type":"entry.party_set"}
|
||||
{"payload":{"account":17,"amount":"709.68","currency":"USD","id":44,"side":"debit"},"prev":"30b4d9e23c114c65583c94594fd0f59763f95cf13c7c6baffbe4eb955646ef57","seq":291,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"709.68","currency":"USD","id":44,"side":"credit"},"prev":"d56cf6fde598e32ffeddc8a792895884703a69e3e27a7bd2dac5c8624bccdd76","seq":292,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":44,"blob_sha":"160144f9771ebf02e5e5c914137f3ae642dcbf75b8f44443296f32fab855b62a","category":"receipts","description":null,"entry_id":44,"file_name":"r44.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"84bb3fcb531b4d64803c05e7e16043e0f7ac5a4b95a3b90a8909edc3b2e004f4","seq":293,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-09-08","description":"Design work 9","id":44,"lines":[{"account":17,"amount":"709.68","currency":"USD","side":"debit"},{"account":1,"amount":"709.68","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c9142d9f7a6dabb1e117100951ea1186914766cd2f21cb59c06285fd7f2b9462","seq":294,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-09-13","description":"Supplies 9","id":45},"prev":"8e62d11efb2f897c1886a72cea2a10024d19bcefa948f39d801ed746c554c45b","seq":295,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":45,"party_id":4},"prev":"9fe86a91236d52e50d70e5dffc7ccc10989700ee85999f8181a1ed45fb598da9","seq":296,"ts":"2026-10-21T21:28:59Z","type":"entry.party_set"}
|
||||
{"payload":{"account":13,"amount":"155.39","currency":"USD","id":45,"side":"debit"},"prev":"534bbb9f5c0790c0e4f332b9857aa54563b5fc8dbba582763997cb2e373999f3","seq":297,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
|
||||
{"payload":{"account":7,"amount":"155.39","currency":"USD","id":45,"side":"credit"},"prev":"7116dfa0caa53e9eb5e5199afb7d693f1cfb9ae715d0b694d08118ab7f22ed14","seq":298,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":45,"blob_sha":"7fcbd4774d24625ed2dd3195dc8b298a41e19dc56c6e5a3fdafb3862a1198eeb","category":"receipts","description":null,"entry_id":45,"file_name":"r45.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"c39dcd970e9da7d21aa1924cbd662d973c60cf9497af73ecb5379d6b1d17ae05","seq":299,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-09-13","description":"Supplies 9","id":45,"lines":[{"account":13,"amount":"155.39","currency":"USD","side":"debit"},{"account":7,"amount":"155.39","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"07dad89fbefd0e89de4920a26cb2da5d61f88365d09ed327f6a753b12c7fd952","seq":300,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-09-18","description":"Software paid by member 9","id":46},"prev":"002822633eb6996ee1ed69849ac2e2027b238e39c8c38fbfe2e1b54c8ca97a3f","seq":301,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":46,"party_id":1},"prev":"a954c30417563df040b718dab32da9f78978a8154fafdbade90fa33e51fb08b9","seq":302,"ts":"2026-10-21T21:28:59Z","type":"entry.party_set"}
|
||||
{"payload":{"account":14,"amount":"749.30","currency":"USD","id":46,"side":"debit"},"prev":"40bc1d6f6745146f9adfa43ba29d203f70c6fe065809922f462288544a62c7d1","seq":303,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
|
||||
{"payload":{"account":8,"amount":"749.30","currency":"USD","id":46,"side":"credit"},"prev":"a43d18d01a5373d97c8b8d39dd7d8e7cccaa27d21bab78d04ff360da6278ba86","seq":304,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":46,"blob_sha":"0126c8f9f243e03c0cb71f8d47ba27730f9a0503e25abdbd43f94e3a44479e6b","category":"receipts","description":null,"entry_id":46,"file_name":"r46.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"2eefc744c374f495f10c6b59738903fcdd77c96f0bc2440fa71c43e7072047b6","seq":305,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-09-18","description":"Software paid by member 9","id":46,"lines":[{"account":14,"amount":"749.30","currency":"USD","side":"debit"},{"account":8,"amount":"749.30","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1030dd48e2298995eafd77d4864b87e492ea842cfe6294018e6bb7a2889f6ab5","seq":306,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-09-23","description":"Bank fee 9","id":47},"prev":"0cc00d0afccbc3cbc47fa69f46c35f70b495d88fe093da4ecc02f9146f585c99","seq":307,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":47,"reason":"bank fee"},"prev":"e38279f0f1ac2d1ba9347ac3dadeed99198a1c8f53603875c140ac79155ebfaa","seq":308,"ts":"2026-10-21T21:28:59Z","type":"entry.no_party"}
|
||||
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":47,"side":"debit"},"prev":"e7f860c5ed73429179d942ed735f3620a5c67a251fd884dcdc81ad2268b98c71","seq":309,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":47,"side":"credit"},"prev":"e02d2ff27e7ca3599be224bfefcf1e0df5b98c0308526953a73603f66f199eb7","seq":310,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":47,"blob_sha":"288625ee69e0c00d34b1defbf7e02b95768dfdcaa3df3fefd684a56a3b2c3d4a","category":"receipts","description":null,"entry_id":47,"file_name":"r47.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"e5f958df210efc615129fe33643298b61bc436a92660d61a707215175ab87169","seq":311,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-09-23","description":"Bank fee 9","id":47,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7777a10915275157ba3f997c41a0e0015607caab9e153489f85f62c4a1af1add","seq":312,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-10-03","description":"Consulting 10","id":48},"prev":"ce7052dbc841adff64afdb9078936d72dd1dd90db17519c517498630f8399f4c","seq":313,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":48,"party_id":5},"prev":"3af46e02bc03d2bc4b0eb68c4beeeef2dbf155b2ecc9740123ccd44c3466ae19","seq":314,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
|
||||
{"payload":{"account":1,"amount":"735.34","currency":"USD","id":48,"side":"debit"},"prev":"2224b0c15c2fa780a17b51d03afae3f7b71bb26f82744d0ffeb90313781b8d34","seq":315,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
|
||||
{"payload":{"account":12,"amount":"735.34","currency":"USD","id":48,"side":"credit"},"prev":"71bc9269902f739f8d1e6b82c12d319831d0d79a7da56242c3847856b3911e28","seq":316,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":48,"blob_sha":"3ff73e7967f4f72c974941bce53012f75a995b229982457def7312c6547df6f9","category":"receipts","description":null,"entry_id":48,"file_name":"r48.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"6c290b24a3ec2d89183d05ee66db17cd4c9f7640671f7312300d6a7834f96269","seq":317,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-10-03","description":"Consulting 10","id":48,"lines":[{"account":1,"amount":"735.34","currency":"USD","side":"debit"},{"account":12,"amount":"735.34","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7d22a280a8ddaedaf9f32f223b27134c70797f259ba9844bdffdd99f10e25df1","seq":318,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-10-08","description":"Design work 10","id":49},"prev":"2957de627eb301a870f9c7247e2e4a76499615f25395b6a9f39912b66f3727c4","seq":319,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":49,"party_id":3},"prev":"1b7bbe892805e43738683ffbb05f37970dc4b0a9c7696b2fe0c12924b8ea8d4c","seq":320,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
|
||||
{"payload":{"account":17,"amount":"894.91","currency":"USD","id":49,"side":"debit"},"prev":"7385e04ea8e892fb45c846291e5b095766f7545e6ee17de349d565ff0fec9178","seq":321,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"894.91","currency":"USD","id":49,"side":"credit"},"prev":"7936ae3480dee70dea7c6f4266074a57cd05f0ff174ab39619a50db805762b1b","seq":322,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":49,"blob_sha":"98ad30c16a2f0b8f462702570194a2ca2ccd95703535299753e773d8ba737d09","category":"receipts","description":null,"entry_id":49,"file_name":"r49.pdf","mime_type":"application/pdf","size_bytes":56},"prev":"2efea58df2f347f7f38c6a4beb5177aa72c5d41d6f32183ca35b8af99736fb1f","seq":323,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-10-08","description":"Design work 10","id":49,"lines":[{"account":17,"amount":"894.91","currency":"USD","side":"debit"},{"account":1,"amount":"894.91","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8706e7f9660800873bd26f0ffd4ff13d49e891fadf8da3961a4ca6f1a1e1de88","seq":324,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-10-13","description":"Supplies 10","id":50},"prev":"a83eb84e3fa5a51a8238dc9ad9e60c35dae9b74d8a291f997a3a18f4ae1a1cce","seq":325,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":50,"party_id":4},"prev":"1a26a3c924bcd3620a6df36922496ef395ec9cb245ac79fc396c9884a7740e86","seq":326,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
|
||||
{"payload":{"account":13,"amount":"237.88","currency":"USD","id":50,"side":"debit"},"prev":"80e6be396f1610f675b29c2f92363a0e7d70807cabd4206f88571e3965fb82bf","seq":327,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
|
||||
{"payload":{"account":7,"amount":"237.88","currency":"USD","id":50,"side":"credit"},"prev":"fd43c46e495a652eb8a6a26f02343b14856d784b40ac52a673efd9fc99085c1a","seq":328,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":50,"blob_sha":"218116eca79b67dbe85d3d813d3b0b8373a2eef6dde93ccb201458a74bfdafb1","category":"receipts","description":null,"entry_id":50,"file_name":"r50.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"a72dec373ee67c4db566214f887c4c973ef4e03b217e96c5dd1b958f6d3519bf","seq":329,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-10-13","description":"Supplies 10","id":50,"lines":[{"account":13,"amount":"237.88","currency":"USD","side":"debit"},{"account":7,"amount":"237.88","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"75aca5b510317e463c70c556001f7a89f9ced1a3887a70f544014057115bf939","seq":330,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-10-18","description":"Software paid by member 10","id":51},"prev":"eed78f69673c4d35ff80e0c782097c6865fc19823aef6898416d84405c050b16","seq":331,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":51,"party_id":1},"prev":"7e053ae25b809a194c8160018d66e31575d647adc60a1ed9b874bbcfd0ef3146","seq":332,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
|
||||
{"payload":{"account":14,"amount":"136.07","currency":"USD","id":51,"side":"debit"},"prev":"b24fe59527a0287a9ed30fd6f8bfa239d847d7336838da4cc2accb34999d0650","seq":333,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
|
||||
{"payload":{"account":8,"amount":"136.07","currency":"USD","id":51,"side":"credit"},"prev":"30b459d0cd9e01a392ed4b97bdec83eb59538928a4d757264e9423facefea8c3","seq":334,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":51,"blob_sha":"53f7de0945923a52b4fbcd56659f6dd4d3a02830ba0adcfbe81df0f1b7bd8ff2","category":"receipts","description":null,"entry_id":51,"file_name":"r51.pdf","mime_type":"application/pdf","size_bytes":68},"prev":"f30844a58b3d32afe3c18c4ad003cff39cf607e715c0e285e89436a4b864cb15","seq":335,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-10-18","description":"Software paid by member 10","id":51,"lines":[{"account":14,"amount":"136.07","currency":"USD","side":"debit"},{"account":8,"amount":"136.07","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"9a14e758062a1c678e3b0d56a3fd14338fe10e303665365d8c060ae5d6b4a97b","seq":336,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-10-23","description":"Bank fee 10","id":52},"prev":"189502c8c423a7264d9071c8b32ccfb1b1bda2c1176969d00a36dff1bb828dd0","seq":337,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":52,"reason":"bank fee"},"prev":"0e2d33f282a68cbbd94dcfbb854d82a66c99089618dcf6f6d0b3f34bb2ce1b9a","seq":338,"ts":"2026-10-21T21:29:00Z","type":"entry.no_party"}
|
||||
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":52,"side":"debit"},"prev":"507b44679c2bf90e6d2a946a5ea757cbc259b37adae922324d79139126e0a5a9","seq":339,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":52,"side":"credit"},"prev":"5a2c0b9f8e4eeb5dc3471c36b8eaca3dd64de3e495f01c03c99a4b9fcca99462","seq":340,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":52,"blob_sha":"37fa15b60bca1e11c46715e3b270a60573ab89be57f4908623a73572ee0325cb","category":"receipts","description":null,"entry_id":52,"file_name":"r52.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"1f672e76d179887d18ed8c28b1a78d57e1392528911d24615fbdda9f6b2c2cd5","seq":341,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-10-23","description":"Bank fee 10","id":52,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"545c33cbafa6fc1486f05c3aafec0948e5052be6037850cad5d5aa94323f635e","seq":342,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-11-03","description":"Consulting 11","id":53},"prev":"f3052b35a608797568c42f3ab2b8238f661718bd6e60275b15a0a99a7198828c","seq":343,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":53,"party_id":5},"prev":"2e7a295cc168ed4fae9d5b6e338403c574804b8298f2cb14d74638f216cef928","seq":344,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
|
||||
{"payload":{"account":1,"amount":"749.68","currency":"USD","id":53,"side":"debit"},"prev":"ef7b3258ac6edc69a5bc0a6ac8885d475cfb9285d6bd774ba6e706ca3b804645","seq":345,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
|
||||
{"payload":{"account":12,"amount":"749.68","currency":"USD","id":53,"side":"credit"},"prev":"5e38b5b521a8edb5200f0dcb42b7c6eb4d1446ce023b2d205f6dcacb65fe1e28","seq":346,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":53,"blob_sha":"5ba9b95dcfaad7cd77f590a2dbc8d14ddeb8f9694ec5f393da4b4802fc7462dd","category":"receipts","description":null,"entry_id":53,"file_name":"r53.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"8f9427d783c6683a07b7b29cb951475610ce8f829d63f89f4683b3424967c636","seq":347,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-11-03","description":"Consulting 11","id":53,"lines":[{"account":1,"amount":"749.68","currency":"USD","side":"debit"},{"account":12,"amount":"749.68","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4b1d07090bc763bae6c8d4778d86d1b636e3da123b1497f773786fef3bd0a039","seq":348,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-11-08","description":"Design work 11","id":54},"prev":"c7eccb78beb92636d00e47362c9760656ee5587ba8318fd5ab73027187aa43a2","seq":349,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":54,"party_id":3},"prev":"2a6c05d2e9362aeedadde55e878bf45407f1daf46cf6f786c8d0b6c293a54072","seq":350,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
|
||||
{"payload":{"account":17,"amount":"838.43","currency":"USD","id":54,"side":"debit"},"prev":"f56fc8fc42dbd05b165a4588689ae1c2b85c3e344aa628a486b0a2f0ea236726","seq":351,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"838.43","currency":"USD","id":54,"side":"credit"},"prev":"7d5e712f56cc3c476fdc5c7f99b08ab4301254acd288ac85d8c58ab5213cfa3f","seq":352,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":54,"blob_sha":"cf63d0935c457f42cf6610cdec14c1072de13b8e0dbd63e83518821e8fd4341b","category":"receipts","description":null,"entry_id":54,"file_name":"r54.pdf","mime_type":"application/pdf","size_bytes":56},"prev":"5cbc46b789656498728ade76bf7291fc277a22d07b7c1932bb4abea0b7608ff0","seq":353,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-11-08","description":"Design work 11","id":54,"lines":[{"account":17,"amount":"838.43","currency":"USD","side":"debit"},{"account":1,"amount":"838.43","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"2041d4dfcad47aa29439edffeb9a1e72f15baef31ce8ca64289985fbbdbc7737","seq":354,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-11-13","description":"Supplies 11","id":55},"prev":"bf3119d06d07a984345850e0f7ab0b59737fac22b13573149fcf5dc05e1b644a","seq":355,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":55,"party_id":4},"prev":"bc57f2095a4cf1e6a9ac606552f42d065dd7b13e7d0798cacf6b228143ed08e5","seq":356,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
|
||||
{"payload":{"account":13,"amount":"247.24","currency":"USD","id":55,"side":"debit"},"prev":"3a6e73647d7b10b82b2e6ef071eb9ff4e11b4b92ec8aaaca997840518655c43b","seq":357,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
|
||||
{"payload":{"account":7,"amount":"247.24","currency":"USD","id":55,"side":"credit"},"prev":"753b14754cb738bc6a744497d02a57291fcaa9a4d6257ca387f7b79c5e080781","seq":358,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":55,"blob_sha":"dfdd55e9d11c066afa47ae3e93b7ec3aa7b77a41bb21ffd7cfd17a260eb4f915","category":"receipts","description":null,"entry_id":55,"file_name":"r55.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"b7a4ea5387942f62b0121f43ac2c6415b2a38020612d1642f0ae843b580604fd","seq":359,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-11-13","description":"Supplies 11","id":55,"lines":[{"account":13,"amount":"247.24","currency":"USD","side":"debit"},{"account":7,"amount":"247.24","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"14a2c5a6a2b1834df2790e7a12b649f9b421e52084a4a500daea236f66bca068","seq":360,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-11-18","description":"Software paid by member 11","id":56},"prev":"2a619bea70d624bac05f2429919e6d5e20192f5615a49b634f3910f89dc41ce9","seq":361,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":56,"party_id":1},"prev":"3d05fd3fad0d19264976f97f1c59c6ef5dfb1545bfd54f110a229e649c74ae87","seq":362,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
|
||||
{"payload":{"account":14,"amount":"489.10","currency":"USD","id":56,"side":"debit"},"prev":"2402181c094548ca28bbb23300cfa632ee0494cfae468ee1bceb4f2176c213a5","seq":363,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
|
||||
{"payload":{"account":8,"amount":"489.10","currency":"USD","id":56,"side":"credit"},"prev":"51fb21d99531ac22e80984309cebd23e3a014d1966e2f3632e7706579489567f","seq":364,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":56,"blob_sha":"82c5c2c9cb7af39e0859bcc399f0df627105673fac9a02f3172af63fae493cc1","category":"receipts","description":null,"entry_id":56,"file_name":"r56.pdf","mime_type":"application/pdf","size_bytes":68},"prev":"0a953433470f13fba6e489fa10abd60e7b6483b57b4461133ab4ad093a908843","seq":365,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-11-18","description":"Software paid by member 11","id":56,"lines":[{"account":14,"amount":"489.10","currency":"USD","side":"debit"},{"account":8,"amount":"489.10","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"40f6bfba12a8454fc2f3b3d90c1d3c40c2be881baebe0366b592c397e4fb46d1","seq":366,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-11-23","description":"Bank fee 11","id":57},"prev":"68b996e35b3e97a95059bef317cb2d828353eef17c71c5fa7fa7b6816f4454e1","seq":367,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":57,"reason":"bank fee"},"prev":"8b6b7a8298429f51a2fb2a292243f31da54d5afa44a1a2106ef0e1824d20668c","seq":368,"ts":"2026-10-21T21:29:01Z","type":"entry.no_party"}
|
||||
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":57,"side":"debit"},"prev":"03cbc8ea6f20210066385046c3153caf4f339147b28075dd404769e23674e7a0","seq":369,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":57,"side":"credit"},"prev":"093db4b5c2dda279d565e1a52e412ff58b87cce5901c5df39be7787fad8efec6","seq":370,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":57,"blob_sha":"58132a2be4c24afc7e9fa6824021b731c1e7ab947950b330efaac5c23838a65f","category":"receipts","description":null,"entry_id":57,"file_name":"r57.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"5e69ad8e782af70a8916c65cb7044e1cdc9ab8b38a7999dcefc8142bf8c7ad69","seq":371,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-11-23","description":"Bank fee 11","id":57,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"ccd47b8a432670e86f576d0894c0288e8abf773811901cdecefb590acd90086b","seq":372,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-12-03","description":"Consulting 12","id":58},"prev":"5a869fd53ac45a8a6db2b2fddefa199472ca491de5225cc9a9651840075c82e4","seq":373,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":58,"party_id":5},"prev":"d4f4e8c092e759349398e17701cc3a8f1c6df7a386970db45145c429b12a6e77","seq":374,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
|
||||
{"payload":{"account":1,"amount":"718.93","currency":"USD","id":58,"side":"debit"},"prev":"7c4bb51ac57db7a1d5bb1c8bbc1c9849ba657b5d90208269e0c80f6224886e86","seq":375,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
|
||||
{"payload":{"account":12,"amount":"718.93","currency":"USD","id":58,"side":"credit"},"prev":"3bd4f070afd2f5e608553123084c96a9927f78d30ed480c3c93c10a255c98fab","seq":376,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":58,"blob_sha":"80107280d3e5d99e971770a018db9285c9e1e46498eab9dd77535cec612e7708","category":"receipts","description":null,"entry_id":58,"file_name":"r58.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"4ce4bb82870b6e9a1679b10621d158cb35e69ad39b63032809fb1a0cbddce7fb","seq":377,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-12-03","description":"Consulting 12","id":58,"lines":[{"account":1,"amount":"718.93","currency":"USD","side":"debit"},{"account":12,"amount":"718.93","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"9ec10febf57a82f705df03a25e7610527e6d4e84c1654a26cd984527ea969df0","seq":378,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-12-08","description":"Design work 12","id":59},"prev":"1322577f2db48ce75ae7a874ec48681ed33b709c7a68d8652294f22a82943f4a","seq":379,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":59,"party_id":3},"prev":"a7715accec23f324d8c6ea7fa92f9400d2d6792d5f319912da7b55916ad5d79c","seq":380,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
|
||||
{"payload":{"account":17,"amount":"83.29","currency":"USD","id":59,"side":"debit"},"prev":"b92370edeb9fbbc30c3dac971d0514ffd6492c88d7105b22e530fa9bda25354c","seq":381,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"83.29","currency":"USD","id":59,"side":"credit"},"prev":"c815de80d2a481c8e5b38881fe8bb947b6c9f439c05e08eb6987653ef8da5933","seq":382,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":59,"blob_sha":"e554361bcf2115a5a55b5ccb80f32fe860710dd679f917f99686d1171e49964d","category":"receipts","description":null,"entry_id":59,"file_name":"r59.pdf","mime_type":"application/pdf","size_bytes":56},"prev":"793ed5800359e366d9500a921eb1eb6de1b03fa1112cd8ce32bc2cb0af267e81","seq":383,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-12-08","description":"Design work 12","id":59,"lines":[{"account":17,"amount":"83.29","currency":"USD","side":"debit"},{"account":1,"amount":"83.29","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"0d759e13534b33038fbd4592af647111c9dc82a81be2106c803e64213a871e92","seq":384,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-12-13","description":"Supplies 12","id":60},"prev":"73580cfcac242f8c5266594daf1263def1f1a5813885071dd26ea85ed474e1da","seq":385,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":60,"party_id":4},"prev":"818998c07fbfb3e620d6a8c007a2933dae383d4b242767542431dca70f2adb2b","seq":386,"ts":"2026-10-21T21:29:02Z","type":"entry.party_set"}
|
||||
{"payload":{"account":13,"amount":"740.72","currency":"USD","id":60,"side":"debit"},"prev":"ca2b88a667f101307223e3d3a682e344ebbe0670c3e2e7bccba356b504e19724","seq":387,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
|
||||
{"payload":{"account":7,"amount":"740.72","currency":"USD","id":60,"side":"credit"},"prev":"c9758d36322d2439ad0842dd94c7a69ba2f2cc7f4f2788f6ec13a1f352d02511","seq":388,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":60,"blob_sha":"348514bbc9e4a658e2681f0981d359d8f8494de6b17459fc4d730503b5a19024","category":"receipts","description":null,"entry_id":60,"file_name":"r60.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"d9a8c135c5412fa3a9a9e87ad3714497788153b8df424d82463f91f7a160c3c1","seq":389,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-12-13","description":"Supplies 12","id":60,"lines":[{"account":13,"amount":"740.72","currency":"USD","side":"debit"},{"account":7,"amount":"740.72","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"975afeb93d9c8ae6b21c0b79082e9b3094b0df96ba4ab2965bb6fba929f6f099","seq":390,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-12-18","description":"Software paid by member 12","id":61},"prev":"e939e60e9d522abf781d651ff470cf4236af80d0d497177ac2907f2107a6f66f","seq":391,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":61,"party_id":1},"prev":"ca11d669a78b359190d893df4d57375c941549fd9ba3b0796deef9dfaa0f5b35","seq":392,"ts":"2026-10-21T21:29:02Z","type":"entry.party_set"}
|
||||
{"payload":{"account":14,"amount":"79.12","currency":"USD","id":61,"side":"debit"},"prev":"e1ef9eb8784d6a8159a2b06eb1e3b19ac8d9c4e5f65741f1d05ebffa91edb9f5","seq":393,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
|
||||
{"payload":{"account":8,"amount":"79.12","currency":"USD","id":61,"side":"credit"},"prev":"91d0bf2878f1b44bef06a569984a5a9e79445606da929eb6cf0ddc178562a107","seq":394,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":61,"blob_sha":"3339b8401edf750b6b34d0d90ec5b7a35fe8b6e26dba68d15b9c65f60d11e251","category":"receipts","description":null,"entry_id":61,"file_name":"r61.pdf","mime_type":"application/pdf","size_bytes":68},"prev":"04399b473dc1425a688aaf3159cbd88cd78fca54a7f8db5b99c9e215da838aa7","seq":395,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-12-18","description":"Software paid by member 12","id":61,"lines":[{"account":14,"amount":"79.12","currency":"USD","side":"debit"},{"account":8,"amount":"79.12","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"0b280a4a27211eacf8d5c7e25b795fdf959ec3b8b69fde307fad7fb0ccc925a9","seq":396,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-12-23","description":"Bank fee 12","id":62},"prev":"27f88d75fc1e88cd66d408e5fa89a9d5b818e4baaa71ff6ab154650f393c851b","seq":397,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":62,"reason":"bank fee"},"prev":"f76c2e6adeb67d7760aadedb534445d632030c9bd12292dac5de5b2b2828ae2a","seq":398,"ts":"2026-10-21T21:29:02Z","type":"entry.no_party"}
|
||||
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":62,"side":"debit"},"prev":"c2f347917a69a154836e7ddf4dec3fe2ab1e15cf2a8f8225c36ebc969ce65328","seq":399,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":62,"side":"credit"},"prev":"2601edb1df9aaf7fcf821ffea0a5aeecb064cc66cb246817e32d9daf265775c9","seq":400,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":62,"blob_sha":"8d95aa1b0d183a31141f7093bc4ef2dcf0b165d42625efe2c50fac9bba78bd7f","category":"receipts","description":null,"entry_id":62,"file_name":"r62.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"c21f7b405b5f50ef6c104c456e7ab42439f4ab617bae4323cc5e7cfb733ad667","seq":401,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-12-23","description":"Bank fee 12","id":62,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"97b789402ca050d2816977e1866958a0206c2682e97cd84c4dab37cffa0fad02","seq":402,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2025-03-01","description":"Laptop","id":63},"prev":"dd3373bb32e5656fa6c7b14e46fbcd0af332c111c79fdae8c6a33585d2203f2f","seq":403,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":63,"party_id":4},"prev":"ed9d7324d7524ed6c45eb23fc8efbd43984855d7710d75a9aedb7cbf8b325fc4","seq":404,"ts":"2026-10-21T21:29:02Z","type":"entry.party_set"}
|
||||
{"payload":{"account":3,"amount":"2400.00","currency":"USD","id":63,"side":"debit"},"prev":"65c34e5cbb2233e08a10fc5fa93334dcfd1cd2d1c6431f004d4b7a7b982a15c6","seq":405,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"2400.00","currency":"USD","id":63,"side":"credit"},"prev":"478214a7e48572598610da9801e4e4bd07d99b8fa0ac230474828c15b195ec47","seq":406,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":63,"blob_sha":"8676902d877e2c45200f351eb1671f62e598df60d83bb2f3da74f60d85a76d5f","category":"receipts","description":null,"entry_id":63,"file_name":"r63.pdf","mime_type":"application/pdf","size_bytes":48},"prev":"77bda531c990996596b82a1155feefa838a7b39b9b218ec5fc289508c7cc0587","seq":407,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-03-01","description":"Laptop","id":63,"lines":[{"account":3,"amount":"2400.00","currency":"USD","side":"debit"},{"account":1,"amount":"2400.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"276fde3703b75331f11e24df3022332cc285f72e27020cc8e1e13528f80c988e","seq":408,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
|
||||
{"payload":{"accum_depr_account":4,"cost":{"amount":"2400.00","currency":"USD"},"cost_account":3,"description":"Laptop","expense_account":15,"id":1,"method":"straight_line","purchase_date":"2025-03-01","salvage_value":{"amount":"0","currency":"USD"},"useful_life_months":36},"prev":"c69cdd6ff196ad5da8f6d7b3242681868f9a1ba4fd42dffb14922ba9c8ce6d2a","seq":409,"ts":"2026-10-21T21:29:02Z","type":"asset.registered"}
|
||||
{"payload":{"asset_id":1,"previous_tag":null,"tag":"FL-0001"},"prev":"3cfb8d20209d19fc8b1718379037da8f3eca780c182a006fe0a000ac4ceb87b0","seq":410,"ts":"2026-10-21T21:29:02Z","type":"asset.tagged"}
|
||||
{"payload":{"asset_id":1,"entry_id":63},"prev":"569bf2e5019bf47db07a22fd34725d254544f5d2faa1286884f46caaa98bd54a","seq":411,"ts":"2026-10-21T21:29:02Z","type":"asset.acquisition_linked"}
|
||||
{"payload":{"date":"2025-03-31","description":"Depreciation — Laptop — 2025-03","id":64},"prev":"22809023c78b048f64008e4e40402ea222a6d5a257112db2f224ca26d3ee41e7","seq":412,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
|
||||
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":64,"side":"debit"},"prev":"863171f7c7793cebbb32a192056eaff8b28f4dfc79c99a8367f92d6eec49378f","seq":413,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":64,"side":"credit"},"prev":"2bee4b7f54dc66c67eb2655fd936adfb57f8d05555cf77cb6b48989d4255821f","seq":414,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2025-03-31","description":"Depreciation — Laptop — 2025-03","id":64,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"48ded01bfc4b8265a49204c4fb8372f99481ba5f3d656dbe498f3d332e47c448","seq":415,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"66.67","asset_id":1,"entry_id":64,"period":"2025-03"},"prev":"c30add1e4ead7b0dcb3948bb83b5244a30b9c733a92ec4732999532b5c9eae59","seq":416,"ts":"2026-10-21T21:29:02Z","type":"asset.depreciation_posted"}
|
||||
{"payload":{"date":"2025-04-30","description":"Depreciation — Laptop — 2025-04","id":65},"prev":"40653fd1e994a584030625fe577665c5db4b2c8ea70772b2ad6ac7dc154ea25d","seq":417,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
|
||||
{"payload":{"account":15,"amount":"66.66","currency":"USD","id":65,"side":"debit"},"prev":"67bc4b24d3cc20a221c1bd14d02c4cf5b7dc19333d187541e94dd29e72a5d6e5","seq":418,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"66.66","currency":"USD","id":65,"side":"credit"},"prev":"3e94b0205bc23dd2f530689d6aca732d68235e20f46ae8416fb03bb729306811","seq":419,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2025-04-30","description":"Depreciation — Laptop — 2025-04","id":65,"lines":[{"account":15,"amount":"66.66","currency":"USD","side":"debit"},{"account":4,"amount":"66.66","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b4b04853c7e85f6f7bab1b542e904ac054d6778e9159a47d0b2b921990f3e8ab","seq":420,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"66.66","asset_id":1,"entry_id":65,"period":"2025-04"},"prev":"981a7a51a034c228969d13ab2eab20613a2bd57e01b28b75f9ed17bbb0c4076c","seq":421,"ts":"2026-10-21T21:29:02Z","type":"asset.depreciation_posted"}
|
||||
{"payload":{"date":"2025-05-31","description":"Depreciation — Laptop — 2025-05","id":66},"prev":"41caf1b729a253a90ca48b694c895a8ad078dfd27ec5e75bb362aa1c79ec402f","seq":422,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
|
||||
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":66,"side":"debit"},"prev":"69d804f402662361cf9ba7135420e11f2ebaa014824df99ceaa1afbdc384d620","seq":423,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":66,"side":"credit"},"prev":"d3019602e2c0e2cf92840eb6c4b9ba77132b79b071cc88e5e928a5525d5a7f0a","seq":424,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2025-05-31","description":"Depreciation — Laptop — 2025-05","id":66,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"f1c7cabf8106ace4486696906ca27948644fea2149c5607191ef8b6426ecaaf9","seq":425,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"66.67","asset_id":1,"entry_id":66,"period":"2025-05"},"prev":"291bfae8f7cdf9132827647ab388af8708a400945bd33bfd98518f43717f6ae2","seq":426,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
|
||||
{"payload":{"date":"2025-06-30","description":"Depreciation — Laptop — 2025-06","id":67},"prev":"1e5895685f8c6248412f13ec2af34949a24b4493efb3e322db370ca7c76f0207","seq":427,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
|
||||
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":67,"side":"debit"},"prev":"94c19beb788960587d6c44f9c7c7294bde4813e015d9444803e3409671371456","seq":428,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":67,"side":"credit"},"prev":"1e5159ac81c1951152d1d871d112658e7c239aa6ec50affc82291136151057d0","seq":429,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2025-06-30","description":"Depreciation — Laptop — 2025-06","id":67,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"04efed2f4e6761c9588b9316d64122f3672e1fed213b1a2f3f1f2868c4b60444","seq":430,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"66.67","asset_id":1,"entry_id":67,"period":"2025-06"},"prev":"5584eb6e1aeeb38029e118670e8379df574a4a499acd38e7158c8b5b45594092","seq":431,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
|
||||
{"payload":{"date":"2025-07-31","description":"Depreciation — Laptop — 2025-07","id":68},"prev":"d738713444f0c465415e5248388ce05b4bd114e6f0fa6570cdc66cc70e7c2ea2","seq":432,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
|
||||
{"payload":{"account":15,"amount":"66.66","currency":"USD","id":68,"side":"debit"},"prev":"8760646dd6d3d731d83b7944c8aa1865ff0aa0c57f5b08daf649b6acb9f68232","seq":433,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"66.66","currency":"USD","id":68,"side":"credit"},"prev":"f75caf0d430852a76f6225a72baab3cc1639d87d6e64ae13f728685a7b19ccca","seq":434,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2025-07-31","description":"Depreciation — Laptop — 2025-07","id":68,"lines":[{"account":15,"amount":"66.66","currency":"USD","side":"debit"},{"account":4,"amount":"66.66","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"e2d21284301eb08363234118bda170f78a5bc6712355398132b57f61ebc90ccf","seq":435,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"66.66","asset_id":1,"entry_id":68,"period":"2025-07"},"prev":"dda1ba8941f6686fc22bafec2e0daa0f9d62a5b5be9ed100670713526d4548c4","seq":436,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
|
||||
{"payload":{"date":"2025-08-31","description":"Depreciation — Laptop — 2025-08","id":69},"prev":"4ab1cdd0071cb84e114c5bc461bbafe0969bb845bbb05f070db067dffeb9e371","seq":437,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
|
||||
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":69,"side":"debit"},"prev":"95bad6ff059f689293534f0f19cc7b57284f0125fa65e6c1e76e8341f5bd66f4","seq":438,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":69,"side":"credit"},"prev":"257ffe0e663df221be536ab21c9429b5ad663f8e3140b256575f4f1ac14f3795","seq":439,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2025-08-31","description":"Depreciation — Laptop — 2025-08","id":69,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"52e67b69798e6e42e4d721c942fc9c661cad3c9dd21e88cebd0968312708e796","seq":440,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"66.67","asset_id":1,"entry_id":69,"period":"2025-08"},"prev":"3d7052193c152126591fdc07e445276ea04114b10f1db38c18029d34c6a1a457","seq":441,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
|
||||
{"payload":{"date":"2025-09-30","description":"Depreciation — Laptop — 2025-09","id":70},"prev":"df0d66ff32bdb0c201e38864413b65321da625ddedc49f131345e8a17d7dc6af","seq":442,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
|
||||
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":70,"side":"debit"},"prev":"353f3e42cc1f1e5c191d9f344cb26c06456fc8c601f4ae86e0420122afb805ca","seq":443,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":70,"side":"credit"},"prev":"545d4a21746f97d8460679b2d01fecf161df220d4fbcefcce23786be972b2f01","seq":444,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2025-09-30","description":"Depreciation — Laptop — 2025-09","id":70,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5515388f429b3bb9234967689c10701f688ffbcacb9eb42e507a56b910a5eca4","seq":445,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"66.67","asset_id":1,"entry_id":70,"period":"2025-09"},"prev":"134600a07a7f9be26ef29d9b3609d6103c22dd5a5acefadd121c5769bec1ab82","seq":446,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
|
||||
{"payload":{"date":"2025-10-31","description":"Depreciation — Laptop — 2025-10","id":71},"prev":"9a088a890a8286cadc7d0df46ef42e343eb5ded8c739fee6ae77bb4eb11cefce","seq":447,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
|
||||
{"payload":{"account":15,"amount":"66.66","currency":"USD","id":71,"side":"debit"},"prev":"cdb6b65327e40050fa39cd7d8c32c9e499129fc06a512b945bb94124b34fa548","seq":448,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"66.66","currency":"USD","id":71,"side":"credit"},"prev":"1b763138e97fbb88aac5d9ecf34d2c6426530408b4b8986b797442ce82b88f61","seq":449,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2025-10-31","description":"Depreciation — Laptop — 2025-10","id":71,"lines":[{"account":15,"amount":"66.66","currency":"USD","side":"debit"},{"account":4,"amount":"66.66","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8f55d708971978b7818f921861f031617adaa6d794f4a369c0137c6be77e6c1d","seq":450,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"66.66","asset_id":1,"entry_id":71,"period":"2025-10"},"prev":"27bfba00c4431cefff8e551102f87feaddbc00e4ac4ec8081171b0f357a8cb4c","seq":451,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
|
||||
{"payload":{"date":"2025-11-30","description":"Depreciation — Laptop — 2025-11","id":72},"prev":"5f05265a9f7207106edef9d8e570d4e63b9f748e6fd382b66b7cb4fd5f026611","seq":452,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
|
||||
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":72,"side":"debit"},"prev":"c5ba941fc07e87c4698b2de0ecf3cc982dc3446084fe99332502a182e006692a","seq":453,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":72,"side":"credit"},"prev":"a77601eb122a34e9d816a28d91efd9c6546f1ab56bad24fca07be761fa51b6b5","seq":454,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2025-11-30","description":"Depreciation — Laptop — 2025-11","id":72,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"d630324206e2658b2855228bded486e09580cda4901906a9cb37ac1c3827694d","seq":455,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"66.67","asset_id":1,"entry_id":72,"period":"2025-11"},"prev":"1b5195c8a8d25c63523207169bf4e1bcc31b9b1e381ccac28ac910cd45ec0bd4","seq":456,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
|
||||
{"payload":{"date":"2025-12-31","description":"Depreciation — Laptop — 2025-12","id":73},"prev":"5b1227aee10a524c14b49637a7423676999a21a95f161164dc6cc4890d172624","seq":457,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
|
||||
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":73,"side":"debit"},"prev":"e21de75b07cc38719cd1eb40177a876faf4b84dfe9791606dc50315750a7cbdf","seq":458,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":73,"side":"credit"},"prev":"b8a2f7d8cd30e888fa68f883989190bf6635a4f7f4c90840b6a8a7bb0ef3bda8","seq":459,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2025-12-31","description":"Depreciation — Laptop — 2025-12","id":73,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"474e2016cd9deff5c0fcd864ba32f4abce897c35050378958f2eed8342e15e06","seq":460,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"66.67","asset_id":1,"entry_id":73,"period":"2025-12"},"prev":"4496340b38dcfe3c449c5a7d5ce15e98f8052f6130405dd209a54fc3e155d04d","seq":461,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
|
||||
{"payload":{"date":"2025-02-01","description":"Insurance year","id":74},"prev":"8a3ce2e06e292d10ea2648daf59f15b845ff4954f5b071e7d0d034ee31919b9f","seq":462,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
|
||||
{"payload":{"entry_id":74,"party_id":4},"prev":"8b3a320f0a1931010797ebae04ea284d7afc902376e5528a1e42cab2bf6dfbf5","seq":463,"ts":"2026-10-21T21:29:03Z","type":"entry.party_set"}
|
||||
{"payload":{"account":2,"amount":"1200.00","currency":"USD","id":74,"side":"debit"},"prev":"bfb05eb96932c13c0bf9f6088f96e5456753d8abe274282d0c5b71bf72c3d45d","seq":464,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"1200.00","currency":"USD","id":74,"side":"credit"},"prev":"0e46904b35239d223507dd3249a48e72abf8c2b72787e5a71577285dc7a3fd13","seq":465,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
|
||||
{"payload":{"attachment_id":64,"blob_sha":"407b7b6f86ec969d83c5370e7a2f639522bbdb7bceafb7cb3dad36a384c2f4ba","category":"receipts","description":null,"entry_id":74,"file_name":"r64.pdf","mime_type":"application/pdf","size_bytes":56},"prev":"fa55ae4bf5fbf7d8e65d0bfa4466c6ba6675e0e33cf08c52e0536c4e734e359c","seq":466,"ts":"2026-10-21T21:29:03Z","type":"receipt.attached"}
|
||||
{"payload":{"date":"2025-02-01","description":"Insurance year","id":74,"lines":[{"account":2,"amount":"1200.00","currency":"USD","side":"debit"},{"account":1,"amount":"1200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"39cc2c369dec884abebbaf2afa842e530c0a7d2f9c928cfa658e3ba2dc6877fe","seq":467,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
|
||||
{"payload":{"balance_account":2,"description":"Insurance","id":1,"kind":"prepaid_expense","months":12,"pl_account":16,"start":"2025-02-01","total":{"amount":"1200.00","currency":"USD"}},"prev":"7576a892214455b4f54a9ba4d3a54b31d6e72f95b3c2d57b197bdd86d54de240","seq":468,"ts":"2026-10-21T21:29:03Z","type":"schedule.registered"}
|
||||
{"payload":{"end":"2025-03-31","id":1,"name":"2025-Q1","start":"2025-01-01"},"prev":"ff0fc0c6c7bc3537bd06b5a1e73d325af2e183e54a75a441e20a740db0bb5cbd","seq":469,"ts":"2026-10-21T21:29:03Z","type":"period.created"}
|
||||
{"payload":{"close_to":{"members":[{"account":9,"distributions":null,"share":"0.5"},{"account":10,"distributions":null,"share":"0.5"}],"rounding":"cumulative"},"closing_entry":75,"entry":{"date":"2025-03-31","description":"Close period 2025-Q1","id":75,"lines":[{"account":12,"amount":"598.41","currency":"USD","side":"debit"},{"account":13,"amount":"961.27","currency":"USD","side":"credit"},{"account":14,"amount":"1384.64","currency":"USD","side":"credit"},{"account":15,"amount":"66.67","currency":"USD","side":"credit"},{"account":17,"amount":"1568.21","currency":"USD","side":"credit"},{"account":9,"amount":"1691.19","currency":"USD","side":"debit"},{"account":10,"amount":"1691.19","currency":"USD","side":"debit"}]},"id":1},"prev":"7c2011737f719566576cb5834961a755e285f77373c976213bf976e3f1ca4622","seq":470,"ts":"2026-10-21T21:29:03Z","type":"period.closed"}
|
||||
@@ -1 +0,0 @@
|
||||
bebe2eda9edb31285dbc4d6ae7a3761d88e260af13a929ba8f1b97baf3d83178
|
||||
@@ -1 +0,0 @@
|
||||
WBV14\�8�>F+��(���PC7A��7��Y��maM��l��M6/K_�rt�p���K����Uz<��6f�l��!g�
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large.
Load diff
Loaded 100 of 727 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user