Authors: | Florian Dupeyron <[email protected]> |
---|
SympaCompta is a python toolkit aiming at simplfying your personnal accounting workflow by packing easy-to-use tools as python modules. You can build custom scripts and interfaces on top of that, for example to manage transactions, generate various reports, or integrate data from other sources (stock markets for instance).
- pandas
- numpy
- money (the package, not the actual thing)
This toolkit implements a double-entry like bookkeeping system, inspired by Gnucash. This means:
- Everything is an account (no category system) ;
- For every transaction, there is an "origin" account, and a "target" account. For instance, your checking account, to the account representing your rent.
There are currently four major types of accounts:
- Assets
- Liabilities
- Income
- Outcome
No Equity type is currently implemented. The equity can be checked by user scripts.
Let's take some basic examples:
- Your checking account is an asset account ;
- Your rent account is an outcome account ;
- Your salary account is an income account ;
- Your loan account is a liability account.
The key to understand the double entry system is to remember the following equation:
equity - outcome = assets - liability + income
Transactions have the following information:
- The day of the transaction;
- An optional time;
- The source account;
- The destination account;
- The amount;
- An optional tag
As this toolkit is intended for personal accounting, a canonical period of 1 month is defined to group the transactions. This is why only the day is stored.
The recommended folder hierarchy is the following:
root_folder ├── accounts │ ├── assets │ │ ├── common │ │ │ └── checking.toml │ │ ├── person1 │ │ │ ├── cash.toml │ │ │ └── checking.toml │ │ └── person2 │ │ ├── cash.toml │ │ └── checking.toml │ ├── income │ │ ├── person1 │ │ │ └── salary.toml │ │ └── person2 │ │ └── salary.toml │ ├── liabilities │ │ └── common │ │ └── house_loan.toml │ └── outcome │ ├── common │ │ └── household │ │ ├── food.toml │ │ ├── internet.toml │ │ ├── rent.toml │ │ └── tv.toml │ ├── person1 │ │ ├── food.toml │ │ └── phone.toml │ └── person2 │ ├── food.toml │ └── phone.toml ├── periods │ ├── 2022-01 │ │ └── transactions.csv │ ├── 2022-02 │ │ └── transactions.csv │ ├── 2022-03 │ │ └── transactions.csv │ ├── 2022-04 │ │ └── transactions.csv │ └── 2022-05 │ └── transactions.csv ├── sources │ ├── 2022-01 │ │ ├── account1.qif │ │ └── account2.ofx │ └── 2022-02 │ ├── account1.qif │ └── account2.ofx └── reports ├── report1.py └── report2.py
As all used files are text based, it should be well suited to be used with version control software, like git!
Transactions for 1 period (= 1 month) are stored in a CSV file. For example:
day;time;label;from;to;amount;label;tag
2;;Salary may;income/person1/salary;assets/person1/checking;EUR 1000.000;Salary stuff;salary
3;;Rent may;assets/person1/checking;outcome/common/household/loan;EUR 300.0;Truc;rent
5;12:00;Lunch;assets/person1/checking;outcome/person1/food;10.00;;
Please note:
- Accounts are identified by their relative path from the accounts folder, without the extension. This is called the account slug;
- Currency information is given in the amount field;
- This specific header is mandatory.
As the CSV format is pretty common, you can use your favorite tool to edit it. For instance, if you want easy remote editing, you could use google sheets (untested at this point).
The recommended format to describe an account in a toml file is the following:
[account]
name="Checking acount for Person1"
type="assets"
At this moment, only these two fields are mandatory and available.
The purpose of the following script is to compute the final balance of all the assets, and to fix the amounts to save in various accounts:
import argparse
import logging
from pathlib import Path
from decimal import Decimal
import scompta.db.accounts as accounts_db
import scompta.db.transactions as transactions_db
import scompta.views.transactions as transactions_views
# Helper to parse directories
def __parse_dir(x):
dpath = Path(x)
if not dpath.is_dir():
raise NotADirectoryError(x)
else:
return dpath
# Root directory
root_dir = (Path(__file__).parent / "..").resolve()
# Parse arguments
parser = argparse.ArgumentParser(description="Report saving strategy for the given period")
parser.add_argument("folder", type=__parse_dir)
args = parser.parse_args()
# Load accounts
df_accs = accounts_db.load_from_dir(root_dir / "accounts")
df_transactions = transactions_db.load(args.folder / "transactions.csv")
# Find assets accounts
df_accs_assets = accounts.with_type(accounts.Account_Type.Assets)
# Find gain and losses transactions
t_in = transactions_views.input (df_transactions, df_accs, df_accs_assets.index)
t_out = transactions_views.output(df_transactions, df_accs, df_accs_assets.index)
# Compute total gains and losses, and final balance
v_in = t_in["amount"].sum()
v_out = t_out["amount"].sum()
v_bal = v_in - v_out
# Print stuff
print()
print("# ──────────────── Amounts ─────────────── #")
print()
print(f"Gains: {v_in}")
print(f"Losses: {v_out}")
print(f"Available for savings: {v_bal}")
print()
print("# ──────────────── Savings ─────────────── #")
print()
v_savings_1 = v_bal*Decimal(0.2) # 20% for one account
v_savings_2 = v_bal*Decimal(0.5) # 50% for another account
v_savings_3 = v_bal*Decimal(0.1) # 10% for this one
v_savings_4 = v_bal*Decimal(0.2) # 20% for this one
print(f"Account 1: {v_savings_1}")
print(f"Account 2: {v_savings_2}")
print(f"Account 3: {v_savings_3}")
print(f"Account 4: {v_savings_4}")
This example shows how the importer modules can be used to help import various account files into a transaction list.
from argparse import ArgumentParser
from dataclasses import asdict
from scompta.db import transactions as strans
from scompta import importer
import pandas as pd
import logging
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("Sources processor")
parser = ArgumentParser(description="Import files into period")
parser.add_argument("period", help="target period")
args = parser.parse_args()
log.info(f"Process period {args.period}")
transactions = []
transactions += importer.ofx.from_file("accounts/assets/person1/checking", f"sources/{args.period}/person1/checking.ofx")
transactions += importer.qif.from_file("accounts/assets/person2/checking", f"sources/{args.period}/person2/checking.qif")
# FIXME # Importers should return a dataframe?
df = pd.DataFrame.from_records([asdict(x) for x in transactions])
# Backup?
output_path = Path(f"periods/{args.period}/transactions.csv")
if output_path.is_file():
dt = datetime.now()
backup_path = output_path.parent / f"{output_path.stem}-{dt.year}-{dt.month}-{dt.day}.csv"
log.warn(f"{output_path} already exists, backup to {backup_path}")
backup_path.write_bytes(output_path.read_bytes())
log.info(f"Writing to {output_path}")
strans.save(output_path, df)