Metadata-Version: 2.5
Name: fava-intransit
Version: 0.1.1
Summary: Fava extension: Sankey diagram and consistency checks for a transit account
Author: gio
License: MIT
License-File: LICENSE
Keywords: beancount,fava,plaintextaccounting,sankey
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial :: Accounting
Requires-Python: >=3.10
Requires-Dist: beancount>=3
Requires-Dist: fava>=1.30
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: twine; extra == 'dev'
Description-Content-Type: text/markdown

# fava-intransit

A [Fava](https://beancount.github.io/fava/) extension that maps the flows through a
transit account, and tells you where the chain is broken.

Money moved between two institutions is booked twice, once on each side, against a
transit account such as `Assets:Transfer:InTransit`:

```beancount
2020-02-01 * "transfer out to Beta"
  Assets:Bank:Alpha:Checking  -1000.00 EUR
  Assets:Transfer:InTransit

2020-02-04 * "transfer in from Alpha"
  Assets:Bank:Beta:Checking    1000.00 EUR
  Assets:Transfer:InTransit
```

The two legs come from two different statements and are usually imported days apart,
so the transit account is where half-finished transfers pile up. This extension pairs
the legs up and reports what is left over:

- **unmatched legs** — money that left an institution and never arrived, or arrived
  without having been sent. Usually a statement you have not imported yet;
- **transfers that skip the transit account** — the opposite mistake, one institution
  booked straight against another.

Both are drawn as a **Sankey diagram** of institution-to-institution flows, so a broken
chain shows up as a red ribbon ending on a dedicated *Unmatched* node instead of as a
line in a list.

## Install

```bash
pip install fava-intransit
```

## Enable

Add a `custom` directive to your ledger:

```beancount
2010-01-01 custom "fava-extension" "fava_intransit" "{
  'transit_account': 'Assets:Transfer:InTransit',
  'days': 5
}"
```

Every key is optional — with `"{}"` (or no config string at all) the defaults above
apply. The report then appears in Fava's sidebar as **In Transit**.

| key | default | meaning |
| --- | --- | --- |
| `transit_account` | `Assets:Transfer:InTransit` | the account transfers pass through |
| `days` | `5` | how far apart two legs of the same transfer may be dated |
| `institution_level` | `3` | which account component names the institution: with `3`, `Assets:Bank:ING:ContoCorrente` is `ING` |
| `institutions` | `{}` | account prefix → name, when the level rule is not enough |

`days` can also be changed from the report itself, without editing the ledger.

## Which accounts are checked

The set of accounts that are *supposed* to settle through the transit account is
derived from the ledger, not configured: an account joins it as soon as it has been
booked against the transit account even once.

That is what keeps payment wallets out of the report. A wallet topped up by direct
debit is meant to face the bank account directly, and a naive check over every
`Assets:Bank:*` account would flag every single top-up.

## Command line

The same analysis, as text, for use from a Makefile or at the end of an import script:

```bash
fava-intransit main.beancount --days 5
```

```python
from fava_intransit.core import Config, analyze

analysis = analyze(entries, Config(transit_account="Assets:Transfer:InTransit"))
print(len(analysis.unmatched), "legs without a counterpart")
```

`fava_intransit.core` does no I/O: it takes entries you have already loaded, which is
also how the Fava extension avoids parsing the ledger a second time on every request.

## Notes

- Pairing is greedy, by growing date distance: same-day pairs are settled before
  one-day-apart ones, and so on up to `days`. Two legs on the **same account**
  are never paired - an account does not wire to itself, and treating an
  unrelated credit as the counterpart of an outgoing wire produces two false
  orphans instead of one real pair.
- Pairing always runs over the **whole** ledger. Fava's time filter narrows the diagram
  and the tables afterwards, so that a transfer straddling the edge of the filtered
  period is not reported as unmatched.
- One diagram per currency.
- Amounts follow Fava's own `locale` option, in the diagram as well as in the
  tables: with `custom "fava-option" "locale" "it_IT"` they read `1.111.111,33`.
  Nothing here formats numbers on its own.
- No JavaScript and no bundled assets: the Sankey layout is computed in Python and
  emitted as plain SVG, themed with Fava's own CSS variables.

## Releasing a new version

```bash
# 1. bump `version` in pyproject.toml, then
python -m pytest
git commit -am "release 0.2.0" && git tag v0.2.0

# 2. build and check
rm -rf dist && python -m build && twine check --strict dist/*

# 3. rehearse on TestPyPI, install it back in a throwaway venv
twine upload -r testpypi dist/*
python -m venv /tmp/rehearse && /tmp/rehearse/bin/pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ fava-intransit

# 4. the real thing
twine upload dist/*
git push && git push --tags
```

Things that bite:

- **A version number is spent the moment it is uploaded.** PyPI refuses to
  overwrite one, and deleting a release does not free the number either. A
  mistake in 0.2.0 is fixed by 0.2.1, never by re-uploading. That is what step 3
  is for: TestPyPI is a separate site with its own account and its own token.
- **The README is the package page**, rendered from the sdist metadata. Read it
  as a stranger before uploading, and keep the author's own ledger out of the
  examples.
- **Check what is actually in the artifacts**, not what you think is:
  `tar tzf dist/*.tar.gz` and `unzip -l dist/*.whl`. `templates/InTransit.html`
  must be in the wheel - without it the extension returns 500 on its own page,
  and no test catches that because the tests never render the template.
- `twine` reads `~/.pypirc` for the token; `pip` never does.

`~/.pypirc`, mode 600, `username` literally `__token__`:

```ini
[distutils]
index-servers = pypi testpypi

[pypi]
username = __token__
password = pypi-<token>

[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-<token from test.pypi.org>
```

Once the source is hosted somewhere publicly reachable, add it to
`[project.urls]` in `pyproject.toml`: it becomes the link on the package page,
which right now has none.

## Changelog

### 0.1.1

- Fixed pairing that could match two legs sitting on the **same account**. An
  outgoing wire would take an unrelated credit that happened to have the same
  amount as its counterpart, and the leg it should have paired with was then
  reported as missing - so the report pointed at the wrong institution.
- Candidates are now tried by growing date distance, so a leg several days away
  no longer takes a counterpart that a same-day leg needed.

### 0.1.0

- First release.

## License

MIT
