Compare commits

...

10 Commits

Author SHA1 Message Date
1d256d7def
Adds links between transactions
Sometimes transctions are directly related to one another w/o being of
an equal value, e.g. someone pays for the meal w/ the CC, and everyone
pays him/her.

Clear leftover __repr__ methods in the model classes, the database
decorator will create those automatically.
2022-12-19 22:48:49 +00:00
f20cf685ad
Update Foreign Key delete cascade for categories 2022-12-19 22:10:06 +00:00
e57859f601
Updates verbosity options
Now it's used by adding -v to the command called. No verbosity=quiet.
2022-12-19 22:02:11 +00:00
e27f2f08cf
Adds rule based tagging of transactions
Tags will work as additional categories to filter/organize by. It makes
sense they can also be rule based.
Since rules are common to both categories and tags, reorganize the
classes in the model. It doesn't affect the DB.
2022-12-19 21:59:41 +00:00
058d0cc05d
Adds Tags rules
Creates an available tags table to cross reference to.
Clarifies some logic in the DbClient, since all adds/removes are the
same.
2022-12-19 20:35:18 +00:00
6c33a94a5f
Change min_amount/max_amount columns for min/max
Simplifies the action creation.
2022-12-19 20:35:18 +00:00
a2b2f2c1d1
Adds modify rule operation 2022-12-18 00:48:45 +00:00
7986fe8e5b
[Fix] argparse group option not an array 2022-12-17 20:59:40 +00:00
17d8d5d813
[Fix] Changes rule's money type to Decimal
Also fixes comparison w/ max amount.
2022-12-17 20:48:16 +00:00
72a8995fe6
Adds regex rule and remove rule option
Categorization rules can now search using a regex pattern.
2022-12-10 18:54:16 +00:00
13 changed files with 697 additions and 116 deletions

View File

@ -0,0 +1,32 @@
"""Regex rule
Revision ID: 0ce89e987770
Revises: 7adf89ec8d14
Create Date: 2022-12-10 14:00:49.418494+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0ce89e987770"
down_revision = "7adf89ec8d14"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"categories_rules",
sa.Column("regex", sa.String(), nullable=True),
schema="transactions",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("categories_rules", "regex", schema="transactions")
# ### end Alembic commands ###

View File

@ -0,0 +1,49 @@
"""Rule min/max
Revision ID: 753c0bfb2062
Revises: e36e6321568e
Create Date: 2022-12-18 00:24:03.861461+00:00
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "753c0bfb2062"
down_revision = "e36e6321568e"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"categories_rules",
"min_amount",
new_column_name="min",
schema="transactions",
)
op.alter_column(
"categories_rules",
"max_amount",
new_column_name="max",
schema="transactions",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"categories_rules",
"min",
new_column_name="min_amount",
schema="transactions",
)
op.alter_column(
"categories_rules",
"max",
new_column_name="max_amount",
schema="transactions",
)
# ### end Alembic commands ###

View File

@ -0,0 +1,46 @@
"""Links
Revision ID: 8cc9870b0d74
Revises: a910e1b2214d
Create Date: 2022-12-19 22:10:25.136479+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "8cc9870b0d74"
down_revision = "a910e1b2214d"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"links",
sa.Column("original", sa.BigInteger(), nullable=False),
sa.Column("link", sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(
["link"],
["transactions.originals.id"],
name=op.f("fk_links_link_originals"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["original"],
["transactions.originals.id"],
name=op.f("fk_links_original_originals"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("original", "link", name=op.f("pk_links")),
schema="transactions",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("links", schema="transactions")
# ### end Alembic commands ###

View File

@ -0,0 +1,68 @@
"""Tag rules
Revision ID: 9028b0f3b985
Revises: 753c0bfb2062
Create Date: 2022-12-18 22:53:13.334046+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "9028b0f3b985"
down_revision = "753c0bfb2062"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"tags_available",
sa.Column("name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("name", name=op.f("pk_tags_available")),
schema="transactions",
)
op.create_table(
"tag_rules",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("tag", sa.String(), nullable=False),
sa.Column("date", sa.Date(), nullable=True),
sa.Column("description", sa.String(), nullable=True),
sa.Column("regex", sa.String(), nullable=True),
sa.Column("bank", sa.String(), nullable=True),
sa.Column("min", sa.Numeric(precision=16, scale=2), nullable=True),
sa.Column("max", sa.Numeric(precision=16, scale=2), nullable=True),
sa.ForeignKeyConstraint(
["tag"],
["transactions.tags_available.name"],
name=op.f("fk_tag_rules_tag_tags_available"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_tag_rules")),
schema="transactions",
)
op.create_foreign_key(
op.f("fk_tags_tag_tags_available"),
"tags",
"tags_available",
["tag"],
["name"],
source_schema="transactions",
referent_schema="transactions",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(
op.f("fk_tags_tag_tags_available"),
"tags",
schema="transactions",
type_="foreignkey",
)
op.drop_table("tag_rules", schema="transactions")
op.drop_table("tags_available", schema="transactions")
# ### end Alembic commands ###

View File

@ -0,0 +1,56 @@
"""Rule inheritance
Revision ID: a910e1b2214d
Revises: 9028b0f3b985
Create Date: 2022-12-19 20:48:04.682812+00:00
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "a910e1b2214d"
down_revision = "9028b0f3b985"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(
"fk_categorized_name_categories_available",
"categorized",
schema="transactions",
type_="foreignkey",
)
op.create_foreign_key(
op.f("fk_categorized_name_categories_available"),
"categorized",
"categories_available",
["name"],
["name"],
source_schema="transactions",
referent_schema="transactions",
ondelete="CASCADE",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(
op.f("fk_categorized_name_categories_available"),
"categorized",
schema="transactions",
type_="foreignkey",
)
op.create_foreign_key(
"fk_categorized_name_categories_available",
"categorized",
"categories_available",
["name"],
["name"],
source_schema="transactions",
referent_schema="transactions",
)
# ### end Alembic commands ###

View File

@ -0,0 +1,58 @@
"""Rules min/max money
Revision ID: e36e6321568e
Revises: 0ce89e987770
Create Date: 2022-12-10 18:55:07.149010+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "e36e6321568e"
down_revision = "0ce89e987770"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"categories_rules",
"min_amount",
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=16, scale=2),
existing_nullable=True,
schema="transactions",
)
op.alter_column(
"categories_rules",
"max_amount",
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=16, scale=2),
existing_nullable=True,
schema="transactions",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"categories_rules",
"max_amount",
existing_type=sa.Numeric(precision=16, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=True,
schema="transactions",
)
op.alter_column(
"categories_rules",
"min_amount",
existing_type=sa.Numeric(precision=16, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=True,
schema="transactions",
)
# ### end Alembic commands ###

View File

@ -6,24 +6,26 @@ if __name__ == "__main__":
args = vars(argparser.parse_args()) args = vars(argparser.parse_args())
assert "op" in args, "No pfbudget.Operation selected" assert "op" in args, "No pfbudget.Operation selected"
op: pfbudget.Operation = args["op"] op: pfbudget.Operation = args.pop("op")
assert "database" in args, "No database selected" assert "database" in args, "No database selected"
db = args["database"] db = args.pop("database")
assert "verbose" in args, "No verbose level specified"
verbosity = args.pop("verbose")
params = None params = None
match (op): match (op):
case pfbudget.Operation.CategoryAdd: case pfbudget.Operation.CategoryAdd:
assert args.keys() >= {"category", "group"}, "argparser ill defined" assert args.keys() >= {"category", "group"}, "argparser ill defined"
params = [ params = [
pfbudget.types.Category(cat, args["group"][0]) pfbudget.types.Category(cat, args["group"]) for cat in args["category"]
for cat in args["category"]
] ]
case pfbudget.Operation.CategoryUpdate: case pfbudget.Operation.CategoryUpdate:
assert args.keys() >= {"category", "group"}, "argparser ill defined" assert args.keys() >= {"category", "group"}, "argparser ill defined"
params = [pfbudget.types.Category(cat) for cat in args["category"]] params = [pfbudget.types.Category(cat) for cat in args["category"]]
params.append(args["group"][0]) params.append(args["group"])
case pfbudget.Operation.CategoryRemove: case pfbudget.Operation.CategoryRemove:
assert "category" in args, "argparser ill defined" assert "category" in args, "argparser ill defined"
@ -43,7 +45,7 @@ if __name__ == "__main__":
for cat in args["category"] for cat in args["category"]
] ]
case pfbudget.Operation.CategoryRule: case pfbudget.Operation.RuleAdd:
assert args.keys() >= { assert args.keys() >= {
"category", "category",
"date", "date",
@ -58,6 +60,7 @@ if __name__ == "__main__":
cat, cat,
args["date"][0] if args["date"] else None, args["date"][0] if args["date"] else None,
args["description"][0] if args["description"] else None, args["description"][0] if args["description"] else None,
args["regex"][0] if args["regex"] else None,
args["bank"][0] if args["bank"] else None, args["bank"][0] if args["bank"] else None,
args["min"][0] if args["min"] else None, args["min"][0] if args["min"] else None,
args["max"][0] if args["max"] else None, args["max"][0] if args["max"] else None,
@ -65,6 +68,79 @@ if __name__ == "__main__":
for cat in args["category"] for cat in args["category"]
] ]
case pfbudget.Operation.RuleRemove | pfbudget.Operation.TagRuleRemove:
assert args.keys() >= {"id"}, "argparser ill defined"
params = args["id"]
case pfbudget.Operation.RuleModify:
assert args.keys() >= {
"id",
"category",
"date",
"description",
"bank",
"min",
"max",
"remove",
}, "argparser ill defined"
nargs_1 = ["category", "date", "description", "regex", "bank", "min", "max"]
params = []
for id in args["id"]:
param = {"id": id}
param |= {k: v[0] for k, v in args.items() if k in nargs_1 and args[k]}
param |= {k: None for k in args["remove"] if k in nargs_1}
params.append(param)
case pfbudget.Operation.TagAdd:
assert args.keys() >= {"tag"}, "argparser ill defined"
params = [pfbudget.types.Tag(tag) for tag in args["tag"]]
case pfbudget.Operation.TagRuleAdd:
assert args.keys() >= {
"tag",
"date",
"description",
"bank",
"min",
"max",
}, "argparser ill defined"
params = [
pfbudget.types.TagRule(
tag,
args["date"][0] if args["date"] else None,
args["description"][0] if args["description"] else None,
args["regex"][0] if args["regex"] else None,
args["bank"][0] if args["bank"] else None,
args["min"][0] if args["min"] else None,
args["max"][0] if args["max"] else None,
)
for tag in args["tag"]
]
case pfbudget.Operation.TagRuleModify:
assert args.keys() >= {
"id",
"tag",
"date",
"description",
"bank",
"min",
"max",
"remove",
}, "argparser ill defined"
nargs_1 = ["tag", "date", "description", "regex", "bank", "min", "max"]
params = []
for id in args["id"]:
param = {"id": id}
param |= {k: v[0] for k, v in args.items() if k in nargs_1 and args[k]}
param |= {k: None for k in args["remove"] if k in nargs_1}
params.append(param)
case pfbudget.Operation.GroupAdd: case pfbudget.Operation.GroupAdd:
assert "group" in args, "argparser ill defined" assert "group" in args, "argparser ill defined"
params = [pfbudget.types.CategoryGroup(group) for group in args["group"]] params = [pfbudget.types.CategoryGroup(group) for group in args["group"]]
@ -73,4 +149,10 @@ if __name__ == "__main__":
assert "group" in args, "argparser ill defined" assert "group" in args, "argparser ill defined"
params = [pfbudget.types.CategoryGroup(group) for group in args["group"]] params = [pfbudget.types.CategoryGroup(group) for group in args["group"]]
pfbudget.Manager(db, args).action(op, params) case pfbudget.Operation.Forge | pfbudget.Operation.Dismantle:
assert args.keys() >= {"original", "links"}, "argparser ill defined"
params = [
pfbudget.types.Link(args["original"][0], link) for link in args["links"]
]
pfbudget.Manager(db, verbosity, args).action(op, params)

View File

@ -1,12 +1,11 @@
from pathlib import Path from pathlib import Path
import argparse import argparse
import datetime as dt import datetime as dt
import decimal
import re import re
from pfbudget.common.types import Operation from pfbudget.common.types import Operation
from pfbudget.core.categories import categorize_data
from pfbudget.db.model import Period from pfbudget.db.model import Period
from pfbudget.input.json import JsonParser
from pfbudget.input.nordigen import NordigenInput from pfbudget.input.nordigen import NordigenInput
from pfbudget.db.sqlite import DatabaseClient from pfbudget.db.sqlite import DatabaseClient
import pfbudget.reporting.graph import pfbudget.reporting.graph
@ -39,12 +38,7 @@ def argparser() -> argparse.ArgumentParser:
help="select current database", help="select current database",
default=DEFAULT_DB, default=DEFAULT_DB,
) )
universal.add_argument( universal.add_argument("-v", "--verbose", action="count", default=0)
"-q", "--quiet", action="store_true", help="reduces the amount of verbose"
)
universal.add_argument(
"-v", "--verbose", action="store_true", help="increases the amount of verbose"
)
period = argparse.ArgumentParser(add_help=False).add_mutually_exclusive_group() period = argparse.ArgumentParser(add_help=False).add_mutually_exclusive_group()
period.add_argument( period.add_argument(
@ -254,6 +248,12 @@ def argparser() -> argparse.ArgumentParser:
category_parser = subparsers.add_parser("category", parents=[universal]) category_parser = subparsers.add_parser("category", parents=[universal])
category(category_parser, universal) category(category_parser, universal)
# Tag
tags(subparsers.add_parser("tag", parents=[universal]), universal)
# Link
link(subparsers.add_parser("link"))
return parser return parser
@ -341,13 +341,7 @@ def category(parser: argparse.ArgumentParser, universal: argparse.ArgumentParser
schedule.add_argument("--frequency", nargs=1, default=[1], type=int) schedule.add_argument("--frequency", nargs=1, default=[1], type=int)
rule = commands.add_parser("rule", parents=[universal]) rule = commands.add_parser("rule", parents=[universal])
rule.set_defaults(op=Operation.CategoryRule) category_rule(rule, universal)
rule.add_argument("category", nargs="+", type=str)
rule.add_argument("--date", nargs=1, type=dt.date.fromisoformat)
rule.add_argument("--description", nargs=1, type=str)
rule.add_argument("--bank", nargs=1, type=str)
rule.add_argument("--min", nargs=1, type=float)
rule.add_argument("--max", nargs=1, type=float)
group = commands.add_parser("group", parents=[universal]) group = commands.add_parser("group", parents=[universal])
category_group(group, universal) category_group(group, universal)
@ -365,6 +359,82 @@ def category_group(parser: argparse.ArgumentParser, universal: argparse.Argument
remove.add_argument("group", nargs="+", type=str) remove.add_argument("group", nargs="+", type=str)
def category_rule(parser: argparse.ArgumentParser, universal: argparse.ArgumentParser):
commands = parser.add_subparsers(required=True)
add = commands.add_parser("add", parents=[universal])
add.set_defaults(op=Operation.RuleAdd)
add.add_argument("category", nargs="+", type=str)
rules(add)
remove = commands.add_parser("remove", parents=[universal])
remove.set_defaults(op=Operation.RuleRemove)
remove.add_argument("id", nargs="+", type=int)
modify = commands.add_parser("modify", parents=[universal])
modify.set_defaults(op=Operation.RuleModify)
modify.add_argument("id", nargs="+", type=int)
modify.add_argument("--category", nargs=1, type=str)
rules(modify)
def tags(parser: argparse.ArgumentParser, universal: argparse.ArgumentParser):
commands = parser.add_subparsers(required=True)
add = commands.add_parser("add", parents=[universal])
add.set_defaults(op=Operation.TagAdd)
add.add_argument("tag", nargs="+", type=str)
remove = commands.add_parser("remove", parents=[universal])
remove.set_defaults(op=Operation.TagRemove)
remove.add_argument("tag", nargs="+", type=str)
rule = commands.add_parser("rule", parents=[universal])
tag_rule(rule, universal)
def tag_rule(parser: argparse.ArgumentParser, universal: argparse.ArgumentParser):
commands = parser.add_subparsers(required=True)
add = commands.add_parser("add", parents=[universal])
add.set_defaults(op=Operation.TagRuleAdd)
add.add_argument("tag", nargs="+", type=str)
rules(add)
remove = commands.add_parser("remove", parents=[universal])
remove.set_defaults(op=Operation.TagRuleRemove)
remove.add_argument("id", nargs="+", type=int)
modify = commands.add_parser("modify", parents=[universal])
modify.set_defaults(op=Operation.TagRuleModify)
modify.add_argument("id", nargs="+", type=int)
modify.add_argument("--tag", nargs=1, type=str)
rules(modify)
def rules(parser: argparse.ArgumentParser):
parser.add_argument("--date", nargs=1, type=dt.date.fromisoformat)
parser.add_argument("--description", nargs=1, type=str)
parser.add_argument("--regex", nargs=1, type=str)
parser.add_argument("--bank", nargs=1, type=str)
parser.add_argument("--min", nargs=1, type=decimal.Decimal)
parser.add_argument("--max", nargs=1, type=decimal.Decimal)
def link(parser: argparse.ArgumentParser):
commands = parser.add_subparsers(required=True)
forge = commands.add_parser("forge")
forge.set_defaults(op=Operation.Forge)
forge.add_argument("original", nargs=1, type=int)
forge.add_argument("links", nargs="+", type=int)
dismantle = commands.add_parser("dismantle")
dismantle.set_defaults(op=Operation.Dismantle)
dismantle.add_argument("original", nargs=1, type=int)
dismantle.add_argument("links", nargs="+", type=int)
def run(): def run():
args = vars(argparser().parse_args()) args = vars(argparser().parse_args())
assert "op" in args, "No operation selected" assert "op" in args, "No operation selected"

View File

@ -17,9 +17,18 @@ class Operation(Enum):
CategoryUpdate = auto() CategoryUpdate = auto()
CategoryRemove = auto() CategoryRemove = auto()
CategorySchedule = auto() CategorySchedule = auto()
CategoryRule = auto() RuleAdd = auto()
RuleRemove = auto()
RuleModify = auto()
GroupAdd = auto() GroupAdd = auto()
GroupRemove = auto() GroupRemove = auto()
TagAdd = auto()
TagRemove = auto()
TagRuleAdd = auto()
TagRuleRemove = auto()
TagRuleModify = auto()
Forge = auto()
Dismantle = auto()
class TransactionError(Exception): class TransactionError(Exception):

View File

@ -2,8 +2,10 @@ from pfbudget.db.model import (
Category, Category,
CategorySelector, CategorySelector,
Selector, Selector,
Tag,
Transaction, Transaction,
TransactionCategory, TransactionCategory,
TransactionTag,
) )
from datetime import timedelta from datetime import timedelta
@ -15,7 +17,12 @@ class Categorizer:
def __init__(self): def __init__(self):
self.options["null_days"] = 4 self.options["null_days"] = 4
def categorize(self, transactions: list[Transaction], categories: list[Category]): def categorize(
self,
transactions: list[Transaction],
categories: list[Category],
tags: list[Tag],
):
"""Overarching categorization tool """Overarching categorization tool
Receives a list of transactions (by ref) and updates their category Receives a list of transactions (by ref) and updates their category
@ -25,7 +32,8 @@ class Categorizer:
""" """
self._nullify(transactions) self._nullify(transactions)
self._rules(transactions, categories) self._rule_based_categories(transactions, categories)
self._rule_based_tags(transactions, tags)
def _nullify(self, transactions: list[Transaction]): def _nullify(self, transactions: list[Transaction]):
count = 0 count = 0
@ -57,27 +65,57 @@ class Categorizer:
print(f"Nullified {count} transactions") print(f"Nullified {count} transactions")
def _rules(self, transactions: list[Transaction], categories: list[Category]): def _rule_based_categories(
self, transactions: list[Transaction], categories: list[Category]
):
d = {}
for category in [c for c in categories if c.rules]: for category in [c for c in categories if c.rules]:
for rule in category.rules: for rule in category.rules:
for transaction in [t for t in transactions if not t.category]: # for transaction in [t for t in transactions if not t.category]:
if rule.date: for transaction in [
if rule.date < transaction.date: t
continue for t in transactions
if rule.description: if not t.category or t.category.name != "null"
if rule.description not in transaction.description: ]:
continue if not rule.matches(transaction):
if rule.bank: continue
if rule.bank != transaction.bank:
continue
if rule.min_amount:
if rule.min_amount > transaction.amount:
continue
if rule.max_amount:
if rule.max_amount <= transaction.amount:
continue
# passed all conditions, assign category # passed all conditions, assign category
transaction.category = TransactionCategory( transaction.category = TransactionCategory(
category.name, CategorySelector(Selector.rules) category.name, CategorySelector(Selector.rules)
) )
if rule in d:
d[rule] += 1
else:
d[rule] = 1
for k, v in d.items():
print(f"{v}: {k}")
def _rule_based_tags(self, transactions: list[Transaction], tags: list[Tag]):
d = {}
for tag in [t for t in tags if t.rules]:
for rule in tag.rules:
# for transaction in [t for t in transactions if not t.category]:
for transaction in [
t
for t in transactions
if tag.name not in [tag.tag for tag in t.tags]
]:
if not rule.matches(transaction):
continue
if not transaction.tags:
transaction.tags = {TransactionTag(tag.name)}
else:
transaction.tags.add(TransactionTag(tag.name))
if rule in d:
d[rule] += 1
else:
d[rule] = 1
for k, v in d.items():
print(f"{v}: {k}")

View File

@ -2,7 +2,14 @@ from pfbudget.input.input import Input
from pfbudget.input.nordigen import NordigenClient from pfbudget.input.nordigen import NordigenClient
from pfbudget.input.parsers import parse_data from pfbudget.input.parsers import parse_data
from pfbudget.db.client import DbClient from pfbudget.db.client import DbClient
from pfbudget.db.model import Category, CategoryGroup, CategoryRule, CategorySchedule from pfbudget.db.model import (
Category,
CategoryGroup,
CategoryRule,
CategorySchedule,
Tag,
TagRule,
)
from pfbudget.common.types import Operation from pfbudget.common.types import Operation
from pfbudget.core.categorizer import Categorizer from pfbudget.core.categorizer import Categorizer
from pfbudget.utils import convert from pfbudget.utils import convert
@ -11,13 +18,16 @@ from pfbudget.cli.runnable import download, parse
class Manager: class Manager:
def __init__(self, db: str, args: dict): def __init__(self, db: str, verbosity: int = 0, args: dict = {}):
self._args = args self._args = args
print(args)
self._db = db self._db = db
self._verbosity = verbosity
def action(self, op: Operation, params: list): def action(self, op: Operation, params: list):
if self._verbosity > 0:
print(f"op={op}, params={params}")
match (op): match (op):
case Operation.Init: case Operation.Init:
pass pass
@ -27,11 +37,13 @@ class Manager:
case Operation.Download: case Operation.Download:
# TODO this is a monstrosity, remove when possible # TODO this is a monstrosity, remove when possible
download(self, self.args) download(self, self.args)
case Operation.Categorize: case Operation.Categorize:
with self.db.session() as session: with self.db.session() as session:
uncategorized = session.uncategorized() uncategorized = session.uncategorized()
categories = session.categories() categories = session.categories()
Categorizer().categorize(uncategorized, categories) tags = session.tags()
Categorizer().categorize(uncategorized, categories, tags)
case Operation.Register: case Operation.Register:
# self._db = DbClient(args["database"]) # self._db = DbClient(args["database"])
@ -49,9 +61,9 @@ class Manager:
self.args["name"], self.args["country"] self.args["name"], self.args["country"]
) )
case Operation.CategoryAdd: case Operation.CategoryAdd | Operation.RuleAdd | Operation.TagAdd | Operation.TagRuleAdd:
with self.db.session() as session: with self.db.session() as session:
session.addcategories(params) session.add(params)
case Operation.CategoryUpdate: case Operation.CategoryUpdate:
with self.db.session() as session: with self.db.session() as session:
@ -59,26 +71,49 @@ class Manager:
case Operation.CategoryRemove: case Operation.CategoryRemove:
with self.db.session() as session: with self.db.session() as session:
session.removecategories(params) session.remove_by_name(Category, params)
case Operation.CategorySchedule: case Operation.CategorySchedule:
with self.db.session() as session: with self.db.session() as session:
session.updateschedules(params) session.updateschedules(params)
case Operation.CategoryRule: case Operation.RuleRemove:
assert all(isinstance(param, int) for param in params)
with self.db.session() as session: with self.db.session() as session:
session.addrules(params) session.remove_by_id(CategoryRule, params)
case Operation.TagRemove:
with self.db.session() as session:
session.remove_by_name(Tag, params)
case Operation.TagRuleRemove:
assert all(isinstance(param, int) for param in params)
with self.db.session() as session:
session.remove_by_id(TagRule, params)
case Operation.RuleModify | Operation.TagRuleModify:
assert all(isinstance(param, dict) for param in params)
with self.db.session() as session:
session.updaterules(params)
case Operation.GroupAdd: case Operation.GroupAdd:
with self.db.session() as session: with self.db.session() as session:
for group in self.args["group"]: session.add(CategoryGroup(params))
session.addcategorygroup(CategoryGroup(name=group))
case Operation.GroupRemove: case Operation.GroupRemove:
assert all(isinstance(param, CategoryGroup) for param in params)
with self.db.session() as session: with self.db.session() as session:
session.removecategorygroup( session.remove_by_name(CategoryGroup, params)
[CategoryGroup(name=group) for group in self.args["group"]]
) case Operation.Forge:
with self.db.session() as session:
session.add(params)
case Operation.Dismantle:
with self.db.session() as session:
original = params[0].original
links = [link.link for link in params]
session.remove_links(original, links)
# def init(self): # def init(self):
# client = DatabaseClient(self.__db) # client = DatabaseClient(self.__db)
@ -119,7 +154,7 @@ class Manager:
@property @property
def db(self) -> DbClient: def db(self) -> DbClient:
return DbClient(self._db, self.args["verbose"]) return DbClient(self._db, self._verbosity > 0)
@db.setter @db.setter
def db(self, url: str): def db(self, url: str):

View File

@ -1,4 +1,3 @@
from copy import deepcopy
from dataclasses import asdict from dataclasses import asdict
from sqlalchemy import create_engine, delete, select, update from sqlalchemy import create_engine, delete, select, update
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
@ -10,6 +9,9 @@ from pfbudget.db.model import (
CategoryGroup, CategoryGroup,
CategoryRule, CategoryRule,
CategorySchedule, CategorySchedule,
Link,
Tag,
TagRule,
Transaction, Transaction,
) )
@ -81,16 +83,11 @@ class DbClient:
def commit(self): def commit(self):
self.__session.commit() self.__session.commit()
def add(self, transactions: list[Transaction]): def add(self, rows: list):
self.__session.add_all(transactions) self.__session.add_all(rows)
def addcategories(self, category: list[Category]): def remove_by_name(self, type: Category | Tag | Transaction, rows: list):
self.__session.add_all(category) stmt = delete(type).where(type.name.in_([row.name for row in rows]))
def removecategories(self, categories: list[Category]):
stmt = delete(Category).where(
Category.name.in_([cat.name for cat in categories])
)
self.__session.execute(stmt) self.__session.execute(stmt)
def updategroup(self, categories: list[Category], group: CategoryGroup): def updategroup(self, categories: list[Category], group: CategoryGroup):
@ -113,16 +110,15 @@ class DbClient:
) )
self.__session.execute(stmt) self.__session.execute(stmt)
def addrules(self, rules: list[CategoryRule]): def remove_by_id(self, type: CategoryRule | TagRule, ids: list[int]):
self.__session.add_all(rules) stmt = delete(type).where(type.id.in_(ids))
self.__session.execute(stmt)
def addcategorygroup(self, group: CategoryGroup): def updaterules(self, rules: list[dict]):
self.__session.add(group) self.__session.execute(update(CategoryRule), rules)
def removecategorygroup(self, groups: list[CategoryGroup]): def remove_links(self, original, links: list):
stmt = delete(CategoryGroup).where( stmt = delete(Link).where(Link.original == original, Link.link.in_(link for link in links))
CategoryGroup.name.in_([grp.name for grp in groups])
)
self.__session.execute(stmt) self.__session.execute(stmt)
def uncategorized(self) -> list[Transaction]: def uncategorized(self) -> list[Transaction]:
@ -133,5 +129,9 @@ class DbClient:
stmt = select(Category) stmt = select(Category)
return self.__session.scalars(stmt).all() return self.__session.scalars(stmt).all()
def tags(self) -> list[Tag]:
stmt = select(Tag)
return self.__session.scalars(stmt).all()
def session(self) -> ClientSession: def session(self) -> ClientSession:
return self.ClientSession(self.engine) return self.ClientSession(self.engine)

View File

@ -21,6 +21,7 @@ from decimal import Decimal
from typing import Annotated, Optional from typing import Annotated, Optional
import datetime as dt import datetime as dt
import enum import enum
import re
class Base(MappedAsDataclass, DeclarativeBase): class Base(MappedAsDataclass, DeclarativeBase):
@ -62,36 +63,25 @@ class Bank(Base):
back_populates="bank", lazy="joined" back_populates="bank", lazy="joined"
) )
def __repr__(self) -> str:
return f"Bank(name={self.name}, BIC={self.BIC}, type={self.type}, nordigen={self.nordigen})"
bankfk = Annotated[str, mapped_column(Text, ForeignKey(Bank.name))] bankfk = Annotated[str, mapped_column(Text, ForeignKey(Bank.name))]
idpk = Annotated[int, mapped_column(BigInteger, primary_key=True)] idpk = Annotated[int, mapped_column(BigInteger, primary_key=True, autoincrement=True)]
money = Annotated[Decimal, mapped_column(Numeric(16, 2), nullable=False)] money = Annotated[Decimal, mapped_column(Numeric(16, 2))]
class Transaction(Base): class Transaction(Base):
__tablename__ = "originals" __tablename__ = "originals"
id: Mapped[idpk] = mapped_column(autoincrement=True, init=False) id: Mapped[idpk] = mapped_column(init=False)
date: Mapped[dt.date] date: Mapped[dt.date]
description: Mapped[Optional[str]] description: Mapped[Optional[str]]
bank: Mapped[bankfk] bank: Mapped[bankfk]
amount: Mapped[money] amount: Mapped[money]
category: Mapped[Optional[TransactionCategory]] = relationship() category: Mapped[Optional[TransactionCategory]] = relationship()
note: Mapped[Optional[Note]] = relationship(back_populates="original", default=None) note: Mapped[Optional[Note]] = relationship(back_populates="original")
tags: Mapped[Optional[set[Tag]]] = relationship( tags: Mapped[Optional[set[TransactionTag]]] = relationship()
back_populates="original",
cascade="all, delete-orphan",
passive_deletes=True,
default=None,
)
def __repr__(self) -> str:
return f"Transaction(date={self.date}, description={self.description}, bank={self.bank}, amount={self.amount}, category={self.category})"
idfk = Annotated[ idfk = Annotated[
@ -114,7 +104,7 @@ class Category(Base):
) )
rules: Mapped[Optional[set[CategoryRule]]] = relationship( rules: Mapped[Optional[set[CategoryRule]]] = relationship(
cascade="all, delete-orphan", passive_deletes=True, default=None cascade="all, delete-orphan", passive_deletes=True, default_factory=set
) )
schedule: Mapped[CategorySchedule] = relationship( schedule: Mapped[CategorySchedule] = relationship(
back_populates="category", default=None back_populates="category", default=None
@ -134,9 +124,9 @@ class TransactionCategory(Base):
__tablename__ = "categorized" __tablename__ = "categorized"
id: Mapped[idfk] = mapped_column(primary_key=True, init=False) id: Mapped[idfk] = mapped_column(primary_key=True, init=False)
name: Mapped[str] = mapped_column(ForeignKey(Category.name)) name: Mapped[catfk]
selector: Mapped[CategorySelector] = relationship() selector: Mapped[CategorySelector] = relationship(cascade="all, delete-orphan")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Category({self.name})" return f"Category({self.name})"
@ -145,7 +135,7 @@ class TransactionCategory(Base):
class Note(Base): class Note(Base):
__tablename__ = "notes" __tablename__ = "notes"
id: Mapped[idfk] = mapped_column(primary_key=True) id: Mapped[idfk] = mapped_column(primary_key=True, init=False)
note: Mapped[str] note: Mapped[str]
original: Mapped[Transaction] = relationship(back_populates="note") original: Mapped[Transaction] = relationship(back_populates="note")
@ -161,29 +151,22 @@ class Nordigen(Base):
bank: Mapped[Bank] = relationship(back_populates="nordigen") bank: Mapped[Bank] = relationship(back_populates="nordigen")
def __repr__(self) -> str:
return f"(bank_id={self.bank_id}, requisition_id={self.requisition_id}, invert={self.invert})"
class Tag(Base): class Tag(Base):
__tablename__ = "tags_available"
name: Mapped[str] = mapped_column(primary_key=True)
rules: Mapped[Optional[set[TagRule]]] = relationship(
cascade="all, delete-orphan", passive_deletes=True, default_factory=set
)
class TransactionTag(Base):
__tablename__ = "tags" __tablename__ = "tags"
id: Mapped[idfk] = mapped_column(primary_key=True) id: Mapped[idfk] = mapped_column(primary_key=True, init=False)
tag: Mapped[str] = mapped_column(primary_key=True) tag: Mapped[str] = mapped_column(ForeignKey(Tag.name), primary_key=True)
original: Mapped[Transaction] = relationship(back_populates="tags")
class CategoryRule(Base):
__tablename__ = "categories_rules"
id: Mapped[idpk] = mapped_column(autoincrement=True, init=False)
name: Mapped[catfk] = mapped_column()
date: Mapped[Optional[dt.date]] = mapped_column()
description: Mapped[Optional[str]] = mapped_column()
bank: Mapped[Optional[str]] = mapped_column()
min_amount: Mapped[Optional[float]] = mapped_column()
max_amount: Mapped[Optional[float]] = mapped_column()
def __hash__(self): def __hash__(self):
return hash(self.id) return hash(self.id)
@ -236,5 +219,60 @@ class CategorySchedule(Base):
category: Mapped[Category] = relationship(back_populates="schedule") category: Mapped[Category] = relationship(back_populates="schedule")
def __repr__(self) -> str:
return f"{self.name} schedule=Schedule(period={self.period}, multiplier={self.period_multiplier}, amount={self.amount})" class Link(Base):
__tablename__ = "links"
original: Mapped[idfk] = mapped_column(primary_key=True)
link: Mapped[idfk] = mapped_column(primary_key=True)
class Rule:
date: Mapped[Optional[dt.date]]
description: Mapped[Optional[str]]
regex: Mapped[Optional[str]]
bank: Mapped[Optional[str]]
min: Mapped[Optional[money]]
max: Mapped[Optional[money]]
def matches(self, transaction: Transaction) -> bool:
if (
(self.date and self.date < transaction.date)
or (
self.description
and transaction.description
and self.description not in transaction.description
)
or (
self.regex
and transaction.description
and not re.compile(self.regex, re.IGNORECASE).search(
transaction.description
)
)
or (self.bank and self.bank != transaction.bank)
or (self.min and self.min > transaction.amount)
or (self.max and self.max < transaction.amount)
):
return False
return True
class CategoryRule(Base, Rule):
__tablename__ = "categories_rules"
id: Mapped[idpk] = mapped_column(init=False)
name: Mapped[catfk]
def __hash__(self):
return hash(self.id)
class TagRule(Base, Rule):
__tablename__ = "tag_rules"
id: Mapped[idpk] = mapped_column(init=False)
tag: Mapped[str] = mapped_column(ForeignKey(Tag.name, ondelete="CASCADE"))
def __hash__(self):
return hash(self.id)