Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ COPY pyproject.toml /opt/api/
COPY uv.lock /opt/api/

WORKDIR /opt/api
RUN --mount=type=cache,target=/root/.cache/uv \
uv export > requirements.txt && \
RUN uv export > requirements.txt && \
uv pip install --system -r requirements.txt

EXPOSE 8000
Expand Down
5 changes: 5 additions & 0 deletions api/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from app.services.token import TokenService
from app.tasks.auto_exchange import schedule_auto_exchange
from app.tasks.balance_reminder import schedule_balance_reminders
from app.tasks.fee_allocation_selection import schedule_fee_allocation_selection
from app.tasks.invoice_auto_pay import schedule_invoice_auto_pay
from app.tasks.keepz_payments_poll import schedule_keepz_poll
from fastapi import FastAPI, Request
Expand All @@ -51,6 +52,9 @@ async def lifespan(app: FastAPI):
app.state.keepz_poll_task = asyncio.create_task(schedule_keepz_poll())
app.state.auto_exchange_task = asyncio.create_task(schedule_auto_exchange())
app.state.balance_reminder_task = asyncio.create_task(schedule_balance_reminders())
app.state.fee_allocation_selection_task = asyncio.create_task(
schedule_fee_allocation_selection()
)
try:
yield
finally:
Expand All @@ -59,6 +63,7 @@ async def lifespan(app: FastAPI):
"keepz_poll_task",
"auto_exchange_task",
"balance_reminder_task",
"fee_allocation_selection_task",
):
task = getattr(app.state, task_name, None)
if task is not None:
Expand Down
183 changes: 178 additions & 5 deletions api/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,57 @@
from dataclasses import dataclass, field
from os import getenv

DEFAULT_FEE_SELECTION_DEADLINE_DAYS = 30
DEFAULT_SAFETY_CUSHION_ENTITY_ID = 60
DEFAULT_COMMON_CONSUMABLES_ENTITY_ID = 61
DEFAULT_GENERAL_PURCHASE_FUND_ENTITY_ID = 62

DEFAULT_FEE_RULES: list[dict[str, object]] = [
{
"membership_tag_id": 2,
"label": "resident",
"invoice_amounts": {"usd": "50.00"},
"legacy_invoice_amounts": {"usd": "42.00"},
"directed_amounts": {"usd": "4.00"},
"fixed_allocations": [
{
"component_key": "safety_cushion",
"amounts": {"usd": "2.00"},
"target_entity_id": DEFAULT_SAFETY_CUSHION_ENTITY_ID,
},
{
"component_key": "common_consumables",
"amounts": {"usd": "2.00"},
"target_entity_id": DEFAULT_COMMON_CONSUMABLES_ENTITY_ID,
},
],
"default_directed_target_entity_id": DEFAULT_GENERAL_PURCHASE_FUND_ENTITY_ID,
},
{
"membership_tag_id": 14,
"label": "member",
"invoice_amounts": {"usd": "30.00"},
"legacy_invoice_amounts": {"usd": "25.00"},
"directed_amounts": {"usd": "1.00"},
"fixed_allocations": [
{
"component_key": "safety_cushion",
"amounts": {"usd": "2.00"},
"target_entity_id": DEFAULT_SAFETY_CUSHION_ENTITY_ID,
},
{
"component_key": "common_consumables",
"amounts": {"usd": "2.00"},
"target_entity_id": DEFAULT_COMMON_CONSUMABLES_ENTITY_ID,
},
],
"default_directed_target_entity_id": DEFAULT_GENERAL_PURCHASE_FUND_ENTITY_ID,
},
]

DEFAULT_FEE_PRESETS: list[dict[str, str | int]] = [
{"tag_id": 2, "currency": "usd", "amount": "42"},
{"tag_id": 2, "currency": "gel", "amount": "115"},
{"tag_id": 14, "currency": "usd", "amount": "25"},
{"tag_id": 14, "currency": "gel", "amount": "70"},
{"tag_id": 2, "currency": "usd", "amount": "50.00"},
{"tag_id": 14, "currency": "usd", "amount": "30.00"},
]


Expand Down Expand Up @@ -47,6 +93,18 @@ class Config:
# Optional database URL for Postgres or other databases
database_url_env: str | None = field(default=getenv("REFINANCE_DATABASE_URL", None))
fee_presets_raw: str = field(default=getenv("REFINANCE_FEE_PRESETS", ""))
fee_rules_raw: str = field(default=getenv("REFINANCE_FEE_RULES", ""))
fee_selection_deadline_days: int = field(
default=int(
getenv(
"REFINANCE_FEE_SELECTION_DEADLINE_DAYS",
str(DEFAULT_FEE_SELECTION_DEADLINE_DAYS),
)
)
)
finance_entity_ids_raw: str = field(
default=getenv("REFINANCE_FINANCE_ENTITY_IDS", "")
)

@property
def database_url(self) -> str:
Expand All @@ -58,7 +116,21 @@ def database_url(self) -> str:
@property
def fee_presets(self) -> list[dict[str, str | int]]:
if not self.fee_presets_raw:
return DEFAULT_FEE_PRESETS
presets: list[dict[str, str | int]] = []
for rule in self.fee_rules:
tag_id = rule.get("membership_tag_id")
invoice_amounts = rule.get("invoice_amounts", {})
if not isinstance(tag_id, int) or not isinstance(invoice_amounts, dict):
continue
for currency, amount in invoice_amounts.items():
presets.append(
{
"tag_id": tag_id,
"currency": str(currency).lower(),
"amount": str(amount),
}
)
return presets or DEFAULT_FEE_PRESETS
try:
parsed = json.loads(self.fee_presets_raw)
except json.JSONDecodeError:
Expand Down Expand Up @@ -90,6 +162,107 @@ def fee_presets(self) -> list[dict[str, str | int]]:
)
return normalized or DEFAULT_FEE_PRESETS

@staticmethod
def _normalize_fee_amounts(raw_value: object) -> dict[str, str]:
if not isinstance(raw_value, dict):
return {}
normalized: dict[str, str] = {}
for currency, amount in raw_value.items():
currency_value = str(currency).lower().strip()
if not currency_value or amount is None:
continue
normalized[currency_value] = str(amount)
return normalized

def _normalize_fee_rule(self, raw_item: object) -> dict[str, object] | None:
if not isinstance(raw_item, dict):
return None
try:
membership_tag_id = int(raw_item["membership_tag_id"])
except (KeyError, TypeError, ValueError):
return None
label = str(raw_item.get("label") or f"tag {membership_tag_id}").strip()
invoice_amounts = self._normalize_fee_amounts(raw_item.get("invoice_amounts"))
legacy_invoice_amounts = self._normalize_fee_amounts(
raw_item.get("legacy_invoice_amounts")
)
directed_amounts = self._normalize_fee_amounts(raw_item.get("directed_amounts"))
if not label or not invoice_amounts or not legacy_invoice_amounts:
return None

fixed_allocations: list[dict[str, object]] = []
for item in raw_item.get("fixed_allocations", []):
if not isinstance(item, dict):
continue
component_key = str(item.get("component_key") or "").strip()
amounts = self._normalize_fee_amounts(item.get("amounts"))
raw_target_entity_id = item.get("target_entity_id")
if raw_target_entity_id is None:
continue
try:
target_entity_id = int(raw_target_entity_id)
except (TypeError, ValueError):
continue
if not component_key or not amounts:
continue
fixed_allocations.append(
{
"component_key": component_key,
"amounts": amounts,
"target_entity_id": target_entity_id,
}
)

try:
default_directed_target_entity_id = int(
raw_item.get(
"default_directed_target_entity_id",
DEFAULT_GENERAL_PURCHASE_FUND_ENTITY_ID,
)
)
except (TypeError, ValueError):
default_directed_target_entity_id = DEFAULT_GENERAL_PURCHASE_FUND_ENTITY_ID

return {
"membership_tag_id": membership_tag_id,
"label": label,
"invoice_amounts": invoice_amounts,
"legacy_invoice_amounts": legacy_invoice_amounts,
"directed_amounts": directed_amounts,
"fixed_allocations": fixed_allocations,
"default_directed_target_entity_id": default_directed_target_entity_id,
}

@property
def fee_rules(self) -> list[dict[str, object]]:
if not self.fee_rules_raw:
return DEFAULT_FEE_RULES
try:
parsed = json.loads(self.fee_rules_raw)
except json.JSONDecodeError:
return DEFAULT_FEE_RULES
if not isinstance(parsed, list):
return DEFAULT_FEE_RULES
normalized = [
rule
for item in parsed
if (rule := self._normalize_fee_rule(item)) is not None
]
return normalized or DEFAULT_FEE_RULES

@property
def finance_entity_ids(self) -> set[int]:
entity_ids: set[int] = set()
for raw_item in self.finance_entity_ids_raw.replace(";", ",").split(","):
item = raw_item.strip()
if not item:
continue
try:
entity_ids.add(int(item))
except ValueError:
continue
return entity_ids


def get_config():
return Config()
27 changes: 27 additions & 0 deletions api/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,35 @@ def create_tables(self) -> None:
"""Create all database tables defined in models."""
logger.info("Creating database tables...")
BaseModel.metadata.create_all(bind=self.engine)
self._apply_schema_compatibility_fixes()
logger.info("Database tables created.")

def _apply_schema_compatibility_fixes(self) -> None:
"""Apply small schema fixes for deployments without migration tooling."""
if self.engine.dialect.name.lower() != "postgresql":
return
with self.engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE transactions "
"DROP CONSTRAINT IF EXISTS transactions_invoice_id_key"
)
)
existing_constraint = conn.execute(
text(
"SELECT 1 FROM pg_constraint "
"WHERE conname = 'fee_allocations_invoice_component_key'"
)
).fetchone()
if existing_constraint is None:
conn.execute(
text(
"ALTER TABLE fee_allocations "
"ADD CONSTRAINT fee_allocations_invoice_component_key "
"UNIQUE (invoice_id, component_key)"
)
)

def drop_tables(self) -> None:
"""Drop all database tables."""
logger.info("Dropping database tables...")
Expand Down
25 changes: 25 additions & 0 deletions api/app/dependencies/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def __init__(self, db: Session, config: Config):
self._pos_service = None
self._currency_exchange_service = None
self._fee_service = None
self._fee_allocation_service = None
self._stats_service = None
self._token_service = None
self._notification_service = None
Expand Down Expand Up @@ -76,6 +77,7 @@ def transaction_service(self):
@property
def invoice_service(self):
self._ensure_invoice_transaction_services()
self._ensure_fee_allocation_service()
return self._invoice_service

def _ensure_invoice_transaction_services(self) -> None:
Expand All @@ -100,6 +102,20 @@ def _ensure_invoice_transaction_services(self) -> None:
)
self._transaction_service.set_invoice_service(self._invoice_service)

def _ensure_fee_allocation_service(self) -> None:
self._ensure_invoice_transaction_services()
if self._fee_allocation_service is None:
from app.services.fee_allocation import FeeAllocationService

self._fee_allocation_service = FeeAllocationService(
db=self.db,
config=self.config,
transaction_service=self._transaction_service,
invoice_service=self._invoice_service,
notification_service=self.notification_service,
)
self._invoice_service.set_fee_allocation_service(self._fee_allocation_service)

@property
def split_service(self):
if self._split_service is None:
Expand Down Expand Up @@ -199,6 +215,11 @@ def fee_service(self):
)
return self._fee_service

@property
def fee_allocation_service(self):
self._ensure_fee_allocation_service()
return self._fee_allocation_service

@property
def stats_service(self):
if self._stats_service is None:
Expand Down Expand Up @@ -302,6 +323,10 @@ def get_fee_service(container: ServiceContainer = Depends(get_container)):
return container.fee_service


def get_fee_allocation_service(container: ServiceContainer = Depends(get_container)):
return container.fee_allocation_service


def get_stats_service(container: ServiceContainer = Depends(get_container)):
return container.stats_service

Expand Down
35 changes: 35 additions & 0 deletions api/app/errors/fee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Fee allocation errors."""

from app.errors.base import ApplicationError


class FeeAllocationNotFound(ApplicationError):
error_code = 2404
error = "Fee allocation not found"


class FeeAllocationSelectionForbidden(ApplicationError):
http_code = 403
error_code = 2403
error = "Fee allocation selection is not allowed"


class FeeAllocationAlreadySettled(ApplicationError):
error_code = 2409
error = "Fee allocation was already settled"


class FeeAllocationTargetInvalid(ApplicationError):
error_code = 2410
error = "Fee allocation target is invalid"


class FeeRuleNotFound(ApplicationError):
error_code = 2411
error = "Fee rule not found"


class FeePolicyForbidden(ApplicationError):
http_code = 403
error_code = 2412
error = "Fee policy access is not allowed"
6 changes: 4 additions & 2 deletions api/app/models/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Base for all ORM models"""

from datetime import datetime
from typing import Optional
from typing import ClassVar, Optional

from sqlalchemy import DateTime, Integer
from sqlalchemy.inspection import inspect
Expand All @@ -17,7 +17,9 @@ class BaseModel(Base):
# do not create separate table for this class
__abstract__ = True
# force AUTOINCREMENT statement for sqlite, as this dialect omits it by default, but we do need sqlite_sequence table for correct seeding.
__table_args__ = {"sqlite_autoincrement": True}
__table_args__: ClassVar[dict[str, bool] | tuple[object, ...]] = {
"sqlite_autoincrement": True
}

# everything should have an id and a comment
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
Expand Down
Loading
Loading