from finance.models import Account, Payee, Category, CardStatement, InstallmentPlan, Transaction, SourceRecord from .base import DomainError, identity, lock, money, save, write def validate_category(kind, category): if category is None: return expected = {"INCOME": "INCOME", "EXPENSE": "EXPENSE", "REFUND": "EXPENSE"}.get(kind) if expected != category.kind: raise DomainError("category_kind_mismatch") def record(*, account_id, amount, posting_date, kind, description="", occurrence_date=None, payee_id=None, category_id=None, statement_id=None, refund_of_id=None, installment_plan_id=None, installment_number=None): amount = money(amount) account = lock(Account, account_id, active=True) payee = lock(Payee, payee_id, active=True) if payee_id is not None else None category = lock(Category, category_id, active=True) if category_id is not None else None validate_category(kind, category) statement = lock(CardStatement, statement_id) if statement_id is not None else None if statement and (account.kind != "CREDIT_CARD" or statement.account_id != account.pk): raise DomainError("statement_account_mismatch") plan = lock(InstallmentPlan, installment_plan_id) if installment_plan_id is not None else None if plan: if kind != "EXPENSE" or plan.account_id != account.pk: raise DomainError("installment_account_or_kind_mismatch") if not isinstance(installment_number, int) or isinstance(installment_number, bool) or not 1 <= installment_number <= plan.total_installments: raise DomainError("invalid_installment_number") elif installment_number is not None: raise DomainError("missing_installment_plan") original = lock(Transaction, refund_of_id) if refund_of_id is not None else None if original: if kind != "REFUND" or original.kind != "EXPENSE" or original.account.currency != account.currency: raise DomainError("invalid_refund_reference") obj = Transaction(account=account, amount=amount, posting_date=posting_date, kind=kind, description=description, occurrence_date=occurrence_date, payee=payee, category=category, statement=statement, refund_of=original, installment_plan=plan, installment_number=installment_number) return save(obj) @write("record_transaction") def record_transaction(**kwargs): return record(**kwargs), sorted(key.removesuffix("_id") for key in kwargs) @write("annotate_transaction") def annotate_transaction(*, transaction_id, **changes): if not changes or set(changes) - {"description", "payee_id", "category_id"}: raise DomainError("immutable_transaction") # Reference rows precede transaction rows in every write path. This avoids # a cycle with refund posting (category -> original transaction). references = {} for field, model in (("payee_id", Payee), ("category_id", Category)): if field in changes: value = changes[field] references[field] = lock(model, value, active=True) if value is not None else None obj = lock(Transaction, transaction_id) if "description" in changes: obj.description = changes["description"] if "payee_id" in changes: obj.payee = references["payee_id"] if "category_id" in changes: obj.category = references["category_id"] validate_category(obj.kind, obj.category) return save(obj), sorted(key.removesuffix("_id") for key in changes) @write("attach_source") def attach_source(*, transaction_id, document_hash, source_format, parser_version, item_locator): obj = lock(Transaction, transaction_id) source = save(SourceRecord(transaction=obj, document_hash=document_hash, source_format=source_format, parser_version=parser_version, item_locator=item_locator)) return source, ["transaction", "document_hash", "source_format", "parser_version", "item_locator"]