Update file metadata in manifest.json and change MinIO image source in docker-compose.yml

This commit is contained in:
k1nq
2026-09-23 09:23:13 +05:00
parent 8c2b89c80d
commit db4dd7572e
71 changed files with 31011 additions and 8308 deletions
+16 -4
View File
@@ -17,9 +17,9 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, PkMixin, TimestampMixin
ACCOUNT_KINDS = ("cash", "debit", "credit")
ACCOUNT_KINDS = ("cash", "debit", "credit", "savings")
ACCOUNT_STATUSES = ("active", "archived")
OPERATION_TYPES = ("expense", "income", "transfer", "refund")
OPERATION_TYPES = ("expense", "income", "transfer", "refund", "adjustment")
OPERATION_STATUSES = ("confirmed", "voided")
@@ -28,10 +28,13 @@ class Account(Base, PkMixin, TimestampMixin):
__tablename__ = "accounts"
__table_args__ = (
CheckConstraint("kind IN ('cash', 'debit', 'credit')", name="kind_valid"),
CheckConstraint(
"kind IN ('cash', 'debit', 'credit', 'savings')", name="kind_valid"
),
CheckConstraint("status IN ('active', 'archived')", name="status_valid"),
CheckConstraint("currency = 'RUB'", name="currency_rub_only"),
UniqueConstraint("space_id", "id", name="uq_accounts_space_id"),
UniqueConstraint("space_id", "system_key", name="uq_accounts_space_system_key"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
@@ -45,6 +48,7 @@ class Account(Base, PkMixin, TimestampMixin):
bank_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
masked_identifier: Mapped[str | None] = mapped_column(String(32), nullable=True)
fingerprint: Mapped[str | None] = mapped_column(String(128), nullable=True)
system_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
@@ -56,7 +60,10 @@ class Operation(Base, PkMixin, TimestampMixin):
__tablename__ = "operations"
__table_args__ = (
CheckConstraint("type IN ('expense', 'income', 'transfer', 'refund')", name="type_valid"),
CheckConstraint(
"type IN ('expense', 'income', 'transfer', 'refund', 'adjustment')",
name="type_valid",
),
CheckConstraint("status IN ('confirmed', 'voided')", name="status_valid"),
CheckConstraint("amount_kopecks > 0", name="amount_positive"),
CheckConstraint(
@@ -114,6 +121,8 @@ class Operation(Base, PkMixin, TimestampMixin):
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
void_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
request_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
converted_from_expense: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
previous_category_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
created_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
@@ -168,6 +177,9 @@ class Category(Base, PkMixin, TimestampMixin):
name="fk_categories_linked_account_tenant",
),
UniqueConstraint("space_id", "id", name="uq_categories_space_id"),
UniqueConstraint(
"space_id", "system_key", "linked_account_id", name="uq_categories_system_pair"
),
)
space_id: Mapped[uuid.UUID] = mapped_column(
+54
View File
@@ -52,6 +52,14 @@ class OperationPatchBody(BaseModel):
category_id: uuid.UUID | None = Field(default=None, alias="categoryId")
class AdjustBalanceBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
target_balance_kopecks: int = Field(ge=0, alias="targetBalanceKopecks")
occurred_on: date = Field(alias="occurredOn")
reason: str = Field(min_length=1, max_length=500)
def account_json(db: Db, account: Any) -> dict:
return {
"id": account.id,
@@ -62,6 +70,8 @@ def account_json(db: Db, account: Any) -> dict:
"openedOn": account.opened_on,
"bankCode": account.bank_code,
"maskedNumber": account.masked_identifier,
"systemKey": account.system_key,
"nameKey": f"account.{account.system_key}" if account.system_key else None,
"status": account.status,
"balanceKopecks": service.account_balance(db, account),
"version": account.version,
@@ -91,6 +101,7 @@ def operation_json(db: Db, operation: Any) -> dict:
"postedOn": operation.posted_on,
"description": operation.description,
"voidReason": operation.void_reason,
"convertedFromExpense": operation.converted_from_expense,
"version": operation.version,
"createdAt": operation.created_at,
}
@@ -276,3 +287,46 @@ def patch_operation(
category_id=body.category_id,
)
return operation_json(db, operation)
@router.post("/accounts/{account_id}/adjust-balance")
def adjust_balance(
account_id: uuid.UUID, body: AdjustBalanceBody, db: Db, context: Context, _csrf: Csrf
) -> dict:
from app.ledger import savings
operation, old_balance, new_balance = savings.adjust_balance(
db,
space_id=context.space.id,
user=context.user,
account_id=account_id,
target_balance_kopecks=body.target_balance_kopecks,
occurred_on=body.occurred_on,
reason=body.reason,
)
payload = operation_json(db, operation)
payload["oldBalanceKopecks"] = old_balance
payload["newBalanceKopecks"] = new_balance
return payload
@router.post("/operations/{operation_id}/savings-transfer")
def savings_transfer(operation_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
from app.ledger import savings
operation = savings.convert_expense_to_savings(
db, space_id=context.space.id, user=context.user, operation_id=operation_id
)
return operation_json(db, operation)
@router.post("/operations/{operation_id}/savings-transfer/revert")
def savings_transfer_revert(
operation_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf
) -> dict:
from app.ledger import savings
operation = savings.revert_savings_conversion(
db, space_id=context.space.id, user=context.user, operation_id=operation_id
)
return operation_json(db, operation)
+334
View File
@@ -0,0 +1,334 @@
"""System savings account/envelope, balance adjustments and reversible conversions.
The savings "convert" identity is defined exclusively by ``system_key``:
user-created accounts or categories that merely share the name «Накопления»
are never captured. Every command is tenant-scoped, period-guarded and
audited; balances stay derived (postings only, never stored mutations).
"""
import uuid
from datetime import date
from sqlalchemy.orm import Session
from app.audit import service as audit
from app.errors import bad_request, conflict, not_found
from app.identity.models import User
from app.ledger.models import Account, Category, Operation, Posting
SAVINGS_SYSTEM_KEY = "savings"
SAVINGS_ACCOUNT_NAME = "Накопления"
def get_savings_account(db: Session, *, space_id: uuid.UUID) -> Account | None:
return (
db.query(Account)
.filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY)
.one_or_none()
)
def _savings_category(db: Session, *, space_id: uuid.UUID) -> Category | None:
return (
db.query(Category)
.filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY)
.one_or_none()
)
def ensure_savings_resources(db: Session, *, space_id: uuid.UUID) -> tuple[Account, Category]:
"""Idempotently return the single system savings account and its envelope.
Any duplicate or inconsistent system pair is an explicit conflict, never a
random pick: identity is keyed by ``system_key`` only.
"""
accounts = db.query(Account).filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY).all()
if len(accounts) > 1:
raise conflict(
"LEDGER_SAVINGS_CONFLICT",
"Multiple system savings accounts exist in this space",
)
account: Account | None = accounts[0] if accounts else None
created = False
if account is None:
account = Account(
space_id=space_id,
name=SAVINGS_ACCOUNT_NAME,
kind="savings",
system_key=SAVINGS_SYSTEM_KEY,
opened_on=date.today(),
opening_amount_kopecks=0,
)
db.add(account)
db.flush()
created = True
categories = (
db.query(Category).filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY).all()
)
if len(categories) > 1:
raise conflict(
"LEDGER_SAVINGS_CONFLICT",
"Multiple system savings categories exist in this space",
)
category: Category | None = categories[0] if categories else None
if category is not None and category.linked_account_id != account.id:
raise conflict(
"LEDGER_SAVINGS_CONFLICT",
"System savings category is linked to a different account",
)
if category is None:
category = Category(
space_id=space_id,
kind="savings_envelope",
system_key=SAVINGS_SYSTEM_KEY,
linked_account_id=account.id,
)
db.add(category)
db.flush()
created = True
if created:
audit.record(
db,
event_type="savings.resources_ensured",
space_id=space_id,
detail={
"account_id": str(account.id),
"category_id": str(category.id),
},
)
return account, category
def transfer_category_for_accounts(
db: Session, *, space_id: uuid.UUID, source: Account, target: Account
) -> Category | None:
"""System category for a manual transfer touching the savings account."""
if SAVINGS_SYSTEM_KEY not in {source.system_key, target.system_key}:
return None
_, category = ensure_savings_resources(db, space_id=space_id)
return category
def adjust_balance(
db: Session,
*,
space_id: uuid.UUID,
user: User,
account_id: uuid.UUID,
target_balance_kopecks: int,
occurred_on: date,
reason: str,
) -> tuple[Operation, int, int]:
"""Reconcile a derived balance with reality via one audited adjustment.
The operation carries one signed posting for the delta; ``amount_kopecks``
stays positive and the sign lives in the posting. Adjustments never touch
income/expense totals, category spend, cash flow or the budget pool.
"""
if not reason.strip():
raise bad_request("LEDGER_ADJUST_REASON_REQUIRED", "An adjustment reason is required")
account = (
db.query(Account)
.filter_by(id=account_id, space_id=space_id)
.with_for_update()
.one_or_none()
)
if account is None:
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
if account.status != "active":
raise conflict("LEDGER_ACCOUNT_ARCHIVED", "Account is archived")
if account.kind == "credit":
raise conflict(
"LEDGER_ADJUST_CREDIT_UNSUPPORTED",
"Credit account balances cannot be adjusted with a non-negative target",
)
if target_balance_kopecks < 0:
raise bad_request(
"LEDGER_ADJUST_TARGET_NEGATIVE", "Target balance cannot be negative"
)
from app.ledger.service import account_balance, ensure_period_open_for_date
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
old_balance = account_balance(db, account)
delta = target_balance_kopecks - old_balance
if delta == 0:
raise conflict(
"LEDGER_ADJUST_NOOP",
"Balance already equals the target; nothing to adjust",
details={"balance_kopecks": old_balance},
)
operation = Operation(
space_id=space_id,
type="adjustment",
amount_kopecks=abs(delta),
account_id=account.id,
occurred_on=occurred_on,
description=reason.strip(),
created_by_user_id=user.id,
)
db.add(operation)
db.flush()
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=account.id,
amount_kopecks=delta,
)
)
audit.record(
db,
event_type="account.balance_adjusted",
space_id=space_id,
user_id=user.id,
detail={
"account_id": str(account.id),
"operation_id": str(operation.id),
"old_balance_kopecks": old_balance,
"new_balance_kopecks": target_balance_kopecks,
"delta_kopecks": delta,
"reason": reason.strip(),
},
)
return operation, old_balance, target_balance_kopecks
def convert_expense_to_savings(
db: Session, *, space_id: uuid.UUID, user: User, operation_id: uuid.UUID
) -> Operation:
"""Turn a confirmed expense into a transfer into the system savings account.
Keeps the original negative posting plus description, payee, request key
and import provenance; adds one positive posting on the savings account and
remembers the previous category for exact reversion.
"""
operation = (
db.query(Operation)
.filter_by(id=operation_id, space_id=space_id)
.with_for_update()
.one_or_none()
)
if operation is None:
raise not_found("LEDGER_OPERATION_NOT_FOUND", "Operation not found")
if operation.status != "confirmed":
raise conflict(
"LEDGER_SAVINGS_CONVERT_INVALID",
"Only confirmed operations can be converted",
)
if operation.type != "expense":
raise conflict(
"LEDGER_SAVINGS_CONVERT_INVALID",
"Only expenses can be converted into savings transfers",
)
if operation.converted_from_expense:
raise conflict(
"LEDGER_SAVINGS_CONVERT_INVALID",
"Operation is already a savings transfer",
)
source_account = (
db.query(Account).filter_by(id=operation.account_id, space_id=space_id).one_or_none()
)
if source_account is None:
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
if source_account.system_key is not None:
raise conflict(
"LEDGER_SAVINGS_CONVERT_INVALID",
"Operations on system accounts cannot be converted",
)
confirmed_refund = (
db.query(Operation.id)
.filter_by(
space_id=space_id,
parent_operation_id=operation.id,
type="refund",
status="confirmed",
)
.first()
)
if confirmed_refund is not None:
raise conflict(
"LEDGER_SAVINGS_CONVERT_HAS_REFUNDS",
"Expenses with confirmed refunds cannot be converted into savings transfers",
)
from app.ledger.service import ensure_period_open_for_date
ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on)
savings_account, savings_category = ensure_savings_resources(db, space_id=space_id)
previous_category_id = operation.category_id
operation.previous_category_id = previous_category_id
operation.converted_from_expense = True
operation.type = "transfer"
operation.counter_account_id = savings_account.id
operation.category_id = savings_category.id
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=savings_account.id,
amount_kopecks=operation.amount_kopecks,
)
)
audit.record(
db,
event_type="operation.savings_transfer",
space_id=space_id,
user_id=user.id,
detail={
"operation_id": str(operation.id),
"savings_account_id": str(savings_account.id),
"amount_kopecks": operation.amount_kopecks,
"previous_category_id": (
str(previous_category_id) if previous_category_id else None
),
},
)
return operation
def revert_savings_conversion(
db: Session, *, space_id: uuid.UUID, user: User, operation_id: uuid.UUID
) -> Operation:
"""Undo a marked conversion: restore the expense type, category and postings."""
operation = (
db.query(Operation)
.filter_by(id=operation_id, space_id=space_id)
.with_for_update()
.one_or_none()
)
if operation is None:
raise not_found("LEDGER_OPERATION_NOT_FOUND", "Operation not found")
if operation.status != "confirmed":
raise conflict(
"LEDGER_SAVINGS_REVERT_INVALID",
"Only confirmed operations can be reverted",
)
if operation.type != "transfer" or not operation.converted_from_expense:
raise conflict(
"LEDGER_SAVINGS_REVERT_INVALID",
"Only converted expenses can be reverted from savings",
)
from app.ledger.service import ensure_period_open_for_date
ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on)
counter_account_id = operation.counter_account_id
if counter_account_id is not None:
savings_posting = (
db.query(Posting)
.filter_by(operation_id=operation.id, account_id=counter_account_id)
.one_or_none()
)
if savings_posting is not None:
db.delete(savings_posting)
operation.type = "expense"
operation.counter_account_id = None
operation.category_id = operation.previous_category_id
operation.converted_from_expense = False
operation.previous_category_id = None
audit.record(
db,
event_type="operation.savings_transfer_reverted",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id)},
)
return operation
+24
View File
@@ -48,6 +48,15 @@ def _require_active_account(db: Session, *, space_id: uuid.UUID, account_id: uui
return account
def _reject_system_account(account: Account) -> None:
"""System accounts (savings) are journal-internal and never statement-facing."""
if account.system_key is not None:
raise bad_request(
"LEDGER_SYSTEM_ACCOUNT_NOT_EXTERNAL",
"System accounts cannot host external income or expense operations",
)
def _validate_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UUID | None) -> None:
if category_id is None:
return
@@ -58,6 +67,11 @@ def _validate_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UU
raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found")
if category.archived:
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
if category.system_key is not None:
raise conflict(
"BUDGET_CATEGORY_SYSTEM",
"System categories cannot be assigned to regular operations",
)
def account_balance(db: Session, account: Account) -> int:
@@ -155,6 +169,7 @@ def create_expense(
if existing is not None:
return existing
account = _require_active_account(db, space_id=space_id, account_id=account_id)
_reject_system_account(account)
_validate_category(db, space_id=space_id, category_id=category_id)
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
operation = Operation(
@@ -210,6 +225,7 @@ def create_income(
if existing is not None:
return existing
account = _require_active_account(db, space_id=space_id, account_id=account_id)
_reject_system_account(account)
if income_source_id is not None:
categorization.get_income_source(db, space_id=space_id, income_source_id=income_source_id)
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
@@ -268,12 +284,16 @@ def create_transfer(
source = _require_active_account(db, space_id=space_id, account_id=source_account_id)
target = _require_active_account(db, space_id=space_id, account_id=target_account_id)
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
from app.ledger.savings import transfer_category_for_accounts
category = transfer_category_for_accounts(db, space_id=space_id, source=source, target=target)
operation = Operation(
space_id=space_id,
type="transfer",
amount_kopecks=amount_kopecks,
account_id=source.id,
counter_account_id=target.id,
category_id=category.id if category is not None else None,
occurred_on=occurred_on,
posted_on=posted_on,
description=description,
@@ -463,6 +483,8 @@ def archive_account(
db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID
) -> Account:
account = get_account(db, space_id=space_id, account_id=account_id)
if account.system_key is not None:
raise conflict("LEDGER_ACCOUNT_SYSTEM", "System accounts cannot be archived")
if account.status == "archived":
return account
if account_balance(db, account) != 0:
@@ -483,6 +505,8 @@ def archive_account(
def delete_account(db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID) -> None:
account = get_account(db, space_id=space_id, account_id=account_id)
if account.system_key is not None:
raise conflict("LEDGER_ACCOUNT_SYSTEM", "System accounts cannot be deleted")
used = (
db.query(Operation.id)
.filter((Operation.account_id == account.id) | (Operation.counter_account_id == account.id))