first commit

This commit is contained in:
2025-07-31 11:27:50 -04:00
commit b2e963a6c6
8341 changed files with 1740423 additions and 0 deletions

147
backend/backend/alembic.ini Normal file
View File

@@ -0,0 +1,147 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = sqlite:///./hoa_app.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -0,0 +1 @@
Generic single-database configuration.

View File

@@ -0,0 +1,80 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'hoa_app')))
from hoa_app.models import Base
from hoa_app.database import engine
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,32 @@
"""add reconciled to transactions
Revision ID: 75408262fa43
Revises: 7d2e68fbff09
Create Date: 2025-07-16 15:11:00.818672
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '75408262fa43'
down_revision: Union[str, Sequence[str], None] = '7d2e68fbff09'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('transactions', sa.Column('reconciled', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('transactions', 'reconciled')
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""cashflow entry projected and actual split
Revision ID: 7d2e68fbff09
Revises: c1be870c3f79
Create Date: 2025-07-16 10:25:24.891489
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7d2e68fbff09'
down_revision: Union[str, Sequence[str], None] = 'c1be870c3f79'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('cash_flow_entries', sa.Column('projected_amount', sa.Float(), nullable=True))
op.add_column('cash_flow_entries', sa.Column('actual_amount', sa.Float(), nullable=True))
op.drop_column('cash_flow_entries', 'amount')
op.drop_column('cash_flow_entries', 'is_actual')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('cash_flow_entries', sa.Column('is_actual', sa.BOOLEAN(), nullable=True))
op.add_column('cash_flow_entries', sa.Column('amount', sa.FLOAT(), nullable=False))
op.drop_column('cash_flow_entries', 'actual_amount')
op.drop_column('cash_flow_entries', 'projected_amount')
# ### end Alembic commands ###

View File

@@ -0,0 +1,108 @@
"""Initial tables
Revision ID: b2d22e13e06c
Revises:
Create Date: 2025-07-10 10:17:44.144159
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2d22e13e06c'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('buckets',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_index(op.f('ix_buckets_id'), 'buckets', ['id'], unique=False)
op.create_table('roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.Enum('admin', 'user', name='roleenum'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_index(op.f('ix_roles_id'), 'roles', ['id'], unique=False)
op.create_table('accounts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('bucket_id', sa.Integer(), nullable=True),
sa.Column('name', sa.String(), nullable=False),
sa.Column('type', sa.Enum('checking', 'savings', 'cd', 'tbill', 'other', name='accounttypeenum'), nullable=False),
sa.Column('interest_rate', sa.Float(), nullable=True),
sa.Column('maturity_date', sa.DateTime(), nullable=True),
sa.Column('balance', sa.Float(), nullable=True),
sa.Column('last_validated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['bucket_id'], ['buckets.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_accounts_id'), 'accounts', ['id'], unique=False)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('password_hash', sa.String(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('cash_flows',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('account_id', sa.Integer(), nullable=True),
sa.Column('type', sa.Enum('inflow', 'outflow', name='cashflowtypeenum'), nullable=False),
sa.Column('estimate_actual', sa.Boolean(), nullable=True),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('date', sa.DateTime(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_cash_flows_id'), 'cash_flows', ['id'], unique=False)
op.create_table('transactions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('account_id', sa.Integer(), nullable=True),
sa.Column('type', sa.Enum('deposit', 'withdrawal', 'transfer', name='transactiontypeenum'), nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('date', sa.DateTime(), nullable=True),
sa.Column('description', sa.String(), nullable=True),
sa.Column('related_account_id', sa.Integer(), nullable=True),
sa.Column('reconciled', sa.Boolean(), nullable=False, server_default=sa.false()),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ),
sa.ForeignKeyConstraint(['related_account_id'], ['accounts.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_transactions_id'), 'transactions', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_transactions_id'), table_name='transactions')
op.drop_table('transactions')
op.drop_index(op.f('ix_cash_flows_id'), table_name='cash_flows')
op.drop_table('cash_flows')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_accounts_id'), table_name='accounts')
op.drop_table('accounts')
op.drop_index(op.f('ix_roles_id'), table_name='roles')
op.drop_table('roles')
op.drop_index(op.f('ix_buckets_id'), table_name='buckets')
op.drop_table('buckets')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""cashflow entry projected and actual split
Revision ID: c1be870c3f79
Revises: b2d22e13e06c
Create Date: 2025-07-16 10:24:38.744257
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c1be870c3f79'
down_revision: Union[str, Sequence[str], None] = 'b2d22e13e06c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@@ -0,0 +1,245 @@
[
{
"account_id": 1,
"forecast": [
{
"date": "2025-01-01",
"balance": 21217.22
},
{
"date": "2025-02-01",
"balance": 20643.65
},
{
"date": "2025-03-01",
"balance": 61910.86
},
{
"date": "2025-04-01",
"balance": 121869.26
},
{
"date": "2025-05-01",
"balance": 116787.51
},
{
"date": "2025-06-01",
"balance": 97474.75
},
{
"date": "2025-07-01",
"balance": 28876.15
},
{
"date": "2025-08-01",
"balance": 19355.15
},
{
"date": "2025-09-01",
"balance": 19334.15
},
{
"date": "2025-10-01",
"balance": 19313.15
},
{
"date": "2025-11-01",
"balance": 19292.15
},
{
"date": "2025-12-01",
"balance": 9771.150000000001
},
{
"date": "2026-01-01",
"balance": 4130.810000000001
},
{
"date": "2026-02-01",
"balance": 42964.850000000006
},
{
"date": "2026-03-01",
"balance": 101023.85
},
{
"date": "2026-04-01",
"balance": 100182.74
},
{
"date": "2026-05-01",
"balance": 92845.96
},
{
"date": "2026-06-01",
"balance": 32192.390000000007
}
],
"is_primary": true,
"alert": false,
"account_name": "Checking 5345",
"account_type": "Checking"
},
{
"account_id": 2,
"forecast": [
{
"date": "2025-01-01",
"balance": 0
},
{
"date": "2025-02-01",
"balance": 0
},
{
"date": "2025-03-01",
"balance": 0
},
{
"date": "2025-04-01",
"balance": 0
},
{
"date": "2025-05-01",
"balance": 0
},
{
"date": "2025-06-01",
"balance": 0
},
{
"date": "2025-07-01",
"balance": 30000
},
{
"date": "2025-08-01",
"balance": 30096.25
},
{
"date": "2025-09-01",
"balance": 30192.808802083335
},
{
"date": "2025-10-01",
"balance": 30289.67739699002
},
{
"date": "2025-11-01",
"balance": 30386.856778638696
},
{
"date": "2025-12-01",
"balance": 30484.34794413683
},
{
"date": "2026-01-01",
"balance": 0
},
{
"date": "2026-02-01",
"balance": 0
},
{
"date": "2026-03-01",
"balance": 0
},
{
"date": "2026-04-01",
"balance": 0
},
{
"date": "2026-05-01",
"balance": 0
},
{
"date": "2026-06-01",
"balance": 30096.25
}
],
"is_primary": false,
"alert": false,
"account_name": "CD - 6mo Op",
"account_type": "CD"
},
{
"account_id": 5,
"forecast": [
{
"date": "2025-01-01",
"balance": 0
},
{
"date": "2025-02-01",
"balance": 0
},
{
"date": "2025-03-01",
"balance": 0
},
{
"date": "2025-04-01",
"balance": 0
},
{
"date": "2025-05-01",
"balance": 0
},
{
"date": "2025-06-01",
"balance": 0
},
{
"date": "2025-07-01",
"balance": 30000
},
{
"date": "2025-08-01",
"balance": 30050
},
{
"date": "2025-09-01",
"balance": 20584.25
},
{
"date": "2025-10-01",
"balance": 11102.72375
},
{
"date": "2025-11-01",
"balance": 1605.3949562499993
},
{
"date": "2025-12-01",
"balance": 1608.070614510416
},
{
"date": "2026-01-01",
"balance": 1610.7507322012666
},
{
"date": "2026-02-01",
"balance": 1613.4353167549355
},
{
"date": "2026-03-01",
"balance": 1616.1243756161937
},
{
"date": "2026-04-01",
"balance": 1618.8179162422207
},
{
"date": "2026-05-01",
"balance": 1621.5159461026244
},
{
"date": "2026-06-01",
"balance": 31674.218472679462
}
],
"is_primary": false,
"alert": false,
"account_name": "Money Market Operating",
"account_type": "Money Market"
}
]

23
backend/backend/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -0,0 +1,173 @@
[
{
"account_id": 1,
"forecast": [
{
"date": "2026-01-01",
"balance": 0
},
{
"date": "2026-02-01",
"balance": 0
},
{
"date": "2026-03-01",
"balance": 0
},
{
"date": "2026-04-01",
"balance": 0
},
{
"date": "2026-05-01",
"balance": 0
},
{
"date": "2026-06-01",
"balance": 0
},
{
"date": "2026-07-01",
"balance": 0
},
{
"date": "2026-08-01",
"balance": 0
},
{
"date": "2026-09-01",
"balance": 0
},
{
"date": "2026-10-01",
"balance": 0
},
{
"date": "2026-11-01",
"balance": 0
},
{
"date": "2026-12-01",
"balance": 0
}
],
"is_primary": true,
"alert": true,
"account_name": "Checking 5345",
"account_type": "Checking"
},
{
"account_id": 2,
"forecast": [
{
"date": "2026-01-01",
"balance": 0
},
{
"date": "2026-02-01",
"balance": 0
},
{
"date": "2026-03-01",
"balance": 0
},
{
"date": "2026-04-01",
"balance": 0
},
{
"date": "2026-05-01",
"balance": 0
},
{
"date": "2026-06-01",
"balance": 0
},
{
"date": "2026-07-01",
"balance": 0
},
{
"date": "2026-08-01",
"balance": 0
},
{
"date": "2026-09-01",
"balance": 0
},
{
"date": "2026-10-01",
"balance": 0
},
{
"date": "2026-11-01",
"balance": 0
},
{
"date": "2026-12-01",
"balance": 0
}
],
"is_primary": false,
"alert": false,
"account_name": "CD - 6mo Op",
"account_type": "CD"
},
{
"account_id": 5,
"forecast": [
{
"date": "2026-01-01",
"balance": 0
},
{
"date": "2026-02-01",
"balance": 0
},
{
"date": "2026-03-01",
"balance": 0
},
{
"date": "2026-04-01",
"balance": 0
},
{
"date": "2026-05-01",
"balance": 0
},
{
"date": "2026-06-01",
"balance": 0
},
{
"date": "2026-07-01",
"balance": 0
},
{
"date": "2026-08-01",
"balance": 0
},
{
"date": "2026-09-01",
"balance": 0
},
{
"date": "2026-10-01",
"balance": 0
},
{
"date": "2026-11-01",
"balance": 0
},
{
"date": "2026-12-01",
"balance": 0
}
],
"is_primary": false,
"alert": false,
"account_name": "Money Market Operating",
"account_type": "Money Market"
}
]

View File

@@ -0,0 +1,173 @@
[
{
"account_id": 1,
"forecast": [
{
"date": "2025-01-01",
"balance": 21217.22
},
{
"date": "2025-02-01",
"balance": 20643.65
},
{
"date": "2025-03-01",
"balance": 61910.86
},
{
"date": "2025-04-01",
"balance": 121869.26
},
{
"date": "2025-05-01",
"balance": 116787.51
},
{
"date": "2025-06-01",
"balance": 97474.75
},
{
"date": "2025-07-01",
"balance": 28876.15
},
{
"date": "2025-08-01",
"balance": 19355.15
},
{
"date": "2025-09-01",
"balance": 9834.150000000001
},
{
"date": "2025-10-01",
"balance": 313.15000000000146
},
{
"date": "2025-11-01",
"balance": -9207.849999999999
},
{
"date": "2025-12-01",
"balance": -18728.85
}
],
"is_primary": true,
"alert": true,
"account_name": "Checking 5345",
"account_type": "Checking"
},
{
"account_id": 2,
"forecast": [
{
"date": "2025-01-01",
"balance": 0
},
{
"date": "2025-02-01",
"balance": 0
},
{
"date": "2025-03-01",
"balance": 0
},
{
"date": "2025-04-01",
"balance": 0
},
{
"date": "2025-05-01",
"balance": 0
},
{
"date": "2025-06-01",
"balance": 0
},
{
"date": "2025-07-01",
"balance": 30000
},
{
"date": "2025-08-01",
"balance": 30096.25
},
{
"date": "2025-09-01",
"balance": 30192.808802083335
},
{
"date": "2025-10-01",
"balance": 30289.67739699002
},
{
"date": "2025-11-01",
"balance": 30386.856778638696
},
{
"date": "2025-12-01",
"balance": 30484.34794413683
}
],
"is_primary": false,
"alert": false,
"account_name": "CD - 6mo Op",
"account_type": "CD"
},
{
"account_id": 5,
"forecast": [
{
"date": "2025-01-01",
"balance": 0
},
{
"date": "2025-02-01",
"balance": 0
},
{
"date": "2025-03-01",
"balance": 0
},
{
"date": "2025-04-01",
"balance": 0
},
{
"date": "2025-05-01",
"balance": 0
},
{
"date": "2025-06-01",
"balance": 0
},
{
"date": "2025-07-01",
"balance": 30000
},
{
"date": "2025-08-01",
"balance": 30050
},
{
"date": "2025-09-01",
"balance": 30100.083333333332
},
{
"date": "2025-10-01",
"balance": 30150.25013888889
},
{
"date": "2025-11-01",
"balance": 30200.500555787035
},
{
"date": "2025-12-01",
"balance": 30250.834723380012
}
],
"is_primary": false,
"alert": false,
"account_name": "Money Market Operating",
"account_type": "Money Market"
}
]

View File

@@ -0,0 +1,3 @@
{
"detail": "Backend service unavailable. Please check the logs for more information."
}

View File

@@ -0,0 +1,61 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.2.0",
"@mui/material": "^7.2.0",
"@types/react-router-dom": "^5.3.3",
"axios": "^1.10.0",
"cra-template-pwa-typescript": "2.0.0",
"echarts": "^5.6.0",
"echarts-for-react": "^3.0.2",
"file-saver": "^2.0.5",
"plotly.js": "^3.0.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-plotly.js": "^2.6.0",
"react-router-dom": "^7.6.3",
"react-scripts": "5.0.1",
"recharts": "^3.1.0",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "DANGEROUSLY_DISABLE_HOST_CHECK=true react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/file-saver": "^2.0.7",
"@types/jest": "^30.0.0",
"@types/plotly.js": "^3.0.2",
"@types/react-dom": "^19.1.6",
"@types/testing-library__react": "^10.2.0",
"jest": "^30.0.4",
"ts-jest": "^29.4.0"
},
"proxy": "http://localhost:8000"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>HOA Pro</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -0,0 +1,147 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { fetchAccount, fetchAccountHistory } from './api';
import { Box, Typography, Paper, Button, Grid, CircularProgress, useTheme } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
function formatLocalDate(dateString: string | null | undefined) {
if (!dateString) return '-';
const d = new Date(dateString);
return d.toLocaleDateString();
}
// Helper to calculate interest to maturity or annualized, with monthly compounding
function calculateInterest(account: any) {
if (!account.interest_rate || !account.balance) return null;
const rate = account.interest_rate / 100;
const balance = account.balance;
const n = 12; // monthly compounding
if (account.maturity_date) {
// Calculate months from now to maturity
const now = new Date();
const maturity = new Date(account.maturity_date);
const msPerMonth = 1000 * 60 * 60 * 24 * 30.4375; // average days per month
const months = Math.max(0, Math.round((maturity.getTime() - now.getTime()) / msPerMonth));
const years = months / 12;
// Compound interest formula: A = P * (1 + r/n)^(n*t)
const amount = balance * Math.pow(1 + rate / n, n * years);
const interest = amount - balance;
return {
label: `Est. Interest to Maturity (${months} months, compounded monthly)`,
value: interest,
};
} else {
// Annualized, compounded monthly for 1 year
const amount = balance * Math.pow(1 + rate / n, n * 1);
const interest = amount - balance;
return {
label: 'Est. Annual Interest (compounded monthly)',
value: interest,
};
}
}
const AccountOverview: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [account, setAccount] = useState<any>(null);
const [history, setHistory] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const theme = useTheme();
useEffect(() => {
const load = async () => {
setLoading(true);
setError(null);
try {
const acc = await fetchAccount(Number(id));
const hist = await fetchAccountHistory(Number(id));
setAccount(acc);
setHistory(hist);
} catch (e: any) {
setError(e.message || 'Failed to load account');
} finally {
setLoading(false);
}
};
load();
}, [id]);
if (loading) return <Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh"><CircularProgress /></Box>;
if (error) return <Box color="error.main">{error}</Box>;
if (!account) return null;
// Prepare chart data: show only date (no time)
const chartData = history.map((h: any) => ({
...h,
date: formatLocalDate(h.date),
}));
return (
<Box p={2}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate(-1)} sx={{ mb: 2 }}>
Back to Accounts
</Button>
<Typography variant="h5" gutterBottom>
Account Overview - {account.name}
</Typography>
<Box display="flex" gap={2} mb={3}>
{/* Left: Balance */}
<Paper elevation={3} sx={{ flex: 1, p: 3, borderRadius: 3, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 0 }}>
<Typography variant="subtitle2" color="textSecondary" gutterBottom>Balance</Typography>
<Typography variant="h4" color="primary" sx={{ fontWeight: 700 }}>
{account.balance?.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}
</Typography>
</Paper>
{/* Center: Institution, Type, Bucket (values only) */}
<Paper elevation={3} sx={{ flex: 1, p: 3, borderRadius: 3, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 500 }}>{account.institution_name}</Typography>
<Typography variant="body1">{account.account_type?.name || account.type}</Typography>
<Typography variant="body1">{account.bucket?.name}</Typography>
</Paper>
{/* Right: Interest Rate, Maturity Date, Estimated Interest */}
<Paper elevation={3} sx={{ flex: 1, p: 3, borderRadius: 3, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 0 }}>
<Typography variant="subtitle2" color="textSecondary" gutterBottom>Interest Rate</Typography>
<Typography variant="h6">{account.interest_rate ? `${account.interest_rate}%` : '-'}</Typography>
<Typography variant="subtitle2" color="textSecondary" gutterBottom mt={2}>Maturity Date</Typography>
<Typography variant="body1">{formatLocalDate(account.maturity_date)}</Typography>
{/* Estimated Interest */}
{(() => {
const interest = calculateInterest(account);
return interest ? (
<Box mt={2} textAlign="center">
<Typography variant="subtitle2" color="textSecondary">{interest.label}</Typography>
<Typography variant="body1" color="success.main" sx={{ fontWeight: 600 }}>
{interest.value.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}
</Typography>
</Box>
) : null;
})()}
</Paper>
</Box>
<Box mt={2}>
<Typography variant="body2" color="textSecondary">
Last data update: {formatLocalDate(account.last_validated_at)}
</Typography>
</Box>
<Box mt={4}>
<Typography variant="h6" gutterBottom>Balance Over Time</Typography>
<Paper elevation={2} sx={{ p: 2, borderRadius: 2, background: theme.palette.background.default }}>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData} margin={{ top: 16, right: 32, left: 32, bottom: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tickFormatter={d => d} minTickGap={20} />
<YAxis width={90} tickFormatter={v => v.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })} />
<Tooltip formatter={(v: number) => v.toLocaleString(undefined, { style: 'currency', currency: 'USD' })} labelFormatter={d => d} />
<Line type="monotone" dataKey="balance" stroke={theme.palette.primary.main} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</Paper>
</Box>
</Box>
);
};
export default AccountOverview;

View File

@@ -0,0 +1,279 @@
import React, { useEffect, useState, useContext } from 'react';
import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem, IconButton } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import MonetizationOnIcon from '@mui/icons-material/MonetizationOn';
import { fetchAccounts, createAccount, deleteAccount, fetchBuckets, fetchAccountTypes, updateAccount } from './api';
import { AuthContext } from './App';
import DialogContentText from '@mui/material/DialogContentText';
import { useNavigate } from 'react-router-dom';
const Accounts: React.FC = () => {
const [accounts, setAccounts] = useState<any[]>([]);
const [buckets, setBuckets] = useState<any[]>([]);
const [accountTypes, setAccountTypes] = useState<any[]>([]);
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
name: '',
institution_name: '',
account_type_id: '',
interest_rate: 0,
maturity_date: '',
balance: 0,
bucket_id: '',
});
const [editOpen, setEditOpen] = useState(false);
const [editForm, setEditForm] = useState<any>(null);
const [balanceOpen, setBalanceOpen] = useState(false);
const [balanceForm, setBalanceForm] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<any>(null);
const { user } = useContext(AuthContext);
const navigate = useNavigate();
const load = async () => {
setAccounts(await fetchAccounts());
setBuckets(await fetchBuckets());
setAccountTypes(await fetchAccountTypes());
};
useEffect(() => { load(); }, []);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = async () => {
const payload = {
...form,
interest_rate: parseFloat(form.interest_rate as any),
balance: parseFloat(form.balance as any),
bucket_id: parseInt(form.bucket_id),
account_type_id: parseInt(form.account_type_id),
maturity_date: form.maturity_date === '' ? null : form.maturity_date,
};
await createAccount(payload);
setOpen(false);
setForm({ name: '', institution_name: '', account_type_id: '', interest_rate: 0, maturity_date: '', balance: 0, bucket_id: '' });
load();
};
const handleDeleteClick = (acc: any) => {
setDeleteTarget(acc);
setDeleteConfirmOpen(true);
};
const handleDeleteConfirm = async () => {
if (deleteTarget) {
await deleteAccount(deleteTarget.id);
setDeleteConfirmOpen(false);
setDeleteTarget(null);
load();
}
};
const handleDeleteCancel = () => {
setDeleteConfirmOpen(false);
setDeleteTarget(null);
};
const handleEditOpen = (acc: any) => {
setEditForm({
...acc,
account_type_id: acc.account_type?.id || '',
bucket_id: acc.bucket?.id || '',
maturity_date: acc.maturity_date ? acc.maturity_date.substring(0, 10) : '',
});
setEditOpen(true);
};
const handleEditClose = () => setEditOpen(false);
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEditForm({ ...editForm, [e.target.name]: e.target.value });
};
const handleEditSubmit = async () => {
const payload = {
...editForm,
interest_rate: parseFloat(editForm.interest_rate as any),
balance: parseFloat(editForm.balance as any),
bucket_id: parseInt(editForm.bucket_id),
account_type_id: parseInt(editForm.account_type_id),
maturity_date: editForm.maturity_date === '' ? null : editForm.maturity_date,
};
await updateAccount(editForm.id, payload);
setEditOpen(false);
setEditForm(null);
load();
};
const handleBalanceOpen = (acc: any) => {
setBalanceForm({ id: acc.id, balance: acc.balance });
setBalanceOpen(true);
};
const handleBalanceClose = () => setBalanceOpen(false);
const handleBalanceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setBalanceForm({ ...balanceForm, balance: e.target.value });
};
const handleBalanceSubmit = async () => {
await updateAccount(balanceForm.id, { balance: parseFloat(balanceForm.balance) });
setBalanceOpen(false);
setBalanceForm(null);
load();
};
// Group accounts by bucket
const grouped = buckets.map(bucket => ({
...bucket,
accounts: accounts.filter(acc => acc.bucket && acc.bucket.id === bucket.id)
}));
return (
<Box>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h5">Accounts</Typography>
{user?.role === 'admin' && (
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpen}>Add Account</Button>
)}
</Box>
{grouped.map(group => {
// Calculate total balance for this bucket
const totalBalance = group.accounts.reduce((sum: number, acc: any) => sum + (acc.balance || 0), 0);
return (
<Box key={group.id} mb={4}>
<Box display="flex" alignItems="center" gap={2} mb={1}>
<Typography variant="h6" gutterBottom>{group.name}</Typography>
<Typography variant="subtitle1" color="primary" sx={{ fontWeight: 600, background: '#f5f5f5', borderRadius: 2, px: 2, py: 0.5 }}>
{totalBalance.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}
</Typography>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Institution</TableCell>
<TableCell>Type</TableCell>
<TableCell>Interest Rate (%)</TableCell>
<TableCell>Maturity Date</TableCell>
<TableCell>Balance</TableCell>
<TableCell>Last Verified</TableCell>
{user?.role === 'admin' && <TableCell>Actions</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{group.accounts.map((acc: any) => (
<TableRow key={acc.id} hover style={{ cursor: 'pointer' }} onClick={() => navigate(`/accounts/${acc.id}`)}>
<TableCell>{acc.name}</TableCell>
<TableCell>{acc.institution_name}</TableCell>
<TableCell>{acc.account_type?.name}</TableCell>
<TableCell>{acc.interest_rate}</TableCell>
<TableCell>{acc.maturity_date ? acc.maturity_date.substring(0, 10) : ''}</TableCell>
<TableCell>{acc.balance.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
<TableCell>{acc.last_validated_at ? acc.last_validated_at.substring(0, 19).replace('T', ' ') : ''}</TableCell>
{user?.role === 'admin' && (
<TableCell>
<IconButton aria-label="update-balance" onClick={e => { e.stopPropagation(); handleBalanceOpen(acc); }}>
<MonetizationOnIcon />
</IconButton>
<IconButton aria-label="edit" onClick={e => { e.stopPropagation(); handleEditOpen(acc); }}>
<EditIcon />
</IconButton>
<IconButton aria-label="delete" color="error" onClick={e => { e.stopPropagation(); handleDeleteClick(acc); }}>
<DeleteIcon />
</IconButton>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);
})}
<Dialog open={open} onClose={handleClose}>
<DialogTitle>Add Account</DialogTitle>
<DialogContent>
<TextField label="Name" name="name" value={form.name} onChange={handleChange} fullWidth margin="normal" required />
<TextField label="Institution Name" name="institution_name" value={form.institution_name} onChange={handleChange} fullWidth margin="normal" required />
<TextField select label="Type" name="account_type_id" value={form.account_type_id} onChange={handleChange} fullWidth margin="normal" required>
{accountTypes.map((opt: any) => <MenuItem key={opt.id} value={opt.id}>{opt.name}</MenuItem>)}
</TextField>
<TextField select label="Bucket" name="bucket_id" value={form.bucket_id} onChange={handleChange} fullWidth margin="normal" required>
{buckets.map(b => <MenuItem key={b.id} value={b.id}>{b.name}</MenuItem>)}
</TextField>
<TextField label="Interest Rate (%)" name="interest_rate" type="number" value={form.interest_rate} onChange={handleChange} fullWidth margin="normal" />
{/* Show Maturity Date only if selected account type is CD */}
{(() => {
const selectedType = accountTypes.find((t: any) => String(t.id) === String(form.account_type_id));
if (selectedType && selectedType.name === 'CD') {
return (
<TextField label="Maturity Date" name="maturity_date" type="date" value={form.maturity_date} onChange={handleChange} fullWidth margin="normal" InputLabelProps={{ shrink: true }} />
);
}
return null;
})()}
<TextField label="Balance" name="balance" type="number" value={form.balance} onChange={handleChange} fullWidth margin="normal" />
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleSubmit} variant="contained">Add</Button>
</DialogActions>
</Dialog>
<Dialog open={editOpen} onClose={handleEditClose}>
<DialogTitle>Edit Account</DialogTitle>
<DialogContent>
{editForm && <>
<TextField label="Name" name="name" value={editForm.name} onChange={handleEditChange} fullWidth margin="normal" required />
<TextField label="Institution Name" name="institution_name" value={editForm.institution_name} onChange={handleEditChange} fullWidth margin="normal" required />
<TextField select label="Type" name="account_type_id" value={editForm.account_type_id} onChange={handleEditChange} fullWidth margin="normal" required>
{accountTypes.map((opt: any) => <MenuItem key={opt.id} value={opt.id}>{opt.name}</MenuItem>)}
</TextField>
<TextField select label="Bucket" name="bucket_id" value={editForm.bucket_id} onChange={handleEditChange} fullWidth margin="normal" required>
{buckets.map(b => <MenuItem key={b.id} value={b.id}>{b.name}</MenuItem>)}
</TextField>
<TextField label="Interest Rate (%)" name="interest_rate" type="number" value={editForm.interest_rate} onChange={handleEditChange} fullWidth margin="normal" />
{/* Show Maturity Date only if selected account type is CD */}
{(() => {
const selectedType = accountTypes.find((t: any) => String(t.id) === String(editForm.account_type_id));
if (selectedType && selectedType.name === 'CD') {
return (
<TextField label="Maturity Date" name="maturity_date" type="date" value={editForm.maturity_date} onChange={handleEditChange} fullWidth margin="normal" InputLabelProps={{ shrink: true }} />
);
}
return null;
})()}
<TextField label="Balance" name="balance" type="number" value={editForm.balance} onChange={handleEditChange} fullWidth margin="normal" />
</>}
</DialogContent>
<DialogActions>
<Button onClick={handleEditClose}>Cancel</Button>
<Button onClick={handleEditSubmit} variant="contained">Save</Button>
</DialogActions>
</Dialog>
<Dialog open={balanceOpen} onClose={handleBalanceClose}>
<DialogTitle>Update Balance</DialogTitle>
<DialogContent>
{balanceForm && <TextField label="Balance" name="balance" type="number" value={balanceForm.balance} onChange={handleBalanceChange} fullWidth margin="normal" />}
</DialogContent>
<DialogActions>
<Button onClick={handleBalanceClose}>Cancel</Button>
<Button onClick={handleBalanceSubmit} variant="contained">Save</Button>
</DialogActions>
</Dialog>
<Dialog open={deleteConfirmOpen} onClose={handleDeleteCancel}>
<DialogTitle>Confirm Account Deletion</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete the account "{deleteTarget?.name}"? This action cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel}>Cancel</Button>
<Button onClick={handleDeleteConfirm} color="error">Delete</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default Accounts;

View File

@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@@ -0,0 +1,265 @@
import React, { useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, Link as RouterLink } from 'react-router-dom';
import { AppBar, Toolbar, Typography, CssBaseline, Drawer, List, ListItem, ListItemText, IconButton, Box, Button, Link, Avatar, Menu, MenuItem, Divider } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import { useState } from 'react';
import { ThemeProvider } from '@mui/material/styles';
import Brightness4Icon from '@mui/icons-material/Brightness4';
import Brightness7Icon from '@mui/icons-material/Brightness7';
import { getTheme } from './theme';
import Login from './Login';
import { getUser, fetchUser, logout } from './auth';
import ListItemButton from '@mui/material/ListItemButton';
import LogoutIcon from '@mui/icons-material/Logout';
import PersonIcon from '@mui/icons-material/Person';
import StarIcon from '@mui/icons-material/Star';
import NotificationsIcon from '@mui/icons-material/Notifications';
import SettingsIcon from '@mui/icons-material/Settings';
import HelpIcon from '@mui/icons-material/Help';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import Accounts from './Accounts';
import AccountOverview from './AccountOverview';
import CashFlows from './CashFlows';
import { useLocation } from 'react-router-dom';
import Forecasting from './Forecasting';
import Transactions from './Transactions';
export const AuthContext = React.createContext<{ user: any, setUser: (u: any) => void }>({ user: null, setUser: () => {} });
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user } = React.useContext(AuthContext);
if (!user) {
return <Navigate to="/login" />;
}
return <>{children}</>;
};
// Placeholder components for pages
const Dashboard = () => <div>Dashboard</div>;
const Reports = () => <div>Reports</div>;
const drawerWidth = 220;
function AppContent({ mode, handleThemeToggle, handleLogout, user, setUser, navLinks }: any) {
const location = useLocation();
const isLoginPage = window.location.pathname === '/login';
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleProfileClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleProfileClose = () => {
setAnchorEl(null);
};
return (
<>
<CssBaseline />
{isLoginPage ? (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="*" element={<Navigate to="/login" />} />
</Routes>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppBar position="static" color="default" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Toolbar sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Link
component={RouterLink}
to="/dashboard"
underline="none"
sx={{ display: 'flex', alignItems: 'center' }}
>
<img
src="/images/hoapro-logo.svg"
alt="HOA Pro"
style={{
height: '40px',
width: 'auto',
cursor: 'pointer'
}}
/>
</Link>
{navLinks.map((link: any) => (
<Link
key={link.to}
component={RouterLink}
to={link.to}
underline="none"
sx={{
color: location.pathname.startsWith(link.to) ? 'primary.main' : 'text.primary',
fontWeight: location.pathname.startsWith(link.to) ? 'bold' : 'normal',
borderBottom: location.pathname.startsWith(link.to) ? '2px solid' : '2px solid transparent',
borderColor: location.pathname.startsWith(link.to) ? 'primary.main' : 'transparent',
px: 1.5,
py: 0.5,
fontSize: '1rem',
transition: 'border-color 0.2s',
}}
>
{link.label}
</Link>
))}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton color="inherit" onClick={handleThemeToggle} size="small">
{mode === 'dark' ? <Brightness7Icon /> : <Brightness4Icon />}
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', ml: 2 }}>
<Avatar
onClick={handleProfileClick}
sx={{
width: 32,
height: 32,
cursor: 'pointer',
bgcolor: 'primary.main',
fontSize: '0.875rem',
fontWeight: 'bold'
}}
>
{user?.username?.charAt(0).toUpperCase() || 'U'}
</Avatar>
<IconButton
size="small"
onClick={handleProfileClick}
sx={{ ml: 0.5, color: 'text.secondary' }}
>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
</Box>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleProfileClose}
PaperProps={{
sx: {
mt: 1,
minWidth: 200,
boxShadow: '0 4px 20px rgba(0,0,0,0.1)',
borderRadius: 2,
}
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem sx={{ py: 1, px: 2, color: 'text.secondary', fontSize: '0.875rem' }}>
Signed in as {user?.username || 'user'}
</MenuItem>
<Divider />
<MenuItem onClick={handleProfileClose} sx={{ py: 1.5, px: 2 }}>
<PersonIcon sx={{ mr: 2, fontSize: '1.25rem' }} />
Profile
</MenuItem>
<MenuItem onClick={handleProfileClose} sx={{ py: 1.5, px: 2 }}>
<StarIcon sx={{ mr: 2, fontSize: '1.25rem' }} />
Starred
</MenuItem>
<MenuItem onClick={handleProfileClose} sx={{ py: 1.5, px: 2 }}>
<NotificationsIcon sx={{ mr: 2, fontSize: '1.25rem' }} />
Subscriptions
</MenuItem>
<MenuItem onClick={handleProfileClose} sx={{ py: 1.5, px: 2 }}>
<SettingsIcon sx={{ mr: 2, fontSize: '1.25rem' }} />
Settings
</MenuItem>
<MenuItem onClick={handleProfileClose} sx={{ py: 1.5, px: 2 }}>
<HelpIcon sx={{ mr: 2, fontSize: '1.25rem' }} />
Help
</MenuItem>
{user?.role === 'admin' && (
<>
<Divider />
<MenuItem onClick={handleProfileClose} sx={{ py: 1.5, px: 2 }}>
<AdminPanelSettingsIcon sx={{ mr: 2, fontSize: '1.25rem' }} />
Site Administration
</MenuItem>
</>
)}
<Divider />
<MenuItem
onClick={() => {
handleProfileClose();
handleLogout();
}}
sx={{ py: 1.5, px: 2 }}
>
<LogoutIcon sx={{ mr: 2, fontSize: '1.25rem' }} />
Sign Out
</MenuItem>
</Menu>
</Box>
</Toolbar>
</AppBar>
<Box component="main" sx={{ flexGrow: 1, p: 3, width: '100%' }}>
<Routes>
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/accounts" element={<ProtectedRoute><Accounts /></ProtectedRoute>} />
<Route path="/accounts/:id" element={<ProtectedRoute><AccountOverview /></ProtectedRoute>} />
<Route path="/transactions" element={<ProtectedRoute><Transactions /></ProtectedRoute>} />
<Route path="/cashflows" element={<ProtectedRoute><CashFlows /></ProtectedRoute>} />
<Route path="/forecasting" element={<ProtectedRoute><Forecasting /></ProtectedRoute>} />
<Route path="/reports" element={<ProtectedRoute><Reports /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/dashboard" />} />
</Routes>
</Box>
</Box>
)}
</>
);
}
function App() {
const [mobileOpen, setMobileOpen] = useState(false);
const [mode, setMode] = useState<'light' | 'dark'>(
() => (localStorage.getItem('themeMode') as 'light' | 'dark') || 'light'
);
const [user, setUser] = React.useState<any>(getUser());
const navLinks = [
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Accounts', to: '/accounts' },
{ label: 'Transactions', to: '/transactions' },
{ label: 'Cash Flows', to: '/cashflows' },
{ label: 'Forecasting', to: '/forecasting' },
{ label: 'Reports', to: '/reports' },
];
useEffect(() => {
if (!user) {
fetchUser().then(setUser);
}
}, [user]);
const handleThemeToggle = () => {
const newMode = mode === 'light' ? 'dark' : 'light';
setMode(newMode);
localStorage.setItem('themeMode', newMode);
};
const handleLogout = () => {
logout();
setUser(null);
window.location.href = '/login';
};
return (
<ThemeProvider theme={getTheme(mode)}>
<AuthContext.Provider value={{ user, setUser }}>
<Router>
<AppContent
mode={mode}
handleThemeToggle={handleThemeToggle}
handleLogout={handleLogout}
user={user}
setUser={setUser}
navLinks={navLinks}
/>
</Router>
</AuthContext.Provider>
</ThemeProvider>
);
}
export default App;

View File

@@ -0,0 +1,85 @@
import React, { useEffect, useState, useContext } from 'react';
import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import { fetchBuckets, createBucket, deleteBucket } from './api';
import { AuthContext } from './App';
const Buckets: React.FC = () => {
const [buckets, setBuckets] = useState<any[]>([]);
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ name: '', description: '' });
const { user } = useContext(AuthContext);
const load = async () => {
setBuckets(await fetchBuckets());
};
useEffect(() => { load(); }, []);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = async () => {
await createBucket(form);
setOpen(false);
setForm({ name: '', description: '' });
load();
};
const handleDelete = async (id: number) => {
await deleteBucket(id);
load();
};
return (
<Box>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h5">Buckets</Typography>
{user?.role === 'admin' && (
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpen}>Add Bucket</Button>
)}
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Description</TableCell>
{user?.role === 'admin' && <TableCell>Actions</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{buckets.map(bucket => (
<TableRow key={bucket.id}>
<TableCell>{bucket.name}</TableCell>
<TableCell>{bucket.description}</TableCell>
{user?.role === 'admin' && (
<TableCell>
<IconButton color="error" onClick={() => handleDelete(bucket.id)}><DeleteIcon /></IconButton>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog open={open} onClose={handleClose}>
<DialogTitle>Add Bucket</DialogTitle>
<DialogContent>
<TextField label="Name" name="name" value={form.name} onChange={handleChange} fullWidth margin="normal" required />
<TextField label="Description" name="description" value={form.description} onChange={handleChange} fullWidth margin="normal" />
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleSubmit} variant="contained">Add</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default Buckets;

View File

@@ -0,0 +1,376 @@
import React, { useEffect, useState, useContext, useMemo } from 'react';
import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, TableFooter } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { fetchCashFlowCategories, fetchCashFlowEntries, createCashFlowEntry, updateCashFlowEntry, deleteCashFlowEntry } from './api';
import { AuthContext } from './App';
const MONTHS = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
const CashFlows: React.FC = () => {
const { user } = useContext(AuthContext);
const [categories, setCategories] = useState<any[]>([]);
const [entries, setEntries] = useState<any[]>([]);
const [year, setYear] = useState<number>(new Date().getFullYear());
const [edit, setEdit] = useState<{category: any, month: number, entry: any} | null>(null);
const [form, setForm] = useState<{projected_amount: number | string, actual_amount: number | string}>({ projected_amount: '', actual_amount: '' });
const [loading, setLoading] = useState(false);
const [warning, setWarning] = useState<string | null>(null);
const [bulkDialog, setBulkDialog] = useState<{open: boolean, category: any | null, value: string}>({ open: false, category: null, value: '' });
const [confirmDialog, setConfirmDialog] = useState<{open: boolean, onConfirm: (() => void) | null}>({ open: false, onConfirm: null });
const [operatingStartBalance, setOperatingStartBalance] = useState<number | null>(null);
const load = async () => {
setLoading(true);
const cats = await fetchCashFlowCategories();
const ents = await fetchCashFlowEntries(year);
setCategories(cats);
setEntries(ents);
setLoading(false);
// Debug logging
console.debug('CASHFLOW: Loaded categories', cats);
console.debug('CASHFLOW: Loaded entries', ents);
// Fetch current operating account balance for starting point
try {
const resp = await fetch('/accounts');
const accounts = await resp.json();
// Sum balances of all operating accounts
const opAccounts = accounts.filter((a: any) => a.funding_type === 'Operating');
const opBalance = opAccounts.reduce((sum: number, a: any) => sum + (a.balance || 0), 0);
setOperatingStartBalance(opBalance);
console.debug('CASHFLOW: Operating start balance', opBalance);
// Set warning for projected operating balance
let runningBalance = opBalance;
let minMonth = null;
for (let m = 1; m <= 12; m++) {
let income = 0, expense = 0;
for (const cat of cats) {
const entry = ents.find((e: any) => e.category_id === cat.id && e.month === m);
if (cat.funding_type === 'Operating') {
if (cat.category_type === 'income') income += entry?.projected_amount || 0;
if (cat.category_type === 'expense') expense += entry?.projected_amount || 0;
}
}
runningBalance += income - expense;
if (runningBalance < 1000 && !minMonth) minMonth = m;
}
setWarning(minMonth ? `Warning: Projected operating balance falls below $1,000 in ${MONTHS[minMonth-1]}` : null);
} catch (e) {
setOperatingStartBalance(null);
setWarning(null);
console.error('CASHFLOW: Failed to fetch operating account balance', e);
}
};
useEffect(() => { load(); }, [year]);
// Calculate projected operating balance and minMonth using useMemo for explanation
const { opBalance, minMonth } = useMemo(() => {
if (operatingStartBalance == null) return { opBalance: null, minMonth: null };
let runningBalance = operatingStartBalance;
let minMonth = null;
for (let m = 1; m <= 12; m++) {
let income = 0, expense = 0;
for (const cat of categories) {
const entry = entries.find((e: any) => e.category_id === cat.id && e.month === m);
if (cat.funding_type === 'Operating') {
if (cat.category_type === 'income') income += entry?.projected_amount || 0;
if (cat.category_type === 'expense') expense += entry?.projected_amount || 0;
}
}
runningBalance += income - expense;
if (runningBalance < 1000 && !minMonth) minMonth = m;
}
return { opBalance: runningBalance, minMonth };
}, [categories, entries, year, operatingStartBalance]);
const handleCellClick = (cat: any, month: number) => {
if (user?.role !== 'admin') return;
const entry = entries.find(e => e.category_id === cat.id && e.month === month);
setEdit({ category: cat, month, entry });
setForm({
projected_amount: (typeof entry?.projected_amount === 'number' && entry.projected_amount === 0) ? 0 : (entry?.projected_amount ?? ''),
actual_amount: (typeof entry?.actual_amount === 'number' && entry.actual_amount === 0) ? 0 : (entry?.actual_amount ?? '')
});
};
const handleFormChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setForm(f => ({ ...f, [name]: value }));
};
const handleSave = async () => {
if (!edit) return;
const payload = {
category_id: edit.category.id,
year,
month: edit.month,
projected_amount: parseFloat(form.projected_amount as any),
actual_amount: parseFloat(form.actual_amount as any),
};
if (edit.entry) {
await updateCashFlowEntry(edit.entry.id, payload);
console.debug('CASHFLOW: Updated entry', payload);
} else {
await createCashFlowEntry(payload);
console.debug('CASHFLOW: Created entry', payload);
}
setEdit(null);
load();
};
const handleDelete = async () => {
if (edit?.entry) {
await deleteCashFlowEntry(edit.entry.id);
console.debug('CASHFLOW: Deleted entry', edit.entry.id);
setEdit(null);
load();
}
};
const handleBulkClick = (cat: any) => {
setBulkDialog({ open: true, category: cat, value: '' });
};
const handleBulkValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setBulkDialog(b => ({ ...b, value: e.target.value }));
};
const handleBulkSubmit = () => {
setConfirmDialog({ open: true, onConfirm: async () => {
setConfirmDialog({ open: false, onConfirm: null });
setBulkDialog(b => ({ ...b, open: false }));
// For each month, create or update the entry for this category and year
for (let m = 1; m <= 12; m++) {
const entry = entries.find(e => e.category_id === bulkDialog.category.id && e.month === m);
const payload = {
category_id: bulkDialog.category.id,
year,
month: m,
projected_amount: parseFloat(bulkDialog.value),
actual_amount: entry?.actual_amount || null,
};
if (entry) {
await updateCashFlowEntry(entry.id, payload);
} else {
await createCashFlowEntry(payload);
}
}
load();
}});
};
// Group categories by funding type and type
const grouped = categories.reduce((acc: any, cat: any) => {
if (!acc[cat.funding_type]) acc[cat.funding_type] = { income: [], expense: [] };
acc[cat.funding_type][cat.category_type].push(cat);
return acc;
}, {});
// Add this helper function inside the CashFlows component:
function getTotalsForGroup(group: any[], entries: any[]) {
const monthTotals = Array(12).fill(0);
let actualTotal = 0, budgetTotal = 0;
group.forEach((cat: any) => {
for (let i = 0; i < 12; i++) {
const entry = entries.find((e: any) => e.category_id === cat.id && e.month === i + 1);
if (typeof entry?.actual_amount === 'number' && entry.actual_amount !== null) {
monthTotals[i] += entry.actual_amount;
actualTotal += entry.actual_amount;
budgetTotal += entry.actual_amount;
} else if (typeof entry?.projected_amount === 'number' && entry.projected_amount !== null) {
monthTotals[i] += entry.projected_amount;
budgetTotal += entry.projected_amount;
}
}
});
const varianceTotal = budgetTotal - actualTotal;
return { monthTotals, actualTotal, budgetTotal, varianceTotal };
}
return (
<Box>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h5">Cash Flow Forecast ({year})</Typography>
<Box>
<Button onClick={() => setYear(y => y - 1)}>-</Button>
<Button onClick={() => setYear(y => y + 1)}>+</Button>
</Box>
</Box>
{warning && <Typography color="error" mb={1}>{warning}</Typography>}
{warning && typeof minMonth === 'number' && minMonth >= 1 && (
<Typography variant="body2" color="textSecondary" mb={2}>
The projected operating balance in {MONTHS[minMonth-1]} is ${opBalance.toLocaleString()}.
</Typography>
)}
{loading ? <Typography>Loading...</Typography> : (
<Box>
{Object.keys(grouped).map(funding => (
<Box key={funding} mb={4}>
<Typography variant="h6" color="primary" mb={1}>{funding} Accounts</Typography>
{['income', 'expense'].map(type => {
const { monthTotals, actualTotal, budgetTotal, varianceTotal } = getTotalsForGroup(grouped[funding][type], entries);
return (
<Box key={type} mb={2}>
<Typography variant="subtitle1" color={type === 'income' ? 'success.main' : 'error.main'}>{type.charAt(0).toUpperCase() + type.slice(1)}</Typography>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Category</TableCell>
{MONTHS.map((m, i) => <TableCell key={m} align="right">{m}</TableCell>)}
<TableCell align="right"><b>Actual</b></TableCell>
<TableCell align="right"><b>Budget</b></TableCell>
<TableCell align="right"><b>Variance</b></TableCell>
</TableRow>
</TableHead>
<TableBody>
{grouped[funding][type].map((cat: any) => {
// Calculate summary values for the row
let actualTotal = 0, budgetTotal = 0;
for (let i = 0; i < 12; i++) {
const entry = entries.find((e: any) => e.category_id === cat.id && e.month === i + 1);
if (typeof entry?.actual_amount === 'number' && entry.actual_amount !== null) {
actualTotal += entry.actual_amount;
budgetTotal += entry.actual_amount;
} else if (typeof entry?.projected_amount === 'number' && entry.projected_amount !== null) {
budgetTotal += entry.projected_amount;
}
}
const variance = budgetTotal - actualTotal;
return (
<TableRow key={cat.id}>
<TableCell>{cat.name}
{user?.role === 'admin' && (
<IconButton size="small" onClick={() => handleBulkClick(cat)}><AddIcon fontSize="small" /></IconButton>
)}
</TableCell>
{MONTHS.map((m, i) => {
const entry = entries.find(e => e.category_id === cat.id && e.month === i + 1);
return (
<TableCell
key={m}
align="right"
sx={{ cursor: user?.role === 'admin' ? 'pointer' : 'default' }}
onClick={() => handleCellClick(cat, i + 1)}
>
{entry ? (
<span style={{ display: 'block', position: 'relative', minHeight: 32 }}>
{typeof entry.actual_amount === 'number' && entry.actual_amount !== null ? (
<span style={{ fontWeight: 'bold', color: '#000' }}>
{entry.actual_amount.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}
</span>
) : null}
{typeof entry.projected_amount === 'number' && entry.projected_amount !== null ? (
<span style={{ color: '#555', fontStyle: 'italic', marginLeft: 4 }}>
{entry.actual_amount == null ? entry.projected_amount.toLocaleString(undefined, { style: 'currency', currency: 'USD' }) : null}
</span>
) : null}
{typeof entry.actual_amount === 'number' && typeof entry.projected_amount === 'number' && entry.actual_amount !== null && entry.projected_amount !== null ? (
<span style={{ position: 'absolute', right: 4, bottom: 2, fontSize: '0.7em', color: (entry.projected_amount - entry.actual_amount) < 0 ? 'red' : '#000', background: '#fff', padding: '0 2px' }}>
{entry.projected_amount - entry.actual_amount}
</span>
) : null}
</span>
) : (
user?.role === 'admin' ? <span style={{ color: '#bbb' }}>+</span> : null
)}
</TableCell>
);
})}
<TableCell align="right" style={{ fontWeight: 'bold' }}>{actualTotal.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
<TableCell align="right">{budgetTotal.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
<TableCell align="right" style={{ color: variance < 0 ? 'red' : '#000', fontWeight: 'bold' }}>{variance.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
</TableRow>
);
})}
<TableRow>
<TableCell style={{ fontWeight: 'bold' }}>Totals</TableCell>
{monthTotals.map((total, i) => (
<TableCell key={i} align="right" style={{ fontWeight: 'bold', background: '#f5f5f5' }}>{total ? total.toLocaleString(undefined, { style: 'currency', currency: 'USD' }) : ''}</TableCell>
))}
<TableCell align="right" style={{ fontWeight: 'bold', background: '#f5f5f5' }}>{actualTotal.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
<TableCell align="right" style={{ fontWeight: 'bold', background: '#f5f5f5' }}>{budgetTotal.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
<TableCell align="right" style={{ fontWeight: 'bold', background: '#f5f5f5', color: varianceTotal < 0 ? 'red' : '#000' }}>{varianceTotal.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Box>
);
})}
</Box>
))}
<Box mt={4}>
<Typography variant="body2">
<span>Legend: </span>
<span style={{ fontStyle: 'italic', color: '#555' }}>Projected</span>
<span> values are in italics, </span>
<span style={{ fontWeight: 'bold', color: '#000' }}>Actuals</span>
<span> are in bold.</span>
</Typography>
</Box>
</Box>
)}
<Dialog open={!!edit} onClose={() => setEdit(null)}>
<DialogTitle>Edit Cash Flow Entry</DialogTitle>
<DialogContent>
<Typography gutterBottom>{edit?.category?.name} - {MONTHS[(edit?.month || 1) - 1]} {year}</Typography>
<TextField
label="Projected Amount"
name="projected_amount"
type="number"
value={form.projected_amount === 0 || form.projected_amount === '0' ? 0 : (form.projected_amount || '')}
onChange={handleFormChange}
fullWidth
margin="normal"
/>
<TextField
label="Actual Amount"
name="actual_amount"
type="number"
value={form.actual_amount === 0 || form.actual_amount === '0' ? 0 : (form.actual_amount || '')}
onChange={handleFormChange}
fullWidth
margin="normal"
/>
</DialogContent>
<DialogActions>
{edit?.entry && <Button color="error" onClick={handleDelete}>Delete</Button>}
<Button onClick={() => setEdit(null)}>Cancel</Button>
<Button onClick={handleSave} variant="contained">Save</Button>
</DialogActions>
</Dialog>
<Dialog open={bulkDialog.open} onClose={() => setBulkDialog(b => ({ ...b, open: false }))}>
<DialogTitle>Set Projected Value for All Months</DialogTitle>
<DialogContent>
<Typography gutterBottom>Set projected value for <b>{bulkDialog.category?.name}</b> for all months in {year}?</Typography>
<TextField
label="Projected Amount"
type="number"
value={bulkDialog.value}
onChange={handleBulkValueChange}
fullWidth
margin="normal"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setBulkDialog(b => ({ ...b, open: false }))}>Cancel</Button>
<Button onClick={handleBulkSubmit} variant="contained" color="primary">Set All</Button>
</DialogActions>
</Dialog>
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ open: false, onConfirm: null })}>
<DialogTitle>Confirm Bulk Update</DialogTitle>
<DialogContent>
<Typography>Are you sure you want to overwrite all projected values for this category and year?</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialog({ open: false, onConfirm: null })}>Cancel</Button>
<Button onClick={() => { confirmDialog.onConfirm && confirmDialog.onConfirm(); }} color="error" variant="contained">Yes, Overwrite</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default CashFlows;

View File

@@ -0,0 +1,520 @@
import React, { useEffect, useState, useMemo } from 'react';
import {
Box, Typography, Paper, TextField, Tabs, Tab, Switch, FormControlLabel, useTheme, useMediaQuery, MenuItem
} from '@mui/material';
import { fetchAccounts, fetchForecast } from './api';
// Add this at the top of the file or in a .d.ts file if needed
// @ts-ignore
import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts';
const MONTHS = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
// Define a default color palette for Plotly traces
const plotlyColors = [
'#1976d2', // blue
'#9c27b0', // purple
'#4caf50', // green
'#ff9800', // orange
'#e91e63', // pink
'#f44336', // red
'#00bcd4', // cyan
'#607d8b', // blue grey
'#795548', // brown
'#8bc34a', // light green
];
// Helper: get primary account for a bucket
function getPrimaryAccount(accounts: any[], bucket: string) {
// For now, hardcode by name; in future, use a 'primary' property
if (bucket === 'Operating') {
return accounts.find(a => a.name.toLowerCase().includes('checking 5345')) || accounts[0];
} else if (bucket === 'Reserve') {
return accounts.find(a => a.name.toLowerCase().includes('savings')) || accounts[0];
}
return accounts[0];
}
// Custom Tooltip to ensure label and data are always in sync
// Note: Recharts' TooltipProps typing is not compatible with destructuring, so use 'any' for props
const CustomTooltip = (props: any) => {
const { active, payload, label } = props;
if (active && payload && payload.length) {
// Find the data row for this label (rawDate)
const row: any = payload[0].payload;
return (
<div style={{ background: 'white', border: '1px solid #ccc', padding: 12 }}>
<div style={{ fontWeight: 600 }}>{row.date}</div>
{payload.map((p: any, idx: number) => (
<div key={idx} style={{ color: p.color }}>
{p.name}: {typeof p.value === 'number' ? p.value.toLocaleString(undefined, { style: 'currency', currency: 'USD' }) : p.value}
</div>
))}
</div>
);
}
return null;
};
const Forecasting: React.FC = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [accounts, setAccounts] = useState<any[]>([]);
const [forecast, setForecast] = useState<any[]>([]);
const [tab, setTab] = useState<'Operating' | 'Reserve'>('Operating');
const [months, setMonths] = useState(12);
const [showLegend, setShowLegend] = useState(true);
const [year, setYear] = useState<number>(new Date().getFullYear());
const [loading, setLoading] = useState(false);
const [hasLoaded, setHasLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch accounts and forecast on tab/year/months change
useEffect(() => {
setLoading(true);
setError(null);
// Fetch accounts and forecast for the selected bucket and year
fetchAccounts()
.then(accs => {
setAccounts(accs);
// Filter accounts for the current tab (bucket)
let filtered = accs.filter((a: any) => a.bucket?.name?.toLowerCase() === tab.toLowerCase());
// Fetch forecast for the bucket
if (filtered.length > 0) {
fetchForecast({ bucket: tab, year, months })
.then(forecastData => {
setForecast(forecastData);
setHasLoaded(true);
setLoading(false);
setError(null);
// Debug logging
console.debug('FORECAST: fetched forecast', forecastData);
})
.catch(err => {
setError('Failed to fetch forecast data.');
setLoading(false);
setHasLoaded(true);
console.error('Forecast fetch error:', err);
});
} else {
setForecast([]);
setHasLoaded(true);
setLoading(false);
}
})
.catch(err => {
setError('Failed to load accounts. Please try again.');
setLoading(false);
setHasLoaded(true);
console.error('Accounts fetch error:', err);
});
}, [year, months, tab]);
// Filter accounts by tab (Operating/Reserve)
const filteredAccounts = useMemo(() => {
let filtered = accounts.filter((a: any) => {
if (!a.bucket || !a.bucket.name) {
console.warn('Account missing bucket or bucket.name:', a);
}
return a.bucket?.name?.toLowerCase() === tab.toLowerCase();
});
console.debug('FORECAST: filteredAccounts for tab', tab, filtered, 'from accounts', accounts);
return filtered;
}, [accounts, tab]);
// Sort accounts: primary first, then others (memoized for use in chart and elsewhere)
const orderedAccounts = useMemo(() => {
if (!filteredAccounts.length) {
console.warn('FORECAST: No accounts found for tab', tab, '. Check if bucket property is present on accounts.');
return [];
}
const primary = getPrimaryAccount(filteredAccounts, tab);
const others = filteredAccounts.filter(a => a.id !== primary.id);
return [primary, ...others];
}, [filteredAccounts, tab]);
// Find the earliest validated date for the selected accounts
const validatedDate = useMemo(() => {
let minDate: Date | null = null;
filteredAccounts.forEach((a: any) => {
if (a.last_validated_at) {
const d = new Date(a.last_validated_at);
if (!minDate || d < minDate) minDate = d;
}
});
return minDate;
}, [filteredAccounts]);
// Build chart data: exactly 12 months for the selected year, JanDec, using backend forecast only
const chartData = useMemo(() => {
if (!filteredAccounts.length || !forecast.length) {
return [];
}
// Build a map of accountId -> forecast array (from backend)
const forecastMap: Record<number, any[]> = {};
forecast.forEach((f: any) => {
forecastMap[f.account_id] = f.forecast;
});
// Generate 12 months for the selected year: JanDec
const months = Array.from({ length: 12 }, (_, i) => new Date(year, i, 1));
let chartDataArr = months.map((dateObj) => {
const rawDate = dateObj.toISOString().slice(0, 10); // YYYY-MM-01
const label = dateObj.toLocaleString('default', { month: 'short', year: 'numeric' });
const row: any = { rawDate, date: label };
filteredAccounts.forEach((a: any) => {
// Use the backend forecast point for this month
const fc = forecastMap[a.id]?.find(pt => pt.date === rawDate);
row[`acc_${a.id}`] = fc ? fc.balance : null;
});
row.total = filteredAccounts.reduce((sum, a) => sum + (row[`acc_${a.id}`] || 0), 0);
return row;
});
// Defensive: sort chartDataArr by rawDate
chartDataArr.sort((a, b) => new Date(a.rawDate).getTime() - new Date(b.rawDate).getTime());
// Do NOT filter chartDataArr; always return all 12 months
// Debug: print the final chart data array
console.debug('FORECAST: chartData (final, direct mapping)', chartDataArr);
return chartDataArr;
}, [filteredAccounts, forecast, year]);
// Remove any code that pads the start of the data array
// Only pad the END with a dummy month (Jan of next year)
const paddedChartData = (() => {
if (!chartData.length) return [];
const last = chartData[chartData.length - 1];
// Add Jan of next year as a dummy row
const janNext = { ...last, rawDate: `${year + 1}-01-01`, date: `Jan ${year + 1}` };
Object.keys(janNext).forEach(k => { if (k.startsWith('acc_') || k === 'total') janNext[k] = null; });
return [...chartData, janNext];
})();
// XAxis ticks: only JanDec of selected year
const xTicks = chartData.map(row => row.rawDate);
// Add a helper to convert rawDate (YYYY-MM-01) to a numeric value for X axis
function dateToNum(rawDate: string): number {
// Use YYYYMM as a number (e.g., 202501 for Jan 2025)
const d = new Date(rawDate);
return d.getFullYear() * 100 + (d.getMonth() + 1);
}
// Find months where only the PRIMARY account falls below $1000
const alertMonths = useMemo(() => {
if (!orderedAccounts.length) return [];
const primaryId = orderedAccounts[0].id;
return chartData.reduce<number[]>((acc, row: any, idx: number) => {
// Use actual if present, else projected
const bal = row[`acc_${primaryId}`] != null ? row[`acc_${primaryId}`] : null;
if (bal < 1000) {
acc.push(idx);
}
return acc;
}, []);
}, [chartData, orderedAccounts]);
// Find maturity markers for accounts with a maturity_date in the selected year
const maturityMarkers = useMemo(() => {
return orderedAccounts
.filter(a => a.maturity_date)
.map(a => {
const maturity = new Date(a.maturity_date);
if (maturity.getFullYear() !== year) return null;
const monthIdx = maturity.getMonth(); // 0-based
return {
account: a,
monthIdx,
dateLabel: `${MONTHS[monthIdx]} ${year}`,
maturityDate: maturity,
};
})
.filter(Boolean);
}, [orderedAccounts, year]);
// Colors for accounts
const accountColors = useMemo(() => {
const palette = [theme.palette.primary.main, theme.palette.secondary.main, '#4caf50', '#ff9800', '#e91e63', '#00bcd4', '#9c27b0'];
const map: Record<number, string> = {};
orderedAccounts.forEach((a, i) => {
map[a.id] = palette[i % palette.length];
});
return map;
}, [orderedAccounts, theme]);
// Debug: log chart keys
useEffect(() => {
if (filteredAccounts.length && chartData.length) {
const keys = filteredAccounts.map(a => `acc_${a.id}`);
console.debug('FORECAST: chart keys', keys);
console.debug('FORECAST: chartData sample', chartData[0]);
}
}, [filteredAccounts, chartData]);
useEffect(() => {
// Log filtered accounts and chartData
console.debug('FORECAST: filteredAccounts', filteredAccounts);
console.debug('FORECAST: chartData', chartData.slice(0, 5));
}, [filteredAccounts, chartData]);
// Map chartData to include a numeric 'x' property for the X axis
const chartDataWithX = chartData.map(row => ({ ...row, x: dateToNum(row.rawDate) }));
// Prepare ECharts series and options
// Remove duplicate declaration of 'months' variable
// Use 'monthLabels' for the X-axis labels
const monthLabels = chartData.map(row => row.date); // e.g., ['Jan 2025', ...]
const accountKeys = Object.keys(chartData[0] || {}).filter(k => k.startsWith('acc_'));
const accountMap: Record<string, string> = {};
filteredAccounts.forEach(a => {
accountMap[`acc_${a.id}`] = a.name;
});
// --- Alert markPoint for Checking 5345 ---
let alertMarkPoint: any = undefined;
if (tab === 'Operating' && alertMonths.length > 0 && orderedAccounts.length > 0) {
const alertIdx = alertMonths[0];
const alertMonthLabel = monthLabels[alertIdx];
const alertBalance = chartData[alertIdx][`acc_${orderedAccounts[0].id}`];
alertMarkPoint = {
symbol: 'path://M0,40 L20,0 L40,40 Z', // triangle SVG path
symbolSize: 40,
itemStyle: { color: 'red' },
label: {
show: true,
color: 'red',
fontWeight: 'bold',
fontSize: 14,
position: 'bottom', // place label below the symbol
formatter: () => 'Alert',
// Optionally, add padding to separate text from triangle
padding: [4, 0, 0, 0],
},
data: [
{
name: 'Low Balance',
coord: [alertMonthLabel, alertBalance],
value: 'Alert',
// Use symbol as triangle with a white exclamation mark overlay
symbol: 'image://data:image/svg+xml;utf8,<svg width="40" height="40" xmlns="http://www.w3.org/2000/svg"><polygon points="20,0 40,40 0,40" fill="red"/><text x="20" y="30" text-anchor="middle" font-size="28" font-family="Arial" fill="white">!</text></svg>',
symbolSize: 40,
},
],
tooltip: {
show: true,
formatter: () => `Alert: Balance below $1,000`,
},
};
}
// Determine current month for actual vs projected distinction
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth(); // 0-based
// Build account area series with actual vs projected styling
const accountSeries = accountKeys.map((key, idx) => {
const isPrimary = orderedAccounts.length && key === `acc_${orderedAccounts[0].id}`;
const accountColor = accountColors[parseInt(key.replace('acc_', ''))] || plotlyColors[idx % plotlyColors.length];
// Create data with different itemStyle for actual vs projected
const styledData = chartData.map((row, dataIdx) => {
const rowDate = new Date(row.rawDate);
const isProjected = rowDate.getFullYear() > 2025 ||
(rowDate.getFullYear() === 2025 && rowDate.getMonth() >= 7); // July and later
return {
value: row[key],
itemStyle: {
color: accountColor,
opacity: isProjected ? 0.4 : 1.0
}
};
});
return {
name: accountMap[key] || key,
type: 'line',
stack: 'accounts',
areaStyle: {
color: accountColor,
opacity: 0.8
},
emphasis: { focus: 'series' },
data: styledData,
smooth: true,
lineStyle: {
color: accountColor,
width: 2
},
...(isPrimary && alertMarkPoint ? { markPoint: alertMarkPoint } : {}),
};
});
// Add total as a line with actual vs projected styling
const totalStyledData = chartData.map((row, dataIdx) => {
const rowDate = new Date(row.rawDate);
const isProjected = rowDate.getFullYear() > 2025 ||
(rowDate.getFullYear() === 2025 && rowDate.getMonth() >= 7); // July and later
return {
value: row.total,
itemStyle: {
color: '#f44336',
opacity: isProjected ? 0.6 : 1.0
}
};
});
const totalSeries = {
name: 'Total',
type: 'line',
data: totalStyledData,
lineStyle: {
width: 3,
color: '#f44336'
},
symbol: 'circle',
z: 10,
smooth: true,
};
// Add a markLine to show the boundary between actual and projected data
// Position the line between July and August (month 6 and 7, 0-based)
const julyIndex = 6; // July is month 7, but 0-based index is 6
const augustIndex = 7; // August is month 8, but 0-based index is 7
// Only add the markLine if we're viewing 2025 and have data for August
const markLine = (year === 2025 && chartData.length > augustIndex) ? {
symbol: 'none',
lineStyle: {
color: '#666',
type: 'dashed',
width: 1
},
label: {
show: true,
formatter: 'Actual → Projected',
position: 'insideEndTop',
fontSize: 10,
color: '#666'
},
data: [
{ xAxis: julyIndex + 0.5 } // Position between July and August
]
} : null;
const series = [...accountSeries, totalSeries];
// Add markLine to the first series if it exists
if (markLine && series.length > 0) {
(series[0] as any).markLine = markLine;
}
const option = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
valueFormatter: (v: number) => v?.toLocaleString('en-US', { style: 'currency', currency: 'USD' })
},
legend: {
top: 0
},
grid: { left: 80, right: 32, bottom: 32, top: 40 },
xAxis: {
type: 'category',
data: monthLabels,
boundaryGap: false,
axisLabel: { rotate: 0 },
},
yAxis: {
type: 'value',
axisLabel: { formatter: (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }) },
splitLine: { show: true },
},
series,
};
return (
<Box>
<Typography variant="h5" mb={2}>Forecasting</Typography>
<Paper sx={{ p: isMobile ? 1 : 2, mb: 2, display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center', gap: 2, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center', gap: 2 }}>
<Tabs
value={tab}
onChange={(_, v) => setTab(v)}
textColor="primary"
indicatorColor="primary"
sx={{ minHeight: 36, height: 36, borderRadius: 2, background: theme.palette.background.paper, boxShadow: 1, fontSize: '0.95rem', mb: isMobile ? 1 : 0 }}
>
<Tab value="Operating" label="Operating" sx={{ minHeight: 36, height: 36, fontSize: '0.95rem' }} />
<Tab value="Reserve" label="Reserve" sx={{ minHeight: 36, height: 36, fontSize: '0.95rem' }} />
</Tabs>
<TextField
select
label="Year"
value={year}
onChange={e => setYear(Number(e.target.value))}
sx={{ minWidth: 100 }}
>
{[year - 1, year, year + 1].map(y => <MenuItem key={y} value={y}>{y}</MenuItem>)}
</TextField>
<TextField
label="Projection Months"
type="number"
value={months}
onChange={e => setMonths(Number(e.target.value))}
inputProps={{ min: 1, max: 36 }}
sx={{ minWidth: 120 }}
/>
</Box>
<FormControlLabel
control={<Switch checked={showLegend} onChange={e => setShowLegend(e.target.checked)} />}
label="Show Legend"
sx={{ ml: isMobile ? 0 : 2 }}
/>
</Paper>
<Paper sx={{ p: isMobile ? 1 : 2, mb: 2 }}>
{loading && !hasLoaded ? (
<Typography>Loading...</Typography>
) : error ? (
<Typography color="error" align="center" sx={{ mt: 4 }}>{error}</Typography>
) : !accounts.length ? (
<Typography color="error" align="center" sx={{ mt: 4 }}>
No accounts loaded. Please check your backend or network connection.
</Typography>
) : !filteredAccounts.length ? (
<Typography color="error" align="center" sx={{ mt: 4 }}>
No accounts in this bucket.
</Typography>
) : !chartData.length ? (
<Typography color="error" align="center" sx={{ mt: 4 }}>
{`No forecast data exists for ${year}`}
</Typography>
) : (
<>
<ReactECharts option={option} style={{ height: 500, width: '100%' }} />
{/* Show alert if primary account balance falls below $1000 in any month */}
{tab === 'Operating' && alertMonths.length > 0 && (
<Box mt={2} mb={2}>
<Typography variant="body1" color="error" fontWeight={600}>
Warning: Checking 5345 balance falls below $1,000 in {chartData[alertMonths[0]]?.date} (
{chartData[alertMonths[0]] && chartData[alertMonths[0]][`acc_${orderedAccounts[0].id}`] != null
? chartData[alertMonths[0]][`acc_${orderedAccounts[0].id}`].toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
: 'N/A'}
)
</Typography>
</Box>
)}
</>
)}
</Paper>
<Box mt={2} mb={4}>
<Paper sx={{ p: isMobile ? 1 : 2, background: theme.palette.grey[100] }}>
<Typography variant="subtitle1" fontWeight="bold" mb={1}>Assistant Suggestions (coming soon)</Typography>
<Typography variant="body2" color="textSecondary">This area will provide AI-powered recommendations and alerts based on your forecasted balances and trends.</Typography>
</Paper>
</Box>
</Box>
);
};
export default Forecasting;

View File

@@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Button, TextField, Typography, Paper, Alert } from '@mui/material';
import { login, fetchUser } from './auth';
import { useContext } from 'react';
import { AuthContext } from './App';
const Login: React.FC = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const { setUser } = useContext(AuthContext);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await login(username, password);
const user = await fetchUser();
setUser(user);
navigate('/dashboard');
} catch (err) {
setError('Invalid username or password');
}
};
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh">
<Paper elevation={3} sx={{ p: 4, minWidth: 320 }}>
<Typography variant="h5" mb={2}>Login</Typography>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<form onSubmit={handleSubmit}>
<TextField
label="Username"
value={username}
onChange={e => setUsername(e.target.value)}
fullWidth
margin="normal"
required
/>
<TextField
label="Password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
fullWidth
margin="normal"
required
/>
<Button type="submit" variant="contained" color="primary" fullWidth sx={{ mt: 2 }}>
Login
</Button>
</form>
</Paper>
</Box>
);
};
export default Login;

View File

@@ -0,0 +1,328 @@
import React, { useEffect, useState, useContext } from 'react';
import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem, IconButton, Switch, FormControlLabel } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import { fetchTransactions, createTransaction, deleteTransaction, fetchAccounts, updateTransaction } from './api';
import { AuthContext } from './App';
const transactionTypes = [
{ value: 'deposit', label: 'Deposit' },
{ value: 'withdrawal', label: 'Withdrawal' },
{ value: 'transfer', label: 'Transfer' },
];
const Transactions: React.FC = () => {
const [transactions, setTransactions] = useState<any[]>([]);
const [accounts, setAccounts] = useState<any[]>([]);
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
from_account_id: '',
to_account_id: '',
amount: '',
planned_date: '',
comments: '',
reconciled: false,
});
const [editOpen, setEditOpen] = useState(false);
const [editForm, setEditForm] = useState<any>(null);
const [showReconciled, setShowReconciled] = useState(true);
const { user } = useContext(AuthContext);
const load = async () => {
setTransactions(await fetchTransactions());
setAccounts(await fetchAccounts());
};
useEffect(() => { load(); }, []);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setForm(f => ({ ...f, [name]: type === 'checkbox' ? checked : value }));
};
const handleSubmit = async () => {
// Prepare payload for backend TransactionCreate
const payload = {
account_id: parseInt(form.from_account_id),
related_account_id: parseInt(form.to_account_id),
type: 'transfer',
amount: parseFloat(form.amount),
date: form.planned_date, // maps to backend 'date'
description: form.comments, // maps to backend 'description'
reconciled: form.reconciled,
};
await createTransaction(payload);
setOpen(false);
setForm({ from_account_id: '', to_account_id: '', amount: '', planned_date: '', comments: '', reconciled: false });
load();
};
const handleDelete = async (id: number) => {
await deleteTransaction(id);
load();
};
const handleEditOpen = (tx: any) => {
setEditForm({
id: tx.id,
from_account_id: tx.account_id?.toString() || '',
to_account_id: tx.related_account_id?.toString() || '',
amount: tx.amount?.toString() || '',
planned_date: tx.date ? tx.date.substring(0, 10) : '',
comments: tx.description || '',
reconciled: !!tx.reconciled,
});
setEditOpen(true);
};
const handleEditClose = () => setEditOpen(false);
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setEditForm((f: any) => ({ ...f, [name]: type === 'checkbox' ? checked : value }));
};
const handleEditSubmit = async () => {
if (!editForm) return;
const payload = {
account_id: parseInt(editForm.from_account_id),
related_account_id: parseInt(editForm.to_account_id),
type: 'transfer',
amount: parseFloat(editForm.amount),
date: editForm.planned_date,
description: editForm.comments,
reconciled: editForm.reconciled,
};
await updateTransaction(editForm.id, payload);
setEditOpen(false);
setEditForm(null);
load();
};
// Sort accounts by bucket name, then account name
const sortedAccounts = [...accounts].sort((a, b) => {
const bucketA = (a.bucket?.name || '').toLowerCase();
const bucketB = (b.bucket?.name || '').toLowerCase();
if (bucketA < bucketB) return -1;
if (bucketA > bucketB) return 1;
const nameA = (a.name || '').toLowerCase();
const nameB = (b.name || '').toLowerCase();
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return 0;
});
// Sort transactions by planned date (ascending)
const sortedTransactions = [...transactions].sort((a, b) => {
const dateA = a.date || '';
const dateB = b.date || '';
return dateA.localeCompare(dateB);
});
// Filter transactions by reconciled toggle
const filteredTransactions = showReconciled
? sortedTransactions
: sortedTransactions.filter(tx => !tx.reconciled);
return (
<Box>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h5">Transactions</Typography>
<Box display="flex" alignItems="center" gap={2}>
<FormControlLabel
control={<Switch checked={showReconciled} onChange={e => setShowReconciled(e.target.checked)} />}
label="Show Reconciled"
/>
{user?.role === 'admin' && (
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpen}>Add Transaction</Button>
)}
</Box>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Reconciled</TableCell>
<TableCell>Planned Date</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Transfer From</TableCell>
<TableCell>Transfer To</TableCell>
<TableCell>Comments</TableCell>
{user?.role === 'admin' && <TableCell>Actions</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{filteredTransactions.map(tx => (
<TableRow key={tx.id}>
<TableCell>{tx.reconciled ? '✔️' : ''}</TableCell>
<TableCell>{tx.date ? tx.date.substring(0, 10) : ''}</TableCell>
<TableCell>{tx.amount?.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
<TableCell>{(() => {
const acc = accounts.find(a => a.id === tx.account_id);
return acc ? `${acc.bucket?.name ? acc.bucket.name + '-' : ''}${acc.name}` : '';
})()}</TableCell>
<TableCell>{(() => {
const acc = accounts.find(a => a.id === tx.related_account_id);
return acc ? `${acc.bucket?.name ? acc.bucket.name + '-' : ''}${acc.name}` : '';
})()}</TableCell>
<TableCell>{tx.description}</TableCell>
{user?.role === 'admin' && (
<TableCell>
<IconButton color="primary" onClick={() => handleEditOpen(tx)}><EditIcon /></IconButton>
<IconButton color="error" onClick={() => handleDelete(tx.id)}><DeleteIcon /></IconButton>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog open={open} onClose={handleClose}>
<DialogTitle>Add Transaction</DialogTitle>
<DialogContent>
<Box display="flex" alignItems="center" mb={1}>
<input
type="checkbox"
name="reconciled"
checked={form.reconciled}
onChange={handleChange}
id="reconciled-checkbox"
style={{ marginRight: 8 }}
/>
<label htmlFor="reconciled-checkbox">Reconciled</label>
</Box>
<TextField
label="Planned Date"
name="planned_date"
type="date"
value={form.planned_date}
onChange={handleChange}
fullWidth
margin="normal"
InputLabelProps={{ shrink: true }}
required
/>
<TextField
label="Dollar Amount"
name="amount"
type="number"
value={form.amount}
onChange={handleChange}
fullWidth
margin="normal"
required
/>
<TextField
select
label="Transfer From"
name="from_account_id"
value={form.from_account_id}
onChange={handleChange}
fullWidth
margin="normal"
required
>
{sortedAccounts.map(a => <MenuItem key={a.id} value={a.id}>{(a.bucket?.name ? a.bucket.name + '-' : '') + a.name}</MenuItem>)}
</TextField>
<TextField
select
label="Transfer To"
name="to_account_id"
value={form.to_account_id}
onChange={handleChange}
fullWidth
margin="normal"
required
>
{sortedAccounts.map(a => <MenuItem key={a.id} value={a.id}>{(a.bucket?.name ? a.bucket.name + '-' : '') + a.name}</MenuItem>)}
</TextField>
<TextField
label="Comments"
name="comments"
value={form.comments}
onChange={handleChange}
fullWidth
margin="normal"
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleSubmit} variant="contained">Add</Button>
</DialogActions>
</Dialog>
<Dialog open={editOpen} onClose={handleEditClose}>
<DialogTitle>Edit Transaction</DialogTitle>
<DialogContent>
<Box display="flex" alignItems="center" mb={1}>
<input
type="checkbox"
name="reconciled"
checked={editForm?.reconciled || false}
onChange={handleEditChange}
id="edit-reconciled-checkbox"
style={{ marginRight: 8 }}
/>
<label htmlFor="edit-reconciled-checkbox">Reconciled</label>
</Box>
<TextField
label="Planned Date"
name="planned_date"
type="date"
value={editForm?.planned_date || ''}
onChange={handleEditChange}
fullWidth
margin="normal"
InputLabelProps={{ shrink: true }}
required
/>
<TextField
label="Dollar Amount"
name="amount"
type="number"
value={editForm?.amount || ''}
onChange={handleEditChange}
fullWidth
margin="normal"
required
/>
<TextField
select
label="Transfer From"
name="from_account_id"
value={editForm?.from_account_id || ''}
onChange={handleEditChange}
fullWidth
margin="normal"
required
>
{sortedAccounts.map(a => <MenuItem key={a.id} value={a.id}>{(a.bucket?.name ? a.bucket.name + '-' : '') + a.name}</MenuItem>)}
</TextField>
<TextField
select
label="Transfer To"
name="to_account_id"
value={editForm?.to_account_id || ''}
onChange={handleEditChange}
fullWidth
margin="normal"
required
>
{sortedAccounts.map(a => <MenuItem key={a.id} value={a.id}>{(a.bucket?.name ? a.bucket.name + '-' : '') + a.name}</MenuItem>)}
</TextField>
<TextField
label="Comments"
name="comments"
value={editForm?.comments || ''}
onChange={handleEditChange}
fullWidth
margin="normal"
/>
</DialogContent>
<DialogActions>
<Button onClick={handleEditClose}>Cancel</Button>
<Button onClick={handleEditSubmit} variant="contained">Save</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default Transactions;

View File

@@ -0,0 +1,60 @@
import axios from 'axios';
import { getToken } from './auth';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const api = axios.create({
baseURL: API_URL,
});
api.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers = config.headers || {};
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
// --- Accounts ---
export const fetchAccounts = () => api.get('/accounts').then(res => res.data);
export const fetchAccount = (id: number) => api.get(`/accounts/${id}`).then(res => res.data);
export const fetchAccountHistory = (id: number) => api.get(`/accounts/${id}/history`).then(res => res.data);
export const createAccount = (data: any) => api.post('/accounts', data).then(res => res.data);
export const deleteAccount = (id: number) => api.delete(`/accounts/${id}`).then(res => res.data);
export const updateAccount = (id: number, data: any) => api.patch(`/accounts/${id}`, data).then(res => res.data);
// --- Buckets ---
export const fetchBuckets = () => api.get('/buckets').then(res => res.data);
export const createBucket = (data: any) => api.post('/buckets', data).then(res => res.data);
export const deleteBucket = (id: number) => api.delete(`/buckets/${id}`).then(res => res.data);
// --- Transactions ---
export const fetchTransactions = () => api.get('/transactions').then(res => res.data);
export const createTransaction = (data: any) => api.post('/transactions', data).then(res => res.data);
export const updateTransaction = (id: number, data: any) => api.patch(`/transactions/${id}`, data).then(res => res.data);
export const deleteTransaction = (id: number) => api.delete(`/transactions/${id}`).then(res => res.data);
// --- Cash Flows ---
export const fetchCashFlows = () => api.get('/cashflows').then(res => res.data);
export const createCashFlow = (data: any) => api.post('/cashflows', data).then(res => res.data);
export const deleteCashFlow = (id: number) => api.delete(`/cashflows/${id}`).then(res => res.data);
// --- Cash Flow Categories ---
export const fetchCashFlowCategories = () => api.get('/cashflow/categories').then(res => res.data);
export const createCashFlowCategory = (data: any) => api.post('/cashflow/categories', data).then(res => res.data);
export const updateCashFlowCategory = (id: number, data: any) => api.patch(`/cashflow/categories/${id}`, data).then(res => res.data);
export const deleteCashFlowCategory = (id: number) => api.delete(`/cashflow/categories/${id}`).then(res => res.data);
// --- Cash Flow Entries ---
export const fetchCashFlowEntries = (year?: number) => api.get('/cashflow/entries', { params: year ? { year } : {} }).then(res => res.data);
export const createCashFlowEntry = (data: any) => api.post('/cashflow/entries', data).then(res => res.data);
export const updateCashFlowEntry = (id: number, data: any) => api.patch(`/cashflow/entries/${id}`, data).then(res => res.data);
export const deleteCashFlowEntry = (id: number) => api.delete(`/cashflow/entries/${id}`).then(res => res.data);
// --- Forecasting ---
export const fetchForecast = (data: any) => api.post('/forecast', data).then(res => res.data);
// --- Account Types ---
export const fetchAccountTypes = () => api.get('/account-types').then(res => res.data);
export const createAccountType = (data: any) => api.post('/account-types', data).then(res => res.data);

View File

@@ -0,0 +1,71 @@
import axios from 'axios';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
export const login = async (username: string, password: string) => {
console.log('LOGIN: Attempting login for', username);
try {
const response = await axios.post(`${API_URL}/token`, new URLSearchParams({
username,
password,
}), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
console.log('LOGIN: Response from backend', response);
const { access_token } = response.data;
if (!access_token) {
console.error('LOGIN: No access token returned', response.data);
throw new Error('No access token returned');
}
localStorage.setItem('token', access_token);
console.log('LOGIN: Token stored in localStorage', access_token);
return access_token;
} catch (err: any) {
console.error('LOGIN: Error during login', err);
// If axios error, try to get backend error message
if (err.response && err.response.data && err.response.data.detail) {
console.error('LOGIN: Backend error detail', err.response.data.detail);
throw new Error(err.response.data.detail);
}
throw new Error('Login failed');
}
};
export const logout = () => {
console.log('LOGOUT: Clearing token and user from localStorage');
localStorage.removeItem('token');
localStorage.removeItem('user');
};
export const getToken = () => {
const token = localStorage.getItem('token');
console.log('GET_TOKEN: Retrieved token from localStorage', token);
return token;
};
export const fetchUser = async () => {
const token = getToken();
if (!token) {
console.warn('FETCH_USER: No token found, returning null');
return null;
}
try {
console.log('FETCH_USER: Fetching user with token', token);
const response = await axios.get(`${API_URL}/me`, {
headers: { Authorization: `Bearer ${token}` },
});
localStorage.setItem('user', JSON.stringify(response.data));
console.log('FETCH_USER: User data from backend', response.data);
return response.data;
} catch (err: any) {
console.error('FETCH_USER: Error fetching user, logging out', err);
logout();
return null;
}
};
export const getUser = () => {
const user = localStorage.getItem('user');
console.log('GET_USER: Retrieved user from localStorage', user);
return user ? JSON.parse(user) : null;
};

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// Register service worker for PWA
serviceWorkerRegistration.register();
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
reportWebVitals();

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -0,0 +1,80 @@
/// <reference lib="webworker" />
/* eslint-disable no-restricted-globals */
// This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules
// for the list of available Workbox modules, or add any other
// code you'd like.
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
declare const self: ServiceWorkerGlobalScope;
clientsClaim();
// Precache all of the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA
precacheAndRoute(self.__WB_MANIFEST);
// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
registerRoute(
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }: { request: Request; url: URL }) => {
// If this isn't a navigation, skip.
if (request.mode !== 'navigate') {
return false;
}
// If this is a URL that starts with /_, skip.
if (url.pathname.startsWith('/_')) {
return false;
}
// If this looks like a URL for a resource, because it contains
// a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false;
}
// Return true to signal that we want to use the handler.
return true;
},
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
);
// An example runtime caching route for requests that aren't handled by the
// precache, in this case same-origin .png requests like those from in public/
registerRoute(
// Add in any other file extensions or routing criteria as needed.
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
// Customize this strategy as needed, e.g., by changing to CacheFirst.
new StaleWhileRevalidate({
cacheName: 'images',
plugins: [
// Ensure that once this runtime cache reaches a maximum size the
// least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 }),
],
})
);
// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Any other custom service worker logic can go here.

View File

@@ -0,0 +1,142 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
);
type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL ?? '', window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://cra.link/PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://cra.link/PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log('No internet connection found. App is running in offline mode.');
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}

View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@@ -0,0 +1,24 @@
import { createTheme } from '@mui/material/styles';
export const getTheme = (mode: 'light' | 'dark') =>
createTheme({
palette: {
mode,
primary: {
main: mode === 'light' ? '#1976d2' : '#90caf9',
},
background: {
default: mode === 'light' ? '#f5f5f5' : '#121212',
paper: mode === 'light' ? '#fff' : '#1e1e1e',
},
},
components: {
MuiAppBar: {
styleOverrides: {
colorPrimary: {
backgroundColor: mode === 'light' ? '#1976d2' : '#23272f',
},
},
},
},
});

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES6",
"module": "ESNext",
"lib": ["DOM", "ESNext"],
"jsx": "react-jsx",
"moduleResolution": "Node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}

BIN
backend/backend/hoa_app.db Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,2 @@
# If you get a network error or 500 from the backend, display a user-friendly message to the user, e.g.:
# setError("Backend service unavailable. Please try again later.")

View File

@@ -0,0 +1,104 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from .models import User, RoleEnum
from .database import SessionLocal
from .logging_config import setup_logging, DEBUG_LOGGING
import logging
# Secret key for JWT
SECRET_KEY = "your-secret-key" # Replace with a secure key in production
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
setup_logging()
auth_logger = logging.getLogger("hoa_app.auth")
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def verify_password(plain_password, hashed_password):
try:
result = pwd_context.verify(plain_password, hashed_password)
if DEBUG_LOGGING:
auth_logger.debug(f"Password verification result: {result}")
return result
except Exception as e:
auth_logger.error(f"Password verification error: {e}")
return False
def get_password_hash(password):
hash = pwd_context.hash(password)
if DEBUG_LOGGING:
auth_logger.debug(f"Generated password hash: {hash}")
return hash
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
if DEBUG_LOGGING:
auth_logger.debug(f"Created JWT for {data.get('sub')}")
return encoded_jwt
def get_user_by_username(db: Session, username: str):
user = db.query(User).filter(User.username == username).first()
if DEBUG_LOGGING:
auth_logger.debug(f"User lookup for '{username}': {'found' if user else 'not found'}")
return user
def authenticate_user(db: Session, username: str, password: str):
user = get_user_by_username(db, username)
if not user:
auth_logger.warning(f"Authentication failed: user '{username}' not found.")
return None
if not verify_password(password, user.password_hash):
auth_logger.warning(f"Authentication failed: invalid password for user '{username}'.")
return None
auth_logger.info(f"Authentication successful for user '{username}'.")
return user
def get_current_user(db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if not isinstance(username, str):
auth_logger.warning("JWT decode failed: no username in token or not a string.")
raise credentials_exception
except JWTError as e:
auth_logger.warning(f"JWT decode error: {e}")
raise credentials_exception
user = get_user_by_username(db, username)
if user is None:
auth_logger.warning(f"JWT user not found: {username}")
raise credentials_exception
return user
def get_current_active_user(current_user: User = Depends(get_current_user)):
return current_user
def get_current_admin_user(current_user: User = Depends(get_current_user)):
if current_user.role.name != RoleEnum.admin:
auth_logger.warning(f"Admin privileges required. User: {current_user.username}")
raise HTTPException(status_code=403, detail="Admin privileges required")
return current_user

View File

@@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from .models import Base
from .logging_config import setup_logging, DEBUG_LOGGING
import logging
SQLALCHEMY_DATABASE_URL = "sqlite:///./hoa_app.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
setup_logging()
db_logger = logging.getLogger("hoa_app.database")
def init_db():
try:
Base.metadata.create_all(bind=engine)
db_logger.info("Database tables created/verified.")
except Exception as e:
db_logger.error(f"Database initialization error: {e}")
raise

View File

View File

@@ -0,0 +1,29 @@
import logging
from logging.handlers import RotatingFileHandler
import os
# Debug toggle: set to True for verbose debug logging
DEBUG_LOGGING = os.environ.get('HOA_APP_DEBUG', '1') == '1'
LOG_LEVEL = logging.DEBUG if DEBUG_LOGGING else logging.INFO
LOG_FILE = os.environ.get('HOA_APP_LOGFILE', 'hoa_app.log')
formatter = logging.Formatter('[%(asctime)s] %(levelname)s %(name)s: %(message)s')
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=2*1024*1024, backupCount=3)
file_handler.setFormatter(formatter)
file_handler.setLevel(LOG_LEVEL)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
console_handler.setLevel(LOG_LEVEL)
def setup_logging():
root_logger = logging.getLogger()
root_logger.setLevel(LOG_LEVEL)
# Avoid duplicate handlers if re-imported
if not any(isinstance(h, RotatingFileHandler) for h in root_logger.handlers):
root_logger.addHandler(file_handler)
if not any(isinstance(h, logging.StreamHandler) and not isinstance(h, RotatingFileHandler) for h in root_logger.handlers):
root_logger.addHandler(console_handler)
logging.debug(f"Logging initialized. Level: {LOG_LEVEL}, File: {LOG_FILE}, Debug: {DEBUG_LOGGING}")

View File

@@ -0,0 +1,856 @@
from fastapi import FastAPI, Depends, HTTPException, status, Body
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from . import models, schemas, auth, database
from .models import User, Role, RoleEnum
from .auth import get_db, get_password_hash, authenticate_user, create_access_token, get_current_active_user, get_current_admin_user
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.exc import IntegrityError
from .schemas import BucketCreate, BucketRead, AccountCreate, AccountRead, TransactionCreate, TransactionRead, CashFlowCreate, CashFlowRead, ForecastRequest, ForecastResponse, ForecastPoint, ReportRequest, ReportResponse, ReportEntry, CashFlowCategoryCreate, CashFlowCategoryRead, CashFlowEntryCreate, CashFlowEntryRead
from datetime import datetime, timedelta, date as date_cls
from sqlalchemy import and_, func
from .logging_config import setup_logging, DEBUG_LOGGING
import logging
import os
from pydantic import BaseModel
from typing import Optional, List
from fastapi.responses import JSONResponse
from fastapi.requests import Request
from collections import defaultdict
from bisect import bisect_left
import calendar
app = FastAPI()
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://192.168.2.52:3000",
"http://starship2:3000"
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
setup_logging()
logger = logging.getLogger(__name__)
def create_default_admin(db: Session):
logger.info("Ensuring default admin and user roles exist.")
admin_role = db.query(Role).filter(Role.name == RoleEnum.admin).first()
if not admin_role:
admin_role = Role(name=RoleEnum.admin)
db.add(admin_role)
db.commit()
db.refresh(admin_role)
logger.info("Created admin role.")
user_role = db.query(Role).filter(Role.name == RoleEnum.user).first()
if not user_role:
user_role = Role(name=RoleEnum.user)
db.add(user_role)
db.commit()
logger.info("Created user role.")
admin_user = db.query(User).filter(User.username == "admin").first()
admin_password = os.environ.get("ADMIN_PASSWORD", "admin")
if not admin_user:
admin_user = User(
username="admin",
password_hash=get_password_hash(admin_password),
role_id=admin_role.id
)
db.add(admin_user)
db.commit()
logger.info(f"Created default admin user with username 'admin' and password from {'ADMIN_PASSWORD env' if os.environ.get('ADMIN_PASSWORD') else 'default' }.")
else:
# Check if the password hash matches the current ADMIN_PASSWORD
from .auth import verify_password
if not verify_password(admin_password, admin_user.password_hash):
logger.warning("Admin password hash does not match ADMIN_PASSWORD. To reset, delete the admin user or set ADMIN_PASSWORD and remove the user.")
logger.debug("Admin user already exists.")
@app.on_event("startup")
def on_startup():
logger.info("Starting up application and initializing database.")
database.init_db()
db = next(get_db())
create_default_admin(db)
# Health check: verify bcrypt and passlib
try:
import bcrypt
import passlib
logger.info(f"bcrypt version: {getattr(bcrypt, '__version__', 'unknown')}, passlib version: {getattr(passlib, '__version__', 'unknown')}")
except Exception as e:
logger.error(f"Dependency check failed: {e}")
db.close()
logger.info("Startup complete.")
@app.get("/health")
def health_check():
"""Health check endpoint for service and dependency status."""
try:
import bcrypt
import passlib
bcrypt_version = getattr(bcrypt, '__version__', 'unknown')
passlib_version = getattr(passlib, '__version__', 'unknown')
return {"status": "ok", "bcrypt_version": bcrypt_version, "passlib_version": passlib_version}
except Exception as e:
return {"status": "error", "error": str(e)}
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.error(f"Unhandled exception: {exc}", exc_info=True)
return JSONResponse(
status_code=503,
content={"detail": "Backend service unavailable. Please check the logs for more information."},
)
@app.post("/register", response_model=schemas.UserRead)
def register(user: schemas.UserCreate, db: Session = Depends(get_db)):
logger.info(f"Register endpoint called for username: {user.username}")
user_role = db.query(Role).filter(Role.name == RoleEnum.user).first()
if not user_role:
logger.error("User role not found during registration.")
raise HTTPException(status_code=500, detail="User role not found.")
db_user = User(
username=user.username,
password_hash=get_password_hash(user.password),
role_id=user_role.id
)
db.add(db_user)
try:
db.commit()
db.refresh(db_user)
logger.info(f"User registered: {user.username}")
except IntegrityError:
db.rollback()
logger.warning(f"Registration failed: Username already registered: {user.username}")
raise HTTPException(status_code=400, detail="Username already registered.")
return schemas.UserRead(id=db_user.id, username=db_user.username, role=db_user.role.name)
@app.post("/token", response_model=schemas.Token)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
logger.info(f"Login attempt for username: {form_data.username}")
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
logger.warning(f"Login failed for username: {form_data.username}")
raise HTTPException(status_code=401, detail="Incorrect username or password")
access_token = create_access_token(data={"sub": user.username})
logger.info(f"Login successful for username: {form_data.username}")
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/me", response_model=schemas.UserRead)
def read_users_me(current_user: User = Depends(get_current_active_user)):
logger.debug(f"/me endpoint called for user: {current_user.username}")
return schemas.UserRead(id=current_user.id, username=current_user.username, role=current_user.role.name)
@app.get("/admin-only")
def admin_only(current_user: User = Depends(get_current_admin_user)):
logger.debug(f"/admin-only endpoint accessed by: {current_user.username}")
return {"message": f"Hello, {current_user.username}. You are an admin."}
# --- Bucket Endpoints ---
@app.post("/buckets", response_model=BucketRead, dependencies=[Depends(get_current_admin_user)])
def create_bucket(bucket: BucketCreate, db: Session = Depends(get_db)):
logger.info(f"Create bucket called: {bucket.name}")
if not bucket.name or len(bucket.name) < 2:
logger.warning("Bucket name too short.")
raise HTTPException(status_code=422, detail="Bucket name must be at least 2 characters.")
if db.query(models.Bucket).filter(models.Bucket.name == bucket.name).first():
logger.warning(f"Bucket name already exists: {bucket.name}")
raise HTTPException(status_code=400, detail="Bucket name already exists.")
db_bucket = models.Bucket(name=bucket.name, description=bucket.description)
db.add(db_bucket)
db.commit()
db.refresh(db_bucket)
logger.info(f"Bucket created: {bucket.name}")
return db_bucket
@app.get("/buckets", response_model=list[BucketRead], dependencies=[Depends(get_current_active_user)])
def list_buckets(db: Session = Depends(get_db)):
logger.debug("List buckets called.")
return db.query(models.Bucket).all()
@app.get("/buckets/{bucket_id}", response_model=BucketRead, dependencies=[Depends(get_current_active_user)])
def get_bucket(bucket_id: int, db: Session = Depends(get_db)):
logger.debug(f"Get bucket called: {bucket_id}")
bucket = db.query(models.Bucket).filter(models.Bucket.id == bucket_id).first()
if not bucket:
logger.warning(f"Bucket not found: {bucket_id}")
raise HTTPException(status_code=404, detail="Bucket not found")
return bucket
@app.delete("/buckets/{bucket_id}", dependencies=[Depends(get_current_admin_user)])
def delete_bucket(bucket_id: int, db: Session = Depends(get_db)):
logger.info(f"Delete bucket called: {bucket_id}")
bucket = db.query(models.Bucket).filter(models.Bucket.id == bucket_id).first()
if not bucket:
logger.warning(f"Bucket not found for delete: {bucket_id}")
raise HTTPException(status_code=404, detail="Bucket not found")
db.delete(bucket)
db.commit()
logger.info(f"Bucket deleted: {bucket_id}")
return {"detail": "Bucket deleted"}
# --- Account Endpoints ---
@app.post("/account-types", response_model=schemas.AccountTypeRead, dependencies=[Depends(get_current_admin_user)])
def create_account_type(account_type: schemas.AccountTypeCreate, db: Session = Depends(get_db)):
logger.info(f"Create account type called: {account_type.name}")
if db.query(models.AccountType).filter(models.AccountType.name == account_type.name).first():
logger.warning(f"Account type already exists: {account_type.name}")
raise HTTPException(status_code=400, detail="Account type already exists.")
db_account_type = models.AccountType(name=account_type.name)
db.add(db_account_type)
db.commit()
db.refresh(db_account_type)
logger.info(f"Account type created: {account_type.name}")
return db_account_type
@app.get("/account-types", response_model=list[schemas.AccountTypeRead], dependencies=[Depends(get_current_active_user)])
def list_account_types(db: Session = Depends(get_db)):
logger.debug("List account types called.")
return db.query(models.AccountType).all()
@app.post("/accounts", response_model=schemas.AccountRead, dependencies=[Depends(get_current_admin_user)])
def create_account(account: schemas.AccountCreate, db: Session = Depends(get_db)):
logger.info(f"Create account called: {account.name}")
maturity_date = account.maturity_date
if isinstance(maturity_date, str) and maturity_date:
try:
maturity_date = datetime.fromisoformat(maturity_date)
except Exception as e:
logger.error(f"Invalid maturity_date format: {maturity_date}")
raise HTTPException(status_code=400, detail="Invalid maturity_date format. Use YYYY-MM-DD.")
db_account = models.Account(
name=account.name,
institution_name=account.institution_name,
interest_rate=account.interest_rate,
maturity_date=maturity_date,
balance=account.balance,
bucket_id=account.bucket_id,
account_type_id=account.account_type_id
)
db.add(db_account)
db.commit()
db.refresh(db_account)
# Create initial balance history
new_history = models.AccountBalanceHistory(
account_id=db_account.id,
balance=db_account.balance,
date=datetime.utcnow()
)
db.add(new_history)
db.commit()
logger.info(f"Account created: {account.name}")
return db_account
@app.get("/accounts", response_model=list[AccountRead], dependencies=[Depends(get_current_active_user)])
def list_accounts(db: Session = Depends(get_db)):
logger.debug("List accounts called.")
return db.query(models.Account).all()
@app.get("/accounts/{account_id}", response_model=AccountRead, dependencies=[Depends(get_current_active_user)])
def get_account(account_id: int, db: Session = Depends(get_db)):
logger.debug(f"Get account called: {account_id}")
account = db.query(models.Account).filter(models.Account.id == account_id).first()
if not account:
logger.warning(f"Account not found: {account_id}")
raise HTTPException(status_code=404, detail="Account not found")
return account
@app.delete("/accounts/{account_id}", dependencies=[Depends(get_current_admin_user)])
def delete_account(account_id: int, db: Session = Depends(get_db)):
logger.info(f"Delete account called: {account_id}")
account = db.query(models.Account).filter(models.Account.id == account_id).first()
if not account:
logger.warning(f"Account not found for delete: {account_id}")
raise HTTPException(status_code=404, detail="Account not found")
db.delete(account)
db.commit()
logger.info(f"Account deleted: {account_id}")
return {"detail": "Account deleted"}
class AccountUpdate(BaseModel):
name: Optional[str] = None
institution_name: Optional[str] = None
interest_rate: Optional[float] = None
maturity_date: Optional[str] = None
balance: Optional[float] = None
bucket_id: Optional[int] = None
account_type_id: Optional[int] = None
# last_validated_at is not user-editable except via balance update
@app.patch("/accounts/{account_id}", response_model=schemas.AccountRead, dependencies=[Depends(get_current_admin_user)])
def update_account(account_id: int, update: AccountUpdate = Body(...), db: Session = Depends(get_db)):
logger.info(f"Update account called: {account_id}")
account = db.query(models.Account).filter(models.Account.id == account_id).first()
if not account:
logger.warning(f"Account not found for update: {account_id}")
raise HTTPException(status_code=404, detail="Account not found")
data = update.dict(exclude_unset=True)
if 'maturity_date' in data and data['maturity_date']:
try:
data['maturity_date'] = datetime.fromisoformat(data['maturity_date'])
except Exception:
logger.error(f"Invalid maturity_date format: {data['maturity_date']}")
raise HTTPException(status_code=400, detail="Invalid maturity_date format. Use YYYY-MM-DD.")
for key, value in data.items():
setattr(account, key, value)
# If balance is updated, also update last_validated_at and record history
if 'balance' in data:
account.last_validated_at = datetime.utcnow()
# Record or update today's balance history
today = datetime.utcnow().date()
history = db.query(models.AccountBalanceHistory).filter(
models.AccountBalanceHistory.account_id == account.id,
models.AccountBalanceHistory.date >= datetime.combine(today, datetime.min.time()),
models.AccountBalanceHistory.date <= datetime.combine(today, datetime.max.time())
).first()
if history:
history.balance = account.balance
history.date = datetime.utcnow()
logger.debug(f"Updated balance history for account {account.id} on {today}: {account.balance}")
else:
new_history = models.AccountBalanceHistory(
account_id=account.id,
balance=account.balance,
date=datetime.utcnow()
)
db.add(new_history)
logger.debug(f"Created balance history for account {account.id} on {today}: {account.balance}")
db.commit()
db.refresh(account)
logger.info(f"Account updated: {account_id}")
return account
@app.get("/accounts/{account_id}/history", response_model=List[schemas.AccountBalanceHistoryRead], dependencies=[Depends(get_current_active_user)])
def get_account_balance_history(account_id: int, db: Session = Depends(get_db)):
logger.info(f"Get balance history for account: {account_id}")
history = db.query(models.AccountBalanceHistory).filter(models.AccountBalanceHistory.account_id == account_id).order_by(models.AccountBalanceHistory.date.asc()).all()
return history
# --- Transaction Endpoints ---
@app.post("/transactions", response_model=TransactionRead, dependencies=[Depends(get_current_admin_user)])
def create_transaction(transaction: TransactionCreate, db: Session = Depends(get_db)):
logger.info(f"Create transaction called for account: {transaction.account_id}")
# Parse date string to datetime if necessary
txn_date = transaction.date
if isinstance(txn_date, str) and txn_date:
try:
txn_date = datetime.fromisoformat(txn_date)
except Exception as e:
logger.error(f"Invalid transaction date format: {txn_date}")
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD or ISO format.")
db_transaction = models.Transaction(
account_id=transaction.account_id,
type=transaction.type,
amount=transaction.amount,
date=txn_date or None,
description=transaction.description,
related_account_id=transaction.related_account_id,
reconciled=transaction.reconciled if transaction.reconciled is not None else False
)
db.add(db_transaction)
db.commit()
db.refresh(db_transaction)
logger.info(f"Transaction created for account: {transaction.account_id}")
# Ensure date is returned as ISO string for response
result = db_transaction
if hasattr(result, 'date') and isinstance(result.date, datetime):
result.date = result.date.isoformat()
return result
@app.get("/transactions", response_model=list[TransactionRead], dependencies=[Depends(get_current_active_user)])
def list_transactions(db: Session = Depends(get_db)):
logger.debug("List transactions called.")
transactions = db.query(models.Transaction).all()
# Ensure all dates are ISO strings
for t in transactions:
if hasattr(t, 'date') and isinstance(t.date, datetime):
t.date = t.date.isoformat()
return transactions
@app.get("/transactions/{transaction_id}", response_model=TransactionRead, dependencies=[Depends(get_current_active_user)])
def get_transaction(transaction_id: int, db: Session = Depends(get_db)):
logger.debug(f"Get transaction called: {transaction_id}")
transaction = db.query(models.Transaction).filter(models.Transaction.id == transaction_id).first()
if not transaction:
logger.warning(f"Transaction not found: {transaction_id}")
raise HTTPException(status_code=404, detail="Transaction not found")
# Ensure date is ISO string
if hasattr(transaction, 'date') and isinstance(transaction.date, datetime):
transaction.date = transaction.date.isoformat()
return transaction
@app.delete("/transactions/{transaction_id}", dependencies=[Depends(get_current_admin_user)])
def delete_transaction(transaction_id: int, db: Session = Depends(get_db)):
logger.info(f"Delete transaction called: {transaction_id}")
transaction = db.query(models.Transaction).filter(models.Transaction.id == transaction_id).first()
if not transaction:
logger.warning(f"Transaction not found for delete: {transaction_id}")
raise HTTPException(status_code=404, detail="Transaction not found")
db.delete(transaction)
db.commit()
logger.info(f"Transaction deleted: {transaction_id}")
return {"detail": "Transaction deleted"}
@app.patch("/transactions/{transaction_id}", response_model=TransactionRead, dependencies=[Depends(get_current_admin_user)])
def update_transaction(transaction_id: int, transaction: TransactionCreate, db: Session = Depends(get_db)):
logger.info(f"Update transaction called for id: {transaction_id}")
db_transaction = db.query(models.Transaction).filter(models.Transaction.id == transaction_id).first()
if not db_transaction:
logger.warning(f"Transaction not found for update: {transaction_id}")
raise HTTPException(status_code=404, detail="Transaction not found")
# Parse date string to datetime if necessary
txn_date = transaction.date
if isinstance(txn_date, str) and txn_date:
try:
txn_date = datetime.fromisoformat(txn_date)
except Exception as e:
logger.error(f"Invalid transaction date format: {txn_date}")
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD or ISO format.")
db_transaction.account_id = transaction.account_id
db_transaction.related_account_id = transaction.related_account_id
db_transaction.type = transaction.type
db_transaction.amount = transaction.amount
db_transaction.date = txn_date or None
db_transaction.description = transaction.description
db_transaction.reconciled = transaction.reconciled if transaction.reconciled is not None else False
db.commit()
db.refresh(db_transaction)
logger.info(f"Transaction updated for id: {transaction_id}")
# Ensure date is returned as ISO string for response
result = db_transaction
if hasattr(result, 'date') and isinstance(result.date, datetime):
result.date = result.date.isoformat()
return result
# --- Cash Flow Endpoints ---
@app.post("/cashflows", response_model=CashFlowRead, dependencies=[Depends(get_current_admin_user)])
def create_cashflow(cashflow: CashFlowCreate, db: Session = Depends(get_db)):
logger.info(f"Create cashflow called for account: {cashflow.account_id}")
db_cashflow = models.CashFlow(
account_id=cashflow.account_id,
type=cashflow.type,
estimate_actual=cashflow.estimate_actual,
amount=cashflow.amount,
date=cashflow.date,
description=cashflow.description
)
db.add(db_cashflow)
db.commit()
db.refresh(db_cashflow)
logger.info(f"Cashflow created for account: {cashflow.account_id}")
return db_cashflow
@app.get("/cashflows", response_model=list[CashFlowRead], dependencies=[Depends(get_current_active_user)])
def list_cashflows(db: Session = Depends(get_db)):
logger.debug("List cashflows called.")
try:
return db.query(models.CashFlow).all()
except Exception as e:
logger.error(f"Error fetching cash flows: {e}", exc_info=True)
raise HTTPException(status_code=503, detail="Could not fetch cash flows. Backend error.")
@app.get("/cashflows/{cashflow_id}", response_model=CashFlowRead, dependencies=[Depends(get_current_active_user)])
def get_cashflow(cashflow_id: int, db: Session = Depends(get_db)):
logger.debug(f"Get cashflow called: {cashflow_id}")
cashflow = db.query(models.CashFlow).filter(models.CashFlow.id == cashflow_id).first()
if not cashflow:
logger.warning(f"Cashflow not found: {cashflow_id}")
raise HTTPException(status_code=404, detail="Cashflow not found")
return cashflow
@app.delete("/cashflows/{cashflow_id}", dependencies=[Depends(get_current_admin_user)])
def delete_cashflow(cashflow_id: int, db: Session = Depends(get_db)):
logger.info(f"Delete cashflow called: {cashflow_id}")
cashflow = db.query(models.CashFlow).filter(models.CashFlow.id == cashflow_id).first()
if not cashflow:
logger.warning(f"Cashflow not found for delete: {cashflow_id}")
raise HTTPException(status_code=404, detail="Cashflow not found")
db.delete(cashflow)
db.commit()
logger.info(f"Cashflow deleted: {cashflow_id}")
return {"detail": "Cashflow deleted"}
# --- Cash Flow Category Endpoints ---
@app.post("/cashflow/categories", response_model=CashFlowCategoryRead, dependencies=[Depends(get_current_admin_user)])
def create_cashflow_category(category: CashFlowCategoryCreate, db: Session = Depends(get_db)):
logger.info(f"Create cash flow category called: {category.name}")
db_cat = models.CashFlowCategory(
name=category.name,
category_type=category.category_type,
funding_type=category.funding_type
)
db.add(db_cat)
db.commit()
db.refresh(db_cat)
return db_cat
@app.get("/cashflow/categories", response_model=list[CashFlowCategoryRead], dependencies=[Depends(get_current_active_user)])
def list_cashflow_categories(db: Session = Depends(get_db)):
logger.debug("List cash flow categories called.")
return db.query(models.CashFlowCategory).all()
@app.patch("/cashflow/categories/{category_id}", response_model=CashFlowCategoryRead, dependencies=[Depends(get_current_admin_user)])
def update_cashflow_category(category_id: int, category: CashFlowCategoryCreate, db: Session = Depends(get_db)):
db_cat = db.query(models.CashFlowCategory).filter(models.CashFlowCategory.id == category_id).first()
if not db_cat:
raise HTTPException(status_code=404, detail="Category not found")
db_cat.name = category.name
db_cat.category_type = category.category_type
db_cat.funding_type = category.funding_type
db.commit()
db.refresh(db_cat)
return db_cat
@app.delete("/cashflow/categories/{category_id}", dependencies=[Depends(get_current_admin_user)])
def delete_cashflow_category(category_id: int, db: Session = Depends(get_db)):
db_cat = db.query(models.CashFlowCategory).filter(models.CashFlowCategory.id == category_id).first()
if not db_cat:
raise HTTPException(status_code=404, detail="Category not found")
db.delete(db_cat)
db.commit()
return {"ok": True}
# --- Cash Flow Entry Endpoints ---
@app.post("/cashflow/entries", response_model=CashFlowEntryRead, dependencies=[Depends(get_current_admin_user)])
def create_cashflow_entry(entry: CashFlowEntryCreate, db: Session = Depends(get_db), user: User = Depends(get_current_admin_user)):
logger.info(f"Create cash flow entry called for category {entry.category_id}, {entry.year}-{entry.month}")
db_entry = models.CashFlowEntry(
category_id=entry.category_id,
year=entry.year,
month=entry.month,
projected_amount=entry.projected_amount,
actual_amount=entry.actual_amount,
created_by=user.id
)
db.add(db_entry)
db.commit()
db.refresh(db_entry)
logger.debug(f"Created cash flow entry: projected={entry.projected_amount}, actual={entry.actual_amount}")
return db_entry
@app.get("/cashflow/entries", response_model=list[CashFlowEntryRead], dependencies=[Depends(get_current_active_user)])
def list_cashflow_entries(year: int = None, db: Session = Depends(get_db)):
logger.debug(f"List cash flow entries called for year: {year}")
q = db.query(models.CashFlowEntry)
if year:
q = q.filter(models.CashFlowEntry.year == year)
return q.all()
@app.patch("/cashflow/entries/{entry_id}", response_model=CashFlowEntryRead, dependencies=[Depends(get_current_admin_user)])
def update_cashflow_entry(entry_id: int, entry: CashFlowEntryCreate, db: Session = Depends(get_db)):
db_entry = db.query(models.CashFlowEntry).filter(models.CashFlowEntry.id == entry_id).first()
if not db_entry:
raise HTTPException(status_code=404, detail="Entry not found")
db_entry.category_id = entry.category_id
db_entry.year = entry.year
db_entry.month = entry.month
db_entry.projected_amount = entry.projected_amount
db_entry.actual_amount = entry.actual_amount
db.commit()
db.refresh(db_entry)
logger.debug(f"Updated cash flow entry: projected={entry.projected_amount}, actual={entry.actual_amount}")
return db_entry
@app.delete("/cashflow/entries/{entry_id}", dependencies=[Depends(get_current_admin_user)])
def delete_cashflow_entry(entry_id: int, db: Session = Depends(get_db)):
db_entry = db.query(models.CashFlowEntry).filter(models.CashFlowEntry.id == entry_id).first()
if not db_entry:
raise HTTPException(status_code=404, detail="Entry not found")
db.delete(db_entry)
db.commit()
return {"ok": True}
# --- Forecasting Endpoint ---
@app.post("/forecast", response_model=list[schemas.ForecastResponse], dependencies=[Depends(get_current_active_user)])
def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(get_db)):
logger = logging.getLogger("hoa_app.main")
logger.info(f"Forecast balances called for bucket: {getattr(request, 'bucket', None)} year: {getattr(request, 'year', None)}")
from collections import defaultdict
from datetime import datetime, timedelta
import calendar
bucket_name = getattr(request, 'bucket', None)
year = getattr(request, 'year', None)
months = getattr(request, 'months', 12)
if not bucket_name or not year:
return []
# 1. Get all accounts in the bucket
accounts = db.query(models.Account).join(models.Bucket).filter(models.Bucket.name == bucket_name).all()
account_ids = [a.id for a in accounts]
account_map = {a.id: a for a in accounts}
# 2. Get last validated balance for each account
validated_balances = {}
validated_dates = {}
for acc in accounts:
validated_balances[acc.id] = acc.balance or 0.0
validated_dates[acc.id] = acc.last_validated_at
# 3. Get all reconciled transactions for these accounts in the forecast year
txs = db.query(models.Transaction).filter(
models.Transaction.account_id.in_(account_ids),
models.Transaction.reconciled == True,
models.Transaction.date >= datetime(year, 1, 1),
models.Transaction.date < datetime(year + 1, 1, 1)
).all()
# Also get incoming transfers (where related_account_id is in account_ids)
incoming_txs = db.query(models.Transaction).filter(
models.Transaction.related_account_id.in_(account_ids),
models.Transaction.reconciled == True,
models.Transaction.date >= datetime(year, 1, 1),
models.Transaction.date < datetime(year + 1, 1, 1)
).all()
# --- NEW: Get all planned (unreconciled) transactions for these accounts in the forecast year ---
planned_txs = db.query(models.Transaction).filter(
models.Transaction.account_id.in_(account_ids),
models.Transaction.reconciled == False,
models.Transaction.date >= datetime(year, 1, 1),
models.Transaction.date < datetime(year + 1, 1, 1)
).all()
planned_incoming_txs = db.query(models.Transaction).filter(
models.Transaction.related_account_id.in_(account_ids),
models.Transaction.reconciled == False,
models.Transaction.date >= datetime(year, 1, 1),
models.Transaction.date < datetime(year + 1, 1, 1)
).all()
# 4. Organize transactions by (account_id, month)
txs_by_year_month = defaultdict(list)
for tx in txs:
tx_year = tx.date.year
tx_month = tx.date.month
txs_by_year_month[(tx.account_id, tx_year, tx_month)].append(tx)
for tx in incoming_txs:
tx_year = tx.date.year
tx_month = tx.date.month
# incoming transfer: add to related_account_id
txs_by_year_month[(tx.related_account_id, tx_year, tx_month)].append(tx)
# Add planned transactions to the same structure, with debug logging
for tx in planned_txs:
tx_year = tx.date.year
tx_month = tx.date.month
logger.debug(f"Including planned (unreconciled) transaction for account {tx.account_id} in month {tx_month}: {tx.type} {tx.amount}")
txs_by_year_month[(tx.account_id, tx_year, tx_month)].append(tx)
for tx in planned_incoming_txs:
tx_year = tx.date.year
tx_month = tx.date.month
logger.debug(f"Including planned (unreconciled) incoming transaction for related_account {tx.related_account_id} in month {tx_month}: {tx.type} {tx.amount}")
txs_by_year_month[(tx.related_account_id, tx_year, tx_month)].append(tx)
# Identify primary account for alerting and cash flow application
primary_account_id = None
primary_account = None
if bucket_name.lower() == "operating":
for acc in accounts:
if "5345" in acc.name:
primary_account_id = acc.id
primary_account = acc
break
elif bucket_name.lower() == "reserve":
for acc in accounts:
if "savings" in acc.name.lower():
primary_account_id = acc.id
primary_account = acc
break
alert_threshold = 1000.0
# 2. Get historical balances for each account
history_by_account = defaultdict(list)
histories = db.query(models.AccountBalanceHistory).filter(models.AccountBalanceHistory.account_id.in_(account_ids)).order_by(models.AccountBalanceHistory.date.asc()).all()
for h in histories:
history_by_account[h.account_id].append(h)
# --- Cash Flow Projections: Only for Operating bucket ---
cashflow_by_year_month = defaultdict(float)
if bucket_name.lower() == "operating" and primary_account_id is not None:
# Get all cash flow categories for Operating
op_categories = db.query(models.CashFlowCategory).filter(models.CashFlowCategory.funding_type == models.CashFlowFundingTypeEnum.operating).all()
op_cat_ids = [cat.id for cat in op_categories]
cat_type_map = {cat.id: cat.category_type for cat in op_categories}
# Get all cash flow entries for all years for these categories
cf_entries = db.query(models.CashFlowEntry).filter(
models.CashFlowEntry.category_id.in_(op_cat_ids)
).all()
for entry in cf_entries:
if entry.projected_amount is None:
continue
key = (entry.year, entry.month)
if cat_type_map.get(entry.category_id) == models.CashFlowCategoryTypeEnum.income:
cashflow_by_year_month[key] += entry.projected_amount
elif cat_type_map.get(entry.category_id) == models.CashFlowCategoryTypeEnum.expense:
cashflow_by_year_month[key] -= entry.projected_amount
for m in range(1, months + 1):
forecast_year = year + (m - 1) // 12
forecast_month = ((m - 1) % 12) + 1
logger.debug(f"Cash flow for {forecast_year}-{forecast_month:02d}: {cashflow_by_year_month[(forecast_year, forecast_month)]}")
# 5. Build forecast for each account
responses = []
for acc in accounts:
forecast_points = []
# Build a map of (account_id, year, month) -> latest balance for that month
monthly_actuals = {}
for h in history_by_account[acc.id]:
y, m = h.date.year, h.date.month
key = (y, m)
if key not in monthly_actuals or h.date > monthly_actuals[key][1]:
monthly_actuals[key] = (h.balance, h.date)
# Only keep the balance value
monthly_actuals = {k: v[0] for k, v in monthly_actuals.items()}
# Find the earliest month with a historical balance in the forecast year
earliest_actual_month = None
if monthly_actuals:
earliest_actual_month = min([m for (y, m) in monthly_actuals.keys() if y == year], default=None)
# --- CD-specific logic (must happen before balance determination) ---
is_cd = hasattr(acc, 'account_type') and acc.account_type and acc.account_type.name.lower() == 'cd'
funding_month = None
funding_amount = 0.0
if is_cd:
# Find first reconciled deposit/transfer into this CD in the forecast year
cd_funding_tx = None
for m in range(1, months + 1):
month_txs = txs_by_year_month.get((acc.id, year, m), [])
for tx in month_txs:
if tx.type in ('deposit', 'transfer'):
cd_funding_tx = tx
break
if cd_funding_tx:
funding_month = cd_funding_tx.date.month
funding_amount = cd_funding_tx.amount
logger.debug(f"CD funding for {acc.name} (id={acc.id}) in month {funding_month}: amount={funding_amount}")
break
# For CDs, always start with $0 balance - they don't exist before funding
balance = 0.0
logger.debug(f"[{acc.name}] CD account, using $0 starting balance (will be funded in month {funding_month if funding_month else 'unknown'})")
# --- Find the latest balance from any previous year as starting point ---
latest_prev_balance = None
latest_prev_date = None
for (y, m), bal in monthly_actuals.items():
if y < year:
if latest_prev_date is None or (y > latest_prev_date[0] or (y == latest_prev_date[0] and m > latest_prev_date[1])):
latest_prev_balance = bal
latest_prev_date = (y, m)
# --- Find the ending balance from the previous forecast period (Dec of previous year) ---
prev_dec_balance = None
if (year-1, 12) in monthly_actuals:
prev_dec_balance = monthly_actuals[(year-1, 12)]
# Determine starting balance for forecast (skip for CDs since we already set them to $0)
if not is_cd:
if earliest_actual_month == 1 and (year, 1) in monthly_actuals:
# If there is an actual for Jan of this year, use it
balance = monthly_actuals[(year, 1)]
logger.debug(f"[{acc.name}] Using actual balance for Jan {year}: {balance}")
elif prev_dec_balance is not None:
balance = prev_dec_balance
logger.debug(f"[{acc.name}] Using Dec {year-1} ending balance as starting balance for {year}: {balance}")
elif latest_prev_balance is not None:
balance = latest_prev_balance
logger.debug(f"[{acc.name}] Using latest available balance from previous years ({latest_prev_date[0]}-{latest_prev_date[1]}): {balance}")
else:
# Universal rule: If no historical data, start with $0
balance = 0.0
logger.debug(f"[{acc.name}] No prior year balance found, using $0 starting balance")
# For CDs, balance is already set to $0 above
alert = False
validated_date = validated_dates[acc.id]
for m in range(1, months + 1):
# Calculate the correct year and month for this forecast point
forecast_year = year + (m - 1) // 12
forecast_month = ((m - 1) % 12) + 1
month_date = datetime(forecast_year, forecast_month, 1)
key = (forecast_year, forecast_month)
# --- Universal rule: For months before the first validated balance, use $0 ---
if earliest_actual_month is not None and forecast_year == year and forecast_month < earliest_actual_month:
bal = 0.0
logger.debug(f"[{acc.name}] month {forecast_month}: Using $0 balance (before first validated balance in month {earliest_actual_month})")
balance = bal
forecast_points.append({"date": month_date.strftime("%Y-%m-%d"), "balance": bal})
continue
# --- CD logic: $0 until funded, even if historical ---
if is_cd and (funding_month is None or forecast_month < funding_month):
bal = 0.0
logger.debug(f"CD {acc.name} (id={acc.id}) month {forecast_month}: Forcing balance to $0 (before funding month {funding_month})")
balance = bal
forecast_points.append({"date": month_date.strftime("%Y-%m-%d"), "balance": bal})
continue
# Use actual if available (for non-CDs, or for CDs after funding)
if key in monthly_actuals:
bal = monthly_actuals[key]
balance = bal
forecast_points.append({"date": month_date.strftime("%Y-%m-%d"), "balance": bal})
else:
# Projected: start from last known balance (which may be from previous year)
if balance is None:
bal = 0.0
else:
bal = balance
# Apply transactions for this month
for tx in txs_by_year_month.get((acc.id, forecast_year, forecast_month), []):
if tx.type == "deposit":
bal += tx.amount
elif tx.type == "withdrawal":
bal -= tx.amount
elif tx.type == "transfer":
if tx.account_id == acc.id:
bal -= tx.amount
elif tx.related_account_id == acc.id:
bal += tx.amount
# --- Apply cash flow projection to primary account (Operating only) ---
if bucket_name.lower() == "operating" and acc.id == primary_account_id:
cf = cashflow_by_year_month.get((forecast_year, forecast_month), 0.0)
if cf != 0.0:
logger.debug(f"Applying cash flow projection to primary account {acc.name} (id={acc.id}) {forecast_year}-{forecast_month:02d}: {cf}")
bal += cf
# --- Apply interest if applicable ---
if acc.interest_rate and acc.interest_rate > 0:
interest = bal * (acc.interest_rate / 100.0) / 12.0
bal += interest
logger.debug(f"Interest applied to {acc.name} (id={acc.id}) month {forecast_month}: rate={acc.interest_rate}%, interest={interest:.2f}, new balance={bal:.2f}")
balance = bal
forecast_points.append({"date": month_date.strftime("%Y-%m-%d"), "balance": bal})
if acc.id == primary_account_id and bal < alert_threshold:
alert = True
responses.append(schemas.ForecastResponse(
account_id=acc.id,
forecast=[schemas.ForecastPoint(**pt) for pt in forecast_points],
is_primary=(acc.id == primary_account_id),
alert=alert,
account_name=acc.name,
account_type=acc.account_type.name if hasattr(acc, 'account_type') and acc.account_type else None
))
logger.info(f"Forecast complete for bucket: {bucket_name} year: {year}")
return responses
# --- Reporting Endpoint ---
@app.post("/report", response_model=ReportResponse, dependencies=[Depends(get_current_active_user)])
def report(request: ReportRequest, db: Session = Depends(get_db)):
logger.info(f"Report called for start: {request.start_date}, end: {request.end_date}")
start = datetime.strptime(request.start_date, "%Y-%m-%d")
end = datetime.strptime(request.end_date, "%Y-%m-%d")
query = db.query(models.Transaction)
if request.account_id:
query = query.filter(models.Transaction.account_id == request.account_id)
elif request.bucket_id:
account_ids = [a.id for a in db.query(models.Account).filter(models.Account.bucket_id == request.bucket_id)]
query = query.filter(models.Transaction.account_id.in_(account_ids))
query = query.filter(and_(models.Transaction.date >= start, models.Transaction.date <= end))
transactions = query.all()
entries = []
for t in transactions:
entries.append(ReportEntry(
date=t.date.isoformat() if isinstance(t.date, datetime) else str(t.date),
description=t.description or "",
amount=t.amount,
type=t.type,
balance=None
))
logger.info(f"Report generated with {len(entries)} entries.")
return ReportResponse(entries=entries)

View File

@@ -0,0 +1,149 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Enum, Boolean
from sqlalchemy.orm import relationship, declarative_base
import enum
from datetime import datetime
from .logging_config import setup_logging, DEBUG_LOGGING
import logging
Base = declarative_base()
setup_logging()
models_logger = logging.getLogger("hoa_app.models")
class RoleEnum(str, enum.Enum):
admin = "admin"
user = "user"
class Role(Base):
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
name = Column(Enum(RoleEnum), unique=True, nullable=False)
users = relationship("User", back_populates="role")
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False)
password_hash = Column(String, nullable=False)
role_id = Column(Integer, ForeignKey("roles.id"))
created_at = Column(DateTime, default=datetime.utcnow)
role = relationship("Role", back_populates="users")
class Bucket(Base):
__tablename__ = "buckets"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, nullable=False)
description = Column(String)
accounts = relationship("Account", back_populates="bucket")
class AccountType(Base):
__tablename__ = "account_types"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, nullable=False)
accounts = relationship("Account", back_populates="account_type")
def __init__(self, **kwargs):
super().__init__(**kwargs)
if DEBUG_LOGGING:
models_logger.debug(f"AccountType created: {self.name}")
class Account(Base):
__tablename__ = "accounts"
id = Column(Integer, primary_key=True, index=True)
bucket_id = Column(Integer, ForeignKey("buckets.id"))
account_type_id = Column(Integer, ForeignKey("account_types.id"))
name = Column(String, nullable=False)
institution_name = Column(String, nullable=False)
interest_rate = Column(Float, default=0.0)
maturity_date = Column(DateTime, nullable=True)
balance = Column(Float, default=0.0)
last_validated_at = Column(DateTime, default=datetime.utcnow)
bucket = relationship("Bucket", back_populates="accounts")
account_type = relationship("AccountType", back_populates="accounts")
transactions = relationship("Transaction", back_populates="account", foreign_keys="[Transaction.account_id]")
cash_flows = relationship("CashFlow", back_populates="account")
balance_history = relationship("AccountBalanceHistory", back_populates="account", cascade="all, delete-orphan")
def __init__(self, **kwargs):
super().__init__(**kwargs)
if DEBUG_LOGGING:
models_logger.debug(f"Account created: {self.name} at {self.institution_name}")
def set_balance(self, new_balance):
if self.balance != new_balance:
if DEBUG_LOGGING:
models_logger.debug(f"Balance update for account {self.id}: {self.balance} -> {new_balance}")
self.balance = new_balance
self.last_validated_at = datetime.utcnow()
if DEBUG_LOGGING:
models_logger.debug(f"last_validated_at updated for account {self.id}: {self.last_validated_at}")
class TransactionTypeEnum(str, enum.Enum):
deposit = "deposit"
withdrawal = "withdrawal"
transfer = "transfer"
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("accounts.id"))
type = Column(Enum(TransactionTypeEnum), nullable=False)
amount = Column(Float, nullable=False)
date = Column(DateTime, default=datetime.utcnow)
description = Column(String)
related_account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True)
reconciled = Column(Boolean, default=False) # NEW FIELD
account = relationship("Account", back_populates="transactions", foreign_keys=[account_id])
related_account = relationship("Account", foreign_keys=[related_account_id])
class CashFlowTypeEnum(str, enum.Enum):
inflow = "inflow"
outflow = "outflow"
class CashFlow(Base):
__tablename__ = "cash_flows"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("accounts.id"))
type = Column(Enum(CashFlowTypeEnum), nullable=False)
estimate_actual = Column(Boolean, default=True) # True=estimate, False=actual
amount = Column(Float, nullable=False)
date = Column(DateTime, nullable=False)
description = Column(String)
account = relationship("Account", back_populates="cash_flows")
class AccountBalanceHistory(Base):
__tablename__ = "account_balance_history"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False, index=True)
balance = Column(Float, nullable=False)
date = Column(DateTime, nullable=False, index=True)
account = relationship("Account", back_populates="balance_history")
class CashFlowCategoryTypeEnum(str, enum.Enum):
income = "income"
expense = "expense"
class CashFlowFundingTypeEnum(str, enum.Enum):
operating = "Operating"
reserve = "Reserve"
class CashFlowCategory(Base):
__tablename__ = "cash_flow_categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, nullable=False)
category_type = Column(Enum(CashFlowCategoryTypeEnum), nullable=False)
funding_type = Column(Enum(CashFlowFundingTypeEnum), nullable=False)
entries = relationship("CashFlowEntry", back_populates="category")
class CashFlowEntry(Base):
__tablename__ = "cash_flow_entries"
id = Column(Integer, primary_key=True, index=True)
category_id = Column(Integer, ForeignKey("cash_flow_categories.id"), nullable=False)
year = Column(Integer, nullable=False)
month = Column(Integer, nullable=False)
projected_amount = Column(Float, nullable=True)
actual_amount = Column(Float, nullable=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
category = relationship("CashFlowCategory", back_populates="entries")
user = relationship("User")

View File

@@ -0,0 +1,2 @@
INFO: Will watch for changes in these directories: ['/home/pi/HOApro/backend/backend/hoa_app']
ERROR: [Errno 98] Address already in use

View File

@@ -0,0 +1,174 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from .models import CashFlowCategoryTypeEnum, CashFlowFundingTypeEnum
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
class UserBase(BaseModel):
username: str
class UserCreate(UserBase):
password: str
class UserRead(UserBase):
id: int
role: str
class Config:
orm_mode = True
class BucketBase(BaseModel):
name: str
description: str = ""
class BucketCreate(BucketBase):
pass
class BucketRead(BucketBase):
id: int
class Config:
orm_mode = True
class AccountTypeBase(BaseModel):
name: str
class AccountTypeCreate(AccountTypeBase):
pass
class AccountTypeRead(AccountTypeBase):
id: int
class Config:
orm_mode = True
class AccountBase(BaseModel):
name: str
institution_name: str
interest_rate: float = 0.0
maturity_date: Optional[str] = None
balance: float = 0.0
account_type_id: int
bucket_id: int
class AccountCreate(AccountBase):
pass
class AccountRead(AccountBase):
id: int
maturity_date: Optional[datetime] = None
last_validated_at: Optional[datetime] = None
account_type: AccountTypeRead
bucket: BucketRead
class Config:
from_attributes = True
class TransactionBase(BaseModel):
account_id: int
type: str
amount: float
date: Optional[str] = None
description: Optional[str] = ""
related_account_id: Optional[int] = None
reconciled: Optional[bool] = False
class TransactionCreate(TransactionBase):
pass
class TransactionRead(TransactionBase):
id: int
date: str | None = None
class Config:
orm_mode = True
class CashFlowBase(BaseModel):
account_id: int
type: str
estimate_actual: bool = True
amount: float
date: datetime
description: Optional[str] = ""
class CashFlowCreate(CashFlowBase):
pass
class CashFlowRead(CashFlowBase):
id: int
class Config:
from_attributes = True
class ForecastRequest(BaseModel):
bucket: str = Field(..., description="Bucket name (Operating or Reserve)")
year: int = Field(..., description="Year for forecasting")
months: int = Field(12, gt=0, le=120, description="Number of months to forecast")
class ForecastPoint(BaseModel):
date: str
balance: float
class ForecastResponse(BaseModel):
account_id: int
forecast: list[ForecastPoint]
is_primary: bool = False
alert: bool = False
account_name: str | None = None
account_type: str | None = None
class ReportRequest(BaseModel):
start_date: str
end_date: str
bucket_id: int | None = None
account_id: int | None = None
class ReportEntry(BaseModel):
date: str
description: str
amount: float
type: str
balance: float | None = None
class ReportResponse(BaseModel):
entries: list[ReportEntry]
class AccountBalanceHistoryRead(BaseModel):
id: int
account_id: int
balance: float
date: datetime
class Config:
from_attributes = True
class CashFlowCategoryBase(BaseModel):
name: str
category_type: CashFlowCategoryTypeEnum
funding_type: CashFlowFundingTypeEnum
class CashFlowCategoryCreate(CashFlowCategoryBase):
pass
class CashFlowCategoryRead(CashFlowCategoryBase):
id: int
class Config:
from_attributes = True
class CashFlowEntryBase(BaseModel):
category_id: int
year: int
month: int
projected_amount: float | None = None
actual_amount: float | None = None
class CashFlowEntryCreate(CashFlowEntryBase):
pass
class CashFlowEntryRead(CashFlowEntryBase):
id: int
created_by: Optional[int]
created_at: Optional[datetime]
updated_at: Optional[datetime]
category: Optional[CashFlowCategoryRead]
class Config:
from_attributes = True

View File

@@ -0,0 +1,247 @@
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

View File

@@ -0,0 +1,69 @@
# This file must be used with "source bin/activate" *from bash*
# you cannot run it directly
deactivate () {
# reset old environment variables
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
fi
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
export PYTHONHOME
unset _OLD_VIRTUAL_PYTHONHOME
fi
# This should detect bash and zsh, which have a hash command that must
# be called to get it to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
hash -r 2> /dev/null
fi
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}"
export PS1
unset _OLD_VIRTUAL_PS1
fi
unset VIRTUAL_ENV
unset VIRTUAL_ENV_PROMPT
if [ ! "${1:-}" = "nondestructive" ] ; then
# Self destruct!
unset -f deactivate
fi
}
# unset irrelevant variables
deactivate nondestructive
VIRTUAL_ENV=/home/pi/HOApro/backend/backend/hoa_app/venv
export VIRTUAL_ENV
_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/"bin":$PATH"
export PATH
# unset PYTHONHOME if set
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
# could use `if (set -u; : $PYTHONHOME) ;` in bash
if [ -n "${PYTHONHOME:-}" ] ; then
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
unset PYTHONHOME
fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1:-}"
PS1='(venv) '"${PS1:-}"
export PS1
VIRTUAL_ENV_PROMPT='(venv) '
export VIRTUAL_ENV_PROMPT
fi
# This should detect bash and zsh, which have a hash command that must
# be called to get it to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
hash -r 2> /dev/null
fi

View File

@@ -0,0 +1,26 @@
# This file must be used with "source bin/activate.csh" *from csh*.
# You cannot run it directly.
# Created by Davide Di Blasi <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
# Unset irrelevant variables.
deactivate nondestructive
setenv VIRTUAL_ENV /home/pi/HOApro/backend/backend/hoa_app/venv
set _OLD_VIRTUAL_PATH="$PATH"
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
set _OLD_VIRTUAL_PROMPT="$prompt"
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
set prompt = '(venv) '"$prompt"
setenv VIRTUAL_ENV_PROMPT '(venv) '
endif
alias pydoc python -m pydoc
rehash

View File

@@ -0,0 +1,69 @@
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/); you cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables
if test -n "$_OLD_VIRTUAL_PATH"
set -gx PATH $_OLD_VIRTUAL_PATH
set -e _OLD_VIRTUAL_PATH
end
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
set -e _OLD_VIRTUAL_PYTHONHOME
end
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
set -e _OLD_FISH_PROMPT_OVERRIDE
# prevents error when using nested fish instances (Issue #93858)
if functions -q _old_fish_prompt
functions -e fish_prompt
functions -c _old_fish_prompt fish_prompt
functions -e _old_fish_prompt
end
end
set -e VIRTUAL_ENV
set -e VIRTUAL_ENV_PROMPT
if test "$argv[1]" != "nondestructive"
# Self-destruct!
functions -e deactivate
end
end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV /home/pi/HOApro/backend/backend/hoa_app/venv
set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
# Unset PYTHONHOME if set.
if set -q PYTHONHOME
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
set -e PYTHONHOME
end
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
# fish uses a function instead of an env var to generate the prompt.
# Save the current fish_prompt function as the function _old_fish_prompt.
functions -c fish_prompt _old_fish_prompt
# With the original prompt function renamed, we can override with our own.
function fish_prompt
# Save the return status of the last command.
set -l old_status $status
# Output the venv prompt; color taken from the blue of the Python logo.
printf "%s%s%s" (set_color 4B8BBE) '(venv) ' (set_color normal)
# Restore the return status of the previous command.
echo "exit $old_status" | .
# Output the original/"old" prompt.
_old_fish_prompt
end
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
set -gx VIRTUAL_ENV_PROMPT '(venv) '
end

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from alembic.config import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from dotenv.__main__ import cli
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli())

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from fastapi.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from mako.cmd import cmdline
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cmdline())

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from rsa.cli import decrypt
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(decrypt())

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from rsa.cli import encrypt
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(encrypt())

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from rsa.cli import keygen
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(keygen())

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from rsa.util import private_to_public
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(private_to_public())

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from rsa.cli import sign
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(sign())

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from rsa.cli import verify
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(verify())

View File

@@ -0,0 +1 @@
python3

View File

@@ -0,0 +1 @@
/usr/bin/python3

View File

@@ -0,0 +1 @@
python3

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from uvicorn.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from watchfiles.cli import cli
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli())

View File

@@ -0,0 +1,8 @@
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from websockets.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -0,0 +1,164 @@
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
/* Greenlet object interface */
#ifndef Py_GREENLETOBJECT_H
#define Py_GREENLETOBJECT_H
#include <Python.h>
#ifdef __cplusplus
extern "C" {
#endif
/* This is deprecated and undocumented. It does not change. */
#define GREENLET_VERSION "1.0.0"
#ifndef GREENLET_MODULE
#define implementation_ptr_t void*
#endif
typedef struct _greenlet {
PyObject_HEAD
PyObject* weakreflist;
PyObject* dict;
implementation_ptr_t pimpl;
} PyGreenlet;
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
/* C API functions */
/* Total number of symbols that are exported */
#define PyGreenlet_API_pointers 12
#define PyGreenlet_Type_NUM 0
#define PyExc_GreenletError_NUM 1
#define PyExc_GreenletExit_NUM 2
#define PyGreenlet_New_NUM 3
#define PyGreenlet_GetCurrent_NUM 4
#define PyGreenlet_Throw_NUM 5
#define PyGreenlet_Switch_NUM 6
#define PyGreenlet_SetParent_NUM 7
#define PyGreenlet_MAIN_NUM 8
#define PyGreenlet_STARTED_NUM 9
#define PyGreenlet_ACTIVE_NUM 10
#define PyGreenlet_GET_PARENT_NUM 11
#ifndef GREENLET_MODULE
/* This section is used by modules that uses the greenlet C API */
static void** _PyGreenlet_API = NULL;
# define PyGreenlet_Type \
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
# define PyExc_GreenletError \
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
# define PyExc_GreenletExit \
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
/*
* PyGreenlet_New(PyObject *args)
*
* greenlet.greenlet(run, parent=None)
*/
# define PyGreenlet_New \
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
_PyGreenlet_API[PyGreenlet_New_NUM])
/*
* PyGreenlet_GetCurrent(void)
*
* greenlet.getcurrent()
*/
# define PyGreenlet_GetCurrent \
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
/*
* PyGreenlet_Throw(
* PyGreenlet *greenlet,
* PyObject *typ,
* PyObject *val,
* PyObject *tb)
*
* g.throw(...)
*/
# define PyGreenlet_Throw \
(*(PyObject * (*)(PyGreenlet * self, \
PyObject * typ, \
PyObject * val, \
PyObject * tb)) \
_PyGreenlet_API[PyGreenlet_Throw_NUM])
/*
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
*
* g.switch(*args, **kwargs)
*/
# define PyGreenlet_Switch \
(*(PyObject * \
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
_PyGreenlet_API[PyGreenlet_Switch_NUM])
/*
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
*
* g.parent = new_parent
*/
# define PyGreenlet_SetParent \
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
/*
* PyGreenlet_GetParent(PyObject* greenlet)
*
* return greenlet.parent;
*
* This could return NULL even if there is no exception active.
* If it does not return NULL, you are responsible for decrementing the
* reference count.
*/
# define PyGreenlet_GetParent \
(*(PyGreenlet* (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
/*
* deprecated, undocumented alias.
*/
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
# define PyGreenlet_MAIN \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
# define PyGreenlet_STARTED \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
# define PyGreenlet_ACTIVE \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
/* Macro that imports greenlet and initializes C API */
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
keep the older definition to be sure older code that might have a copy of
the header still works. */
# define PyGreenlet_Import() \
{ \
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
}
#endif /* GREENLET_MODULE */
#ifdef __cplusplus
}
#endif
#endif /* !Py_GREENLETOBJECT_H */

View File

@@ -0,0 +1,28 @@
Copyright 2010 Pallets
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,92 @@
Metadata-Version: 2.1
Name: MarkupSafe
Version: 3.0.2
Summary: Safely add untrusted strings to HTML/XML markup.
Maintainer-email: Pallets <contact@palletsprojects.com>
License: Copyright 2010 Pallets
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Project-URL: Donate, https://palletsprojects.com/donate
Project-URL: Documentation, https://markupsafe.palletsprojects.com/
Project-URL: Changes, https://markupsafe.palletsprojects.com/changes/
Project-URL: Source, https://github.com/pallets/markupsafe/
Project-URL: Chat, https://discord.gg/pallets
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Web Environment
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: BSD License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
Classifier: Topic :: Text Processing :: Markup :: HTML
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE.txt
# MarkupSafe
MarkupSafe implements a text object that escapes characters so it is
safe to use in HTML and XML. Characters that have special meanings are
replaced so that they display as the actual characters. This mitigates
injection attacks, meaning untrusted user input can safely be displayed
on a page.
## Examples
```pycon
>>> from markupsafe import Markup, escape
>>> # escape replaces special characters and wraps in Markup
>>> escape("<script>alert(document.cookie);</script>")
Markup('&lt;script&gt;alert(document.cookie);&lt;/script&gt;')
>>> # wrap in Markup to mark text "safe" and prevent escaping
>>> Markup("<strong>Hello</strong>")
Markup('<strong>hello</strong>')
>>> escape(Markup("<strong>Hello</strong>"))
Markup('<strong>hello</strong>')
>>> # Markup is a str subclass
>>> # methods and operators escape their arguments
>>> template = Markup("Hello <em>{name}</em>")
>>> template.format(name='"World"')
Markup('Hello <em>&#34;World&#34;</em>')
```
## Donate
The Pallets organization develops and supports MarkupSafe and other
popular packages. In order to grow the community of contributors and
users, and allow the maintainers to devote more time to the projects,
[please donate today][].
[please donate today]: https://palletsprojects.com/donate

View File

@@ -0,0 +1,15 @@
MarkupSafe-3.0.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
MarkupSafe-3.0.2.dist-info/LICENSE.txt,sha256=SJqOEQhQntmKN7uYPhHg9-HTHwvY-Zp5yESOf_N9B-o,1475
MarkupSafe-3.0.2.dist-info/METADATA,sha256=aAwbZhSmXdfFuMM-rEHpeiHRkBOGESyVLJIuwzHP-nw,3975
MarkupSafe-3.0.2.dist-info/RECORD,,
MarkupSafe-3.0.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
MarkupSafe-3.0.2.dist-info/WHEEL,sha256=Op2RVjKCU4Yd3uty1Wlljkjcwas4cTvIrdqkKFZWK28,153
MarkupSafe-3.0.2.dist-info/top_level.txt,sha256=qy0Plje5IJuvsCBjejJyhDCjEAdcDLK_2agVcex8Z6U,11
markupsafe/__init__.py,sha256=sr-U6_27DfaSrj5jnHYxWN-pvhM27sjlDplMDPZKm7k,13214
markupsafe/__pycache__/__init__.cpython-311.pyc,,
markupsafe/__pycache__/_native.cpython-311.pyc,,
markupsafe/_native.py,sha256=hSLs8Jmz5aqayuengJJ3kdT5PwNpBWpKrmQSdipndC8,210
markupsafe/_speedups.c,sha256=O7XulmTo-epI6n2FtMVOrJXl8EAaIwD2iNYmBI5SEoQ,4149
markupsafe/_speedups.cpython-311-aarch64-linux-gnu.so,sha256=ERBcuz-gl_TnODv5KWmFWXAr45_JjnsouJnevCcUXlc,98536
markupsafe/_speedups.pyi,sha256=ENd1bYe7gbBUf2ywyYWOGUpnXOHNJ-cgTNqetlW8h5k,41
markupsafe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0

View File

@@ -0,0 +1,6 @@
Wheel-Version: 1.0
Generator: setuptools (75.2.0)
Root-Is-Purelib: false
Tag: cp311-cp311-manylinux_2_17_aarch64
Tag: cp311-cp311-manylinux2014_aarch64

View File

@@ -0,0 +1,20 @@
Copyright (c) 2017-2021 Ingy döt Net
Copyright (c) 2006-2016 Kirill Simonov
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Some files were not shown because too many files have changed in this diff Show More