diff --git a/api/Dockerfile b/api/Dockerfile index acd5d34..45a63be 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -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 diff --git a/api/app/app.py b/api/app/app.py index 4a4d71f..3ce6908 100644 --- a/api/app/app.py +++ b/api/app/app.py @@ -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 @@ -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: @@ -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: diff --git a/api/app/config.py b/api/app/config.py index e190cd5..3c2ae63 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -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"}, ] @@ -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: @@ -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: @@ -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() diff --git a/api/app/db.py b/api/app/db.py index 11ebdf0..dce8a5c 100644 --- a/api/app/db.py +++ b/api/app/db.py @@ -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...") diff --git a/api/app/dependencies/services.py b/api/app/dependencies/services.py index 6f323e5..76ee7b3 100644 --- a/api/app/dependencies/services.py +++ b/api/app/dependencies/services.py @@ -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 @@ -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: @@ -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: @@ -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: @@ -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 diff --git a/api/app/errors/fee.py b/api/app/errors/fee.py new file mode 100644 index 0000000..065fb8a --- /dev/null +++ b/api/app/errors/fee.py @@ -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" diff --git a/api/app/models/base.py b/api/app/models/base.py index c323113..d536d2b 100644 --- a/api/app/models/base.py +++ b/api/app/models/base.py @@ -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 @@ -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) diff --git a/api/app/models/fee.py b/api/app/models/fee.py new file mode 100644 index 0000000..83500ee --- /dev/null +++ b/api/app/models/fee.py @@ -0,0 +1,93 @@ +"""Models for monthly fee policy and allocations.""" + +import enum +from datetime import datetime +from typing import TYPE_CHECKING, ClassVar + +from app.models.base import BaseModel +from app.models.entity import Entity +from app.models.invoice import Invoice +from app.models.split import Split +from app.models.transaction import Transaction +from sqlalchemy import JSON, DateTime, Enum, ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + pass + + +class FeePolicyOverrideKind(enum.Enum): + LEGACY = "legacy" + + +class FeeTargetType(enum.Enum): + ENTITY = "entity" + SPLIT = "split" + + +class FeePolicyOverride(BaseModel): + __tablename__ = "fee_policy_overrides" + + entity_id: Mapped[int] = mapped_column( + ForeignKey("entities.id"), nullable=False, unique=True + ) + entity: Mapped[Entity] = relationship(foreign_keys=[entity_id]) + kind: Mapped[FeePolicyOverrideKind] = mapped_column( + Enum( + FeePolicyOverrideKind, + values_callable=lambda enum_cls: [item.value for item in enum_cls], + name="fee_policy_override_kind", + ), + nullable=False, + ) + active: Mapped[bool] = mapped_column(default=True, nullable=False) + + +class FeeAllocation(BaseModel): + __tablename__ = "fee_allocations" + __table_args__: ClassVar[tuple[object, ...]] = ( + UniqueConstraint( + "invoice_id", + "component_key", + name="fee_allocations_invoice_component_key", + ), + {"sqlite_autoincrement": True}, + ) + + invoice_id: Mapped[int] = mapped_column(ForeignKey("invoices.id"), nullable=False) + invoice: Mapped[Invoice] = relationship(foreign_keys=[invoice_id]) + component_key: Mapped[str] = mapped_column(String(64), nullable=False) + amounts: Mapped[dict[str, str]] = mapped_column(JSON, nullable=False) + extra_amounts: Mapped[dict[str, str] | None] = mapped_column(JSON, nullable=True) + + target_type: Mapped[FeeTargetType | None] = mapped_column( + Enum( + FeeTargetType, + values_callable=lambda enum_cls: [item.value for item in enum_cls], + name="fee_target_type", + ), + nullable=True, + ) + target_entity_id: Mapped[int | None] = mapped_column( + ForeignKey("entities.id"), nullable=True + ) + target_entity: Mapped[Entity | None] = relationship(foreign_keys=[target_entity_id]) + target_split_id: Mapped[int | None] = mapped_column( + ForeignKey("splits.id"), nullable=True + ) + target_split: Mapped[Split | None] = relationship(foreign_keys=[target_split_id]) + selected_by_entity_id: Mapped[int | None] = mapped_column( + ForeignKey("entities.id"), nullable=True + ) + selected_by_entity: Mapped[Entity | None] = relationship( + foreign_keys=[selected_by_entity_id] + ) + selected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + auto_selected: Mapped[bool] = mapped_column(default=False, nullable=False) + selection_deadline_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + allocation_transaction_id: Mapped[int | None] = mapped_column( + ForeignKey("transactions.id"), nullable=True + ) + allocation_transaction: Mapped[Transaction | None] = relationship( + foreign_keys=[allocation_transaction_id] + ) diff --git a/api/app/models/invoice.py b/api/app/models/invoice.py index 8a53643..b842274 100644 --- a/api/app/models/invoice.py +++ b/api/app/models/invoice.py @@ -61,12 +61,16 @@ class Invoice(BaseModel): tags: Mapped[list[Tag]] = relationship(secondary=invoices_tags) - transaction: Mapped["Transaction | None"] = relationship( - "Transaction", back_populates="invoice", uselist=False + transactions: Mapped[list["Transaction"]] = relationship( + "Transaction", back_populates="invoice", order_by="Transaction.id" ) @property def transaction_id(self) -> int | None: - if self.transaction is None: + if not self.transactions: return None - return self.transaction.id + return self.transactions[0].id + + @property + def transaction_ids(self) -> list[int]: + return [transaction.id for transaction in self.transactions] diff --git a/api/app/models/transaction.py b/api/app/models/transaction.py index cba9010..92831c7 100644 --- a/api/app/models/transaction.py +++ b/api/app/models/transaction.py @@ -44,10 +44,10 @@ class Transaction(BaseModel): to_entity: Mapped[Entity] = relationship(foreign_keys=[to_entity_id]) invoice_id: Mapped[int | None] = mapped_column( - ForeignKey("invoices.id"), nullable=True, unique=True + ForeignKey("invoices.id"), nullable=True ) invoice: Mapped["Invoice | None"] = relationship( - "Invoice", back_populates="transaction" + "Invoice", back_populates="transactions" ) amount: Mapped[Decimal] = mapped_column(DECIMAL(scale=2), nullable=False) diff --git a/api/app/routes/fee.py b/api/app/routes/fee.py index d6488a7..51f029b 100644 --- a/api/app/routes/fee.py +++ b/api/app/routes/fee.py @@ -1,14 +1,24 @@ """Fee routes""" -from app.dependencies.services import get_fee_service +from app.dependencies.services import get_fee_allocation_service, get_fee_service from app.middlewares.token import get_entity_from_token from app.models.entity import Entity from app.schemas.fee import ( + FeeAllocationSelectionSchema, FeeAmountSchema, + FeeConfigSchema, + FeeDirectedAllocationUpdateSchema, FeeFiltersSchema, + FeeInvoiceBulkCreateReportSchema, + FeeInvoiceBulkCreateSchema, + FeeInvoiceSettlementCreateSchema, + FeePolicyOverrideSchema, + FeePolicyOverrideUpdateSchema, FeeSchema, ) +from app.schemas.transaction import TransactionSchema from app.services.fee import FeeService +from app.services.fee_allocation import FeeAllocationService from fastapi import APIRouter, Depends router = APIRouter(prefix="/fees", tags=["Fees"]) @@ -22,9 +32,97 @@ def get_fees( return service.get_fees(filters) -@router.get("/config", response_model=list[FeeAmountSchema]) +@router.get("/amounts", response_model=list[FeeAmountSchema]) def get_fee_config( service: FeeService = Depends(get_fee_service), actor_entity: Entity = Depends(get_entity_from_token), ): return service.get_fee_amounts() + + +@router.get("/config", response_model=FeeConfigSchema) +def get_directed_fee_config( + service: FeeAllocationService = Depends(get_fee_allocation_service), + actor_entity: Entity = Depends(get_entity_from_token), +): + return service.get_config() + + +@router.post("/invoices/bulk", response_model=FeeInvoiceBulkCreateReportSchema) +def bulk_create_fee_invoices( + payload: FeeInvoiceBulkCreateSchema, + service: FeeAllocationService = Depends(get_fee_allocation_service), + actor_entity: Entity = Depends(get_entity_from_token), +): + return service.create_fee_invoices(payload, actor_entity) + + +@router.get( + "/invoices/{invoice_id}/directed-allocation", + response_model=FeeAllocationSelectionSchema, +) +def get_directed_allocation( + invoice_id: int, + service: FeeAllocationService = Depends(get_fee_allocation_service), + actor_entity: Entity = Depends(get_entity_from_token), +): + return service.get_selection(invoice_id, actor_entity) + + +@router.patch( + "/invoices/{invoice_id}/directed-allocation", + response_model=FeeAllocationSelectionSchema, +) +def update_directed_allocation( + invoice_id: int, + payload: FeeDirectedAllocationUpdateSchema, + service: FeeAllocationService = Depends(get_fee_allocation_service), + actor_entity: Entity = Depends(get_entity_from_token), +): + return service.update_directed_allocation(invoice_id, payload, actor_entity) + + +@router.post( + "/invoices/{invoice_id}/settlement", + response_model=list[TransactionSchema], +) +def settle_fee_invoice( + invoice_id: int, + payload: FeeInvoiceSettlementCreateSchema, + service: FeeAllocationService = Depends(get_fee_allocation_service), + actor_entity: Entity = Depends(get_entity_from_token), +): + return service.settle_fee_invoice( + invoice_id, + payload.currency, + actor_entity, + status=payload.status, + ) + + +@router.get("/policies/{entity_id}", response_model=FeePolicyOverrideSchema | None) +def get_fee_policy( + entity_id: int, + service: FeeAllocationService = Depends(get_fee_allocation_service), + actor_entity: Entity = Depends(get_entity_from_token), +): + return service.get_policy(entity_id, actor_entity) + + +@router.put("/policies/{entity_id}", response_model=FeePolicyOverrideSchema) +def upsert_fee_policy( + entity_id: int, + payload: FeePolicyOverrideUpdateSchema, + service: FeeAllocationService = Depends(get_fee_allocation_service), + actor_entity: Entity = Depends(get_entity_from_token), +): + return service.upsert_policy(entity_id, payload, actor_entity) + + +@router.delete("/policies/{entity_id}") +def delete_fee_policy( + entity_id: int, + service: FeeAllocationService = Depends(get_fee_allocation_service), + actor_entity: Entity = Depends(get_entity_from_token), +) -> int: + return service.delete_policy(entity_id, actor_entity) diff --git a/api/app/routes/tasks.py b/api/app/routes/tasks.py index a012c69..dfeb94b 100644 --- a/api/app/routes/tasks.py +++ b/api/app/routes/tasks.py @@ -1,8 +1,12 @@ """API routes for manually triggering background tasks.""" +from datetime import datetime + +from app.dependencies.services import get_fee_allocation_service from app.middlewares.token import get_entity_from_token from app.models.entity import Entity from app.schemas.base import BaseSchema +from app.services.fee_allocation import FeeAllocationService from app.tasks.auto_exchange import AutoExchangeTask from app.tasks.balance_reminder import BalanceReminderTask from app.tasks.invoice_auto_pay import InvoiceAutoPayTask @@ -35,3 +39,15 @@ def run_keepz_poll(_actor: Entity = Depends(get_entity_from_token)): @tasks_router.post("/balance-reminder/run", response_model=TaskRunResponse) def run_balance_reminder(_actor: Entity = Depends(get_entity_from_token)): return TaskRunResponse(task="balance-reminder", result=BalanceReminderTask().run()) + + +@tasks_router.post("/fee-allocation-selection/run", response_model=TaskRunResponse) +def run_fee_allocation_selection( + now: datetime | None = None, + service: FeeAllocationService = Depends(get_fee_allocation_service), + _actor: Entity = Depends(get_entity_from_token), +): + return TaskRunResponse( + task="fee-allocation-selection", + result=service.auto_select_expired_allocations(now), + ) diff --git a/api/app/schemas/fee.py b/api/app/schemas/fee.py index 0c26796..1ff6f68 100644 --- a/api/app/schemas/fee.py +++ b/api/app/schemas/fee.py @@ -1,7 +1,13 @@ """DTOs for fees""" +from datetime import date, datetime +from decimal import Decimal + +from app.models.fee import FeePolicyOverrideKind, FeeTargetType +from app.models.transaction import TransactionStatus from app.schemas.base import BaseSchema, CurrencyDecimal from app.schemas.entity import EntitySchema +from pydantic import Field, field_validator class MonthlyFeeSchema(BaseSchema): @@ -26,3 +32,100 @@ class FeeAmountSchema(BaseSchema): tag_id: int currency: str amount: CurrencyDecimal + + +class FeeRuleSchema(BaseSchema): + membership_tag_id: int + label: str + invoice_amounts: dict[str, CurrencyDecimal] + legacy_invoice_amounts: dict[str, CurrencyDecimal] + directed_amounts: dict[str, CurrencyDecimal] + + +class FeeTargetSchema(BaseSchema): + target_type: FeeTargetType + id: int + name: str + currency: str | None = None + + +class FeeConfigSchema(BaseSchema): + rules: list[FeeRuleSchema] + budget_targets: list[FeeTargetSchema] + split_targets: list[FeeTargetSchema] + selection_deadline_days: int + + +class FeeAllocationSchema(BaseSchema): + id: int + invoice_id: int + component_key: str + amounts: dict[str, CurrencyDecimal] + extra_amounts: dict[str, CurrencyDecimal] | None = None + target_type: FeeTargetType | None = None + target_entity_id: int | None = None + target_split_id: int | None = None + selected_by_entity_id: int | None = None + selected_at: datetime | None = None + auto_selected: bool + selection_deadline_at: datetime + allocation_transaction_id: int | None = None + + +class FeeAllocationSelectionSchema(BaseSchema): + has_allocation: bool + invoice_id: int + directed_allocation: FeeAllocationSchema | None = None + fixed_allocations: list[FeeAllocationSchema] = Field(default_factory=list) + budget_targets: list[FeeTargetSchema] = Field(default_factory=list) + split_targets: list[FeeTargetSchema] = Field(default_factory=list) + selected_target_name: str | None = None + + +class FeeDirectedAllocationUpdateSchema(BaseSchema): + target_type: FeeTargetType + target_entity_id: int | None = None + target_split_id: int | None = None + extra_amount: Decimal | None = None + extra_currency: str | None = None + + +class FeeInvoiceBulkCreateSchema(BaseSchema): + from_tag_ids: list[int] = Field(default_factory=list) + billing_period: date | None = None + notify: bool = True + + +class FeeInvoiceBulkCreateReportSchema(BaseSchema): + billing_period: date + created_count: int + skipped_count: int + legacy_count: int + invoice_ids: list[int] + notification_count: int + + +class FeeInvoiceSettlementCreateSchema(BaseSchema): + currency: str + status: TransactionStatus = TransactionStatus.DRAFT + + @field_validator("currency") + def currency_must_be_lowercase(cls, value: str) -> str: + return value.lower() + + +class FeeInvoiceSettlementSchema(BaseSchema): + transaction_ids: list[int] + status: TransactionStatus + + +class FeePolicyOverrideSchema(BaseSchema): + id: int + entity_id: int + kind: FeePolicyOverrideKind + active: bool + + +class FeePolicyOverrideUpdateSchema(BaseSchema): + kind: FeePolicyOverrideKind = FeePolicyOverrideKind.LEGACY + active: bool = True diff --git a/api/app/schemas/invoice.py b/api/app/schemas/invoice.py index ea6340b..d5d7f60 100644 --- a/api/app/schemas/invoice.py +++ b/api/app/schemas/invoice.py @@ -49,6 +49,7 @@ class InvoiceSchema(BaseReadSchema): status: InvoiceStatus tags: list[TagSchema] transaction_id: int | None = None + transaction_ids: list[int] = Field(default_factory=list) class InvoiceCreateSchema(BaseUpdateSchema): diff --git a/api/app/seeding.py b/api/app/seeding.py index 802cafe..9206161 100644 --- a/api/app/seeding.py +++ b/api/app/seeding.py @@ -1,5 +1,3 @@ -import random -from decimal import Decimal from typing import Type from app.models.base import BaseModel @@ -27,6 +25,21 @@ ) fee_tag = Tag(id=3, name="fee", comment="monthly resident's fee") automatic_tag = Tag(id=17, name="automatic", comment="automatically generated / paid") +fee_budget_target_tag = Tag( + id=19, + name="fee-budget-target", + comment="target for directed monthly fee budget allocations", +) +fee_allocation_tag = Tag( + id=20, + name="fee-allocation", + comment="monthly fee settlement component transaction", +) +crowdfunding_target_tag = Tag( + id=21, + name="crowdfunding-target", + comment="split that can receive directed monthly fee contributions", +) # commonly used treasuries cash_treasury = Treasury(id=1, name="cash") usdt_erc20_treasury = Treasury(id=51, name="usdt/erc20") @@ -61,6 +74,66 @@ comment="keepz.me deposit provider", tags=[deposit_tag], ) +safety_cushion_entity = Entity( + id=60, + name="safety-cushion", + comment="monthly fee safety cushion budget", + tags=[fee_budget_target_tag], +) +common_consumables_entity = Entity( + id=61, + name="common-consumables", + comment="monthly fee common consumables and cleaning budget", + tags=[fee_budget_target_tag], +) +general_purchase_fund_entity = Entity( + id=62, + name="general-purchase-fund", + comment="default directed monthly fee target", + tags=[fee_budget_target_tag], +) +open_space_entity = Entity( + id=63, + name="open-space", + comment="open space directed monthly fee budget", + tags=[fee_budget_target_tag], +) +studio_entity = Entity( + id=64, + name="studio", + comment="studio directed monthly fee budget", + tags=[fee_budget_target_tag], +) +lab_entity = Entity( + id=65, + name="lab", + comment="lab directed monthly fee budget", + tags=[fee_budget_target_tag], +) +residents_room_entity = Entity( + id=66, + name="residents-room", + comment="residents room directed monthly fee budget", + tags=[fee_budget_target_tag], +) +basement_entity = Entity( + id=67, + name="basement", + comment="basement directed monthly fee budget", + tags=[fee_budget_target_tag], +) +chill_zone_entity = Entity( + id=68, + name="chill-zone", + comment="chill zone directed monthly fee budget", + tags=[fee_budget_target_tag], +) +bathroom_entity = Entity( + id=69, + name="bathroom", + comment="bathroom directed monthly fee budget", + tags=[fee_budget_target_tag], +) SEEDING: dict[Type[BaseModel], list[BaseModel]] = { Tag: [ @@ -84,6 +157,9 @@ guest_tag, pos_tag, automatic_tag, + fee_budget_target_tag, + fee_allocation_tag, + crowdfunding_target_tag, ], Entity: [ # hackerspace @@ -126,6 +202,17 @@ # payment providers cryptapi_deposit_provider, keepz_deposit_provider, + # directed monthly fee budget targets + safety_cushion_entity, + common_consumables_entity, + general_purchase_fund_entity, + open_space_entity, + studio_entity, + lab_entity, + residents_room_entity, + basement_entity, + chill_zone_entity, + bathroom_entity, # residents # # Entity( diff --git a/api/app/services/entity.py b/api/app/services/entity.py index 2c4b2c7..f62b460 100644 --- a/api/app/services/entity.py +++ b/api/app/services/entity.py @@ -47,7 +47,10 @@ def _apply_filters( query = query.filter(self.model.active == filters.active) if filters.auth_telegram_id is not None: query = query.filter( - cast(func.nullif(self.model.auth.op("->>")("telegram_id"), ""), BigInteger) + cast( + func.nullif(self.model.auth.op("->>")("telegram_id"), ""), + BigInteger, + ) == filters.auth_telegram_id ) if filters.tags_ids: @@ -91,7 +94,11 @@ def get_by_telegram_id(self, telegram_id: int) -> Entity: db_obj = ( self.db.query(self.model) .filter( - cast(func.nullif(self.model.auth.op("->>")("telegram_id"), ""), BigInteger) == telegram_id + cast( + func.nullif(self.model.auth.op("->>")("telegram_id"), ""), + BigInteger, + ) + == telegram_id ) .first() ) diff --git a/api/app/services/fee.py b/api/app/services/fee.py index ebc762c..3c0ba7c 100644 --- a/api/app/services/fee.py +++ b/api/app/services/fee.py @@ -268,7 +268,7 @@ def add_months(base: date, months_forward: int) -> date: invoices_tags.c.tag_id == fee_tag.id, ), ) - .options(selectinload(Invoice.transaction)) + .options(selectinload(Invoice.transactions)) .filter( Invoice.to_entity_id == hackerspace.id, Invoice.billing_period.isnot(None), @@ -281,9 +281,9 @@ def add_months(base: date, months_forward: int) -> date: # Process transactions into a nested dictionary # {resident_id: {(year, month): {currency: amount}}} - fees_by_resident_by_month = defaultdict( - lambda: defaultdict(lambda: defaultdict(Decimal)) - ) + fees_by_resident_by_month: defaultdict[ + int, defaultdict[tuple[int, int], defaultdict[str, Decimal]] + ] = defaultdict(lambda: defaultdict(lambda: defaultdict(Decimal))) # Track unpaid invoices # {resident_id: {(year, month): invoice_id}} unpaid_invoice_by_resident_by_month: dict[int, dict[tuple[int, int], int]] = ( @@ -316,8 +316,12 @@ def add_months(base: date, months_forward: int) -> date: continue if invoice.status != InvoiceStatus.PAID: continue - tx = invoice.transaction - if tx is None or tx.status != TransactionStatus.COMPLETED: + completed_transactions = [ + transaction + for transaction in invoice.transactions + if transaction.status == TransactionStatus.COMPLETED + ] + if not completed_transactions: continue current_paid = paid_invoice_by_resident_by_month[ invoice.from_entity_id @@ -326,9 +330,10 @@ def add_months(base: date, months_forward: int) -> date: paid_invoice_by_resident_by_month[invoice.from_entity_id][ (year, month) ] = invoice.id - fees_by_resident_by_month[invoice.from_entity_id][(year, month)][ - tx.currency.lower() - ] += tx.amount + for transaction in completed_transactions: + fees_by_resident_by_month[invoice.from_entity_id][(year, month)][ + transaction.currency.lower() + ] += transaction.amount # Build the final response structure results: list[FeeRecord] = [] diff --git a/api/app/services/fee_allocation.py b/api/app/services/fee_allocation.py new file mode 100644 index 0000000..327b130 --- /dev/null +++ b/api/app/services/fee_allocation.py @@ -0,0 +1,1057 @@ +"""Service for directed monthly fee invoices and allocations.""" + +import datetime +import json +import random +from dataclasses import dataclass +from decimal import Decimal +from typing import TYPE_CHECKING + +from app.config import ( + DEFAULT_GENERAL_PURCHASE_FUND_ENTITY_ID, + Config, + get_config, +) +from app.dependencies.services import ( + get_invoice_service, + get_notification_service, + get_transaction_service, +) +from app.errors.fee import ( + FeeAllocationAlreadySettled, + FeeAllocationNotFound, + FeeAllocationSelectionForbidden, + FeeAllocationTargetInvalid, + FeePolicyForbidden, + FeeRuleNotFound, +) +from app.models.entity import Entity +from app.models.fee import ( + FeeAllocation, + FeePolicyOverride, + FeePolicyOverrideKind, + FeeTargetType, +) +from app.models.invoice import Invoice, InvoiceStatus +from app.models.split import Split +from app.models.tag import Tag +from app.models.transaction import Transaction, TransactionStatus +from app.schemas.base import CurrencyDecimal +from app.schemas.fee import ( + FeeAllocationSchema, + FeeAllocationSelectionSchema, + FeeConfigSchema, + FeeDirectedAllocationUpdateSchema, + FeeInvoiceBulkCreateReportSchema, + FeeInvoiceBulkCreateSchema, + FeePolicyOverrideSchema, + FeePolicyOverrideUpdateSchema, + FeeRuleSchema, + FeeTargetSchema, +) +from app.schemas.invoice import InvoiceAmountCreateSchema, InvoiceCreateSchema +from app.schemas.transaction import TransactionCreateSchema, TransactionUpdateSchema +from app.seeding import ( + automatic_tag, + crowdfunding_target_tag, + f0_entity, + fee_allocation_tag, + fee_budget_target_tag, + fee_tag, +) +from app.services.notification import NotificationService +from app.services.transaction import TransactionService +from app.uow import get_uow +from fastapi import Depends +from sqlalchemy import or_ +from sqlalchemy.orm import Session, selectinload + +if TYPE_CHECKING: + from app.services.invoice import InvoiceService + + +@dataclass(frozen=True, slots=True) +class FeeRule: + membership_tag_id: int + label: str + invoice_amounts: dict[str, Decimal] + legacy_invoice_amounts: dict[str, Decimal] + directed_amounts: dict[str, Decimal] + fixed_allocations: list[dict[str, object]] + default_directed_target_entity_id: int + + +@dataclass(frozen=True, slots=True) +class FeeTarget: + target_type: FeeTargetType + id: int + name: str + currency: str | None = None + + def to_schema(self) -> FeeTargetSchema: + return FeeTargetSchema( + target_type=self.target_type, + id=self.id, + name=self.name, + currency=self.currency, + ) + + +class FeeAllocationService: + def __init__( + self, + db: Session = Depends(get_uow), + config: Config = Depends(get_config), + transaction_service: TransactionService = Depends(get_transaction_service), + invoice_service: "InvoiceService" = Depends(get_invoice_service), + notification_service: NotificationService = Depends(get_notification_service), + ): + self.db = db + self.config = config + self._transaction_service = transaction_service + self._invoice_service = invoice_service + self._notification_service = notification_service + + @staticmethod + def _decimal_map(raw_amounts: dict[str, object]) -> dict[str, Decimal]: + normalized: dict[str, Decimal] = {} + for currency, amount in raw_amounts.items(): + normalized[str(currency).lower()] = Decimal(str(amount)).quantize( + Decimal("0.01") + ) + return normalized + + @staticmethod + def _serialize_amounts(amounts: dict[str, Decimal]) -> dict[str, str]: + return { + currency.lower(): format(amount.quantize(Decimal("0.01")), "f") + for currency, amount in amounts.items() + } + + @staticmethod + def _amounts_to_invoice_payload( + amounts: dict[str, Decimal], + ) -> list[InvoiceAmountCreateSchema]: + return [ + InvoiceAmountCreateSchema(currency=currency, amount=amount) + for currency, amount in sorted(amounts.items()) + ] + + def _rules(self) -> list[FeeRule]: + rules: list[FeeRule] = [] + for raw_rule in self.config.fee_rules: + membership_tag_id = int(str(raw_rule["membership_tag_id"])) + raw_invoice_amounts = raw_rule.get("invoice_amounts", {}) + raw_legacy_amounts = raw_rule.get("legacy_invoice_amounts", {}) + raw_directed_amounts = raw_rule.get("directed_amounts", {}) + raw_fixed_allocations = raw_rule.get("fixed_allocations", []) + invoice_amounts = ( + self._decimal_map(raw_invoice_amounts) + if isinstance(raw_invoice_amounts, dict) + else {} + ) + legacy_invoice_amounts = ( + self._decimal_map(raw_legacy_amounts) + if isinstance(raw_legacy_amounts, dict) + else {} + ) + directed_amounts = ( + self._decimal_map(raw_directed_amounts) + if isinstance(raw_directed_amounts, dict) + else {} + ) + fixed_allocations = ( + raw_fixed_allocations if isinstance(raw_fixed_allocations, list) else [] + ) + rules.append( + FeeRule( + membership_tag_id=membership_tag_id, + label=str(raw_rule["label"]), + invoice_amounts=invoice_amounts, + legacy_invoice_amounts=legacy_invoice_amounts, + directed_amounts=directed_amounts, + fixed_allocations=fixed_allocations, + default_directed_target_entity_id=int( + str( + raw_rule.get( + "default_directed_target_entity_id", + DEFAULT_GENERAL_PURCHASE_FUND_ENTITY_ID, + ) + ) + ), + ) + ) + return rules + + def _rule_for_tag_id(self, tag_id: int) -> FeeRule: + for rule in self._rules(): + if rule.membership_tag_id == tag_id: + return rule + raise FeeRuleNotFound(tag_id) + + def _rule_for_entity( + self, entity: Entity, allowed_tag_ids: set[int] + ) -> FeeRule | None: + entity_tag_ids = {tag.id for tag in entity.tags} + for rule in self._rules(): + if ( + rule.membership_tag_id in allowed_tag_ids + and rule.membership_tag_id in entity_tag_ids + ): + return rule + return None + + def _is_finance_actor(self, actor_entity: Entity) -> bool: + return ( + actor_entity.id == f0_entity.id + or actor_entity.id in self.config.finance_entity_ids + ) + + def _assert_finance_actor(self, actor_entity: Entity) -> None: + if not self._is_finance_actor(actor_entity): + raise FeePolicyForbidden + + def _assert_selection_access(self, invoice: Invoice, actor_entity: Entity) -> None: + if invoice.from_entity_id == actor_entity.id or self._is_finance_actor( + actor_entity + ): + return + raise FeeAllocationSelectionForbidden + + @staticmethod + def _normalize_billing_period(value: datetime.date | None) -> datetime.date: + if value is None: + today = datetime.date.today() + return datetime.date(today.year, today.month, 1) + return datetime.date(value.year, value.month, 1) + + def _legacy_override_for_entity(self, entity_id: int) -> FeePolicyOverride | None: + return ( + self.db.query(FeePolicyOverride) + .filter( + FeePolicyOverride.entity_id == entity_id, + FeePolicyOverride.kind == FeePolicyOverrideKind.LEGACY, + FeePolicyOverride.active.is_(True), + ) + .first() + ) + + def get_config(self) -> FeeConfigSchema: + return FeeConfigSchema( + rules=[ + FeeRuleSchema( + membership_tag_id=rule.membership_tag_id, + label=rule.label, + invoice_amounts={ + currency: CurrencyDecimal(amount) + for currency, amount in rule.invoice_amounts.items() + }, + legacy_invoice_amounts={ + currency: CurrencyDecimal(amount) + for currency, amount in rule.legacy_invoice_amounts.items() + }, + directed_amounts={ + currency: CurrencyDecimal(amount) + for currency, amount in rule.directed_amounts.items() + }, + ) + for rule in self._rules() + ], + budget_targets=[ + target.to_schema() for target in self.list_budget_targets() + ], + split_targets=[ + target.to_schema() for target in self.list_eligible_split_targets(None) + ], + selection_deadline_days=self.config.fee_selection_deadline_days, + ) + + def list_budget_targets(self) -> list[FeeTarget]: + entities = ( + self.db.query(Entity) + .join(Entity.tags) + .filter(Tag.id == fee_budget_target_tag.id, Entity.active.is_(True)) + .order_by(Entity.name.asc()) + .all() + ) + return [ + FeeTarget(target_type=FeeTargetType.ENTITY, id=entity.id, name=entity.name) + for entity in entities + ] + + def list_eligible_split_targets(self, currency: str | None) -> list[FeeTarget]: + query = ( + self.db.query(Split) + .join(Split.tags) + .filter(Tag.id == crowdfunding_target_tag.id, Split.performed.is_(False)) + ) + if currency: + query = query.filter(Split.currency == currency.lower()) + splits = query.order_by(Split.id.desc()).all() + return [ + FeeTarget( + target_type=FeeTargetType.SPLIT, + id=split.id, + name=split.comment or f"split #{split.id}", + currency=split.currency, + ) + for split in splits + ] + + def _primary_invoice_currency(self, invoice: Invoice) -> str | None: + if invoice.transactions: + return invoice.transactions[0].currency.lower() + for amount in invoice.amounts or []: + currency = str(amount.get("currency", "")).lower() + if currency: + return currency + return None + + def _selection_deadline(self, invoice: Invoice) -> datetime.datetime: + created_at = invoice.created_at or datetime.datetime.now() + return created_at + datetime.timedelta( + days=self.config.fee_selection_deadline_days + ) + + def _base_amounts_for_rule(self, rule: FeeRule) -> dict[str, Decimal]: + allocated: dict[str, Decimal] = { + currency: Decimal("0.00") for currency in rule.invoice_amounts + } + for fixed in rule.fixed_allocations: + raw_amounts = fixed.get("amounts", {}) + if not isinstance(raw_amounts, dict): + continue + for currency, amount in self._decimal_map(raw_amounts).items(): + allocated[currency] = allocated.get(currency, Decimal("0.00")) + amount + for currency, amount in rule.directed_amounts.items(): + allocated[currency] = allocated.get(currency, Decimal("0.00")) + amount + + base_amounts: dict[str, Decimal] = {} + for currency, invoice_amount in rule.invoice_amounts.items(): + amount = ( + invoice_amount - allocated.get(currency, Decimal("0.00")) + ).quantize(Decimal("0.01")) + if amount > Decimal("0.00"): + base_amounts[currency] = amount + return base_amounts + + def _create_allocations_for_invoice(self, invoice: Invoice, rule: FeeRule) -> None: + deadline = self._selection_deadline(invoice) + base_amounts = self._base_amounts_for_rule(rule) + if base_amounts: + self.db.add( + FeeAllocation( + invoice_id=invoice.id, + component_key="base", + amounts=self._serialize_amounts(base_amounts), + target_type=FeeTargetType.ENTITY, + target_entity_id=f0_entity.id, + selected_at=invoice.created_at, + selection_deadline_at=deadline, + ) + ) + for fixed in rule.fixed_allocations: + raw_amounts = fixed.get("amounts", {}) + if not isinstance(raw_amounts, dict): + continue + amounts = self._decimal_map(raw_amounts) + target_entity_id = int(str(fixed["target_entity_id"])) + self.db.add( + FeeAllocation( + invoice_id=invoice.id, + component_key=str(fixed["component_key"]), + amounts=self._serialize_amounts(amounts), + target_type=FeeTargetType.ENTITY, + target_entity_id=target_entity_id, + selected_at=invoice.created_at, + selection_deadline_at=deadline, + ) + ) + if rule.directed_amounts: + self.db.add( + FeeAllocation( + invoice_id=invoice.id, + component_key="directed", + amounts=self._serialize_amounts(rule.directed_amounts), + target_type=None, + target_entity_id=None, + target_split_id=None, + selection_deadline_at=deadline, + ) + ) + self.db.flush() + + def create_fee_invoices( + self, + schema: FeeInvoiceBulkCreateSchema, + actor_entity: Entity, + ) -> FeeInvoiceBulkCreateReportSchema: + if not schema.from_tag_ids: + raise FeeRuleNotFound("from_tag_ids") + + billing_period = self._normalize_billing_period(schema.billing_period) + allowed_tag_ids = set(schema.from_tag_ids) + tags = self.db.query(Tag).filter(Tag.id.in_(schema.from_tag_ids)).all() + tag_filters = [Entity.tags.contains(tag) for tag in tags] + entity_ids: set[int] = set() + if tag_filters: + entities = ( + self.db.query(Entity) + .filter(or_(*tag_filters)) + .options(selectinload(Entity.tags)) + .all() + ) + for entity in entities: + entity_ids.add(entity.id) + + invoice_ids: list[int] = [] + created_count = 0 + skipped_count = 0 + legacy_count = 0 + notification_count = 0 + + for entity_id in sorted(entity_ids): + db_entity = ( + self.db.query(Entity) + .filter(Entity.id == entity_id) + .options(selectinload(Entity.tags)) + .first() + ) + if db_entity is None or not db_entity.active: + skipped_count += 1 + continue + rule = self._rule_for_entity(db_entity, allowed_tag_ids) + if rule is None: + skipped_count += 1 + continue + legacy = self._legacy_override_for_entity(db_entity.id) is not None + amounts = rule.legacy_invoice_amounts if legacy else rule.invoice_amounts + invoice = self._invoice_service.create( + InvoiceCreateSchema( + from_entity_id=db_entity.id, + to_entity_id=f0_entity.id, + amounts=self._amounts_to_invoice_payload(amounts), + billing_period=billing_period, + tag_ids=[fee_tag.id], + comment=f"{rule.label} monthly fee", + ), + overrides={ + "actor_entity_id": actor_entity.id, + "_skip_auto_pay": True, + }, + ) + invoice_ids.append(invoice.id) + created_count += 1 + if legacy: + legacy_count += 1 + if schema.notify and self._notify_legacy_invoice(invoice, db_entity): + notification_count += 1 + continue + self._create_allocations_for_invoice(invoice, rule) + directed = self._directed_allocation(invoice.id) + if ( + schema.notify + and directed is not None + and self._notify_fee_invoice(invoice, db_entity, directed) + ): + notification_count += 1 + + return FeeInvoiceBulkCreateReportSchema( + billing_period=billing_period, + created_count=created_count, + skipped_count=skipped_count, + legacy_count=legacy_count, + invoice_ids=invoice_ids, + notification_count=notification_count, + ) + + def _allocations_for_invoice(self, invoice_id: int) -> list[FeeAllocation]: + return ( + self.db.query(FeeAllocation) + .filter(FeeAllocation.invoice_id == invoice_id) + .order_by(FeeAllocation.id.asc()) + .all() + ) + + def _directed_allocation(self, invoice_id: int) -> FeeAllocation | None: + return ( + self.db.query(FeeAllocation) + .filter( + FeeAllocation.invoice_id == invoice_id, + FeeAllocation.component_key == "directed", + ) + .first() + ) + + def _invoice_has_settlement(self, invoice_id: int) -> bool: + return ( + self.db.query(FeeAllocation) + .filter( + FeeAllocation.invoice_id == invoice_id, + FeeAllocation.allocation_transaction_id.isnot(None), + ) + .first() + is not None + ) + + def _allocation_to_schema(self, allocation: FeeAllocation) -> FeeAllocationSchema: + return FeeAllocationSchema( + id=allocation.id, + invoice_id=allocation.invoice_id, + component_key=allocation.component_key, + amounts={ + currency: CurrencyDecimal(Decimal(str(amount))) + for currency, amount in (allocation.amounts or {}).items() + }, + extra_amounts=( + { + currency: CurrencyDecimal(Decimal(str(amount))) + for currency, amount in allocation.extra_amounts.items() + } + if allocation.extra_amounts + else None + ), + target_type=allocation.target_type, + target_entity_id=allocation.target_entity_id, + target_split_id=allocation.target_split_id, + selected_by_entity_id=allocation.selected_by_entity_id, + selected_at=allocation.selected_at, + auto_selected=allocation.auto_selected, + selection_deadline_at=allocation.selection_deadline_at, + allocation_transaction_id=allocation.allocation_transaction_id, + ) + + def _target_name(self, allocation: FeeAllocation | None) -> str | None: + if allocation is None or allocation.target_type is None: + return None + if ( + allocation.target_type == FeeTargetType.ENTITY + and allocation.target_entity_id + ): + entity = ( + self.db.query(Entity) + .filter(Entity.id == allocation.target_entity_id) + .first() + ) + return entity.name if entity else None + if allocation.target_type == FeeTargetType.SPLIT and allocation.target_split_id: + split = ( + self.db.query(Split) + .filter(Split.id == allocation.target_split_id) + .first() + ) + if split is None: + return None + return split.comment or f"split #{split.id}" + return None + + def get_selection( + self, + invoice_id: int, + actor_entity: Entity, + ) -> FeeAllocationSelectionSchema: + invoice = self.db.query(Invoice).filter(Invoice.id == invoice_id).first() + if invoice is None: + raise FeeAllocationNotFound(invoice_id) + self._assert_selection_access(invoice, actor_entity) + allocations = self._allocations_for_invoice(invoice_id) + directed = next( + ( + allocation + for allocation in allocations + if allocation.component_key == "directed" + ), + None, + ) + currency = self._primary_invoice_currency(invoice) + return FeeAllocationSelectionSchema( + has_allocation=directed is not None, + invoice_id=invoice_id, + directed_allocation=( + self._allocation_to_schema(directed) if directed is not None else None + ), + fixed_allocations=[ + self._allocation_to_schema(allocation) + for allocation in allocations + if allocation.component_key != "directed" + ], + budget_targets=[ + target.to_schema() for target in self.list_budget_targets() + ], + split_targets=[ + target.to_schema() + for target in self.list_eligible_split_targets(currency) + ], + selected_target_name=self._target_name(directed), + ) + + def _validate_target( + self, + schema: FeeDirectedAllocationUpdateSchema, + currency: str | None, + ) -> FeeTarget: + if schema.target_type == FeeTargetType.ENTITY: + if schema.target_entity_id is None: + raise FeeAllocationTargetInvalid("target_entity_id") + target = ( + self.db.query(Entity) + .join(Entity.tags) + .filter( + Entity.id == schema.target_entity_id, + Entity.active.is_(True), + Tag.id == fee_budget_target_tag.id, + ) + .first() + ) + if target is None: + raise FeeAllocationTargetInvalid("entity") + return FeeTarget(FeeTargetType.ENTITY, target.id, target.name) + + if schema.target_split_id is None: + raise FeeAllocationTargetInvalid("target_split_id") + split = ( + self.db.query(Split) + .join(Split.tags) + .filter( + Split.id == schema.target_split_id, + Split.performed.is_(False), + Tag.id == crowdfunding_target_tag.id, + ) + .first() + ) + if split is None: + raise FeeAllocationTargetInvalid("split") + if currency is not None and split.currency.lower() != currency.lower(): + raise FeeAllocationTargetInvalid("currency") + return FeeTarget( + FeeTargetType.SPLIT, + split.id, + split.comment or f"split #{split.id}", + split.currency, + ) + + def update_directed_allocation( + self, + invoice_id: int, + schema: FeeDirectedAllocationUpdateSchema, + actor_entity: Entity, + ) -> FeeAllocationSelectionSchema: + invoice = self.db.query(Invoice).filter(Invoice.id == invoice_id).first() + if invoice is None: + raise FeeAllocationNotFound(invoice_id) + self._assert_selection_access(invoice, actor_entity) + allocation = self._directed_allocation(invoice_id) + if allocation is None: + raise FeeAllocationNotFound(invoice_id) + if self._invoice_has_settlement(invoice_id): + raise FeeAllocationAlreadySettled + + currency = self._primary_invoice_currency(invoice) + target = self._validate_target(schema, currency) + allocation.target_type = target.target_type + allocation.target_entity_id = ( + target.id if target.target_type == FeeTargetType.ENTITY else None + ) + allocation.target_split_id = ( + target.id if target.target_type == FeeTargetType.SPLIT else None + ) + allocation.selected_by_entity_id = actor_entity.id + allocation.selected_at = datetime.datetime.now() + allocation.auto_selected = False + + if schema.extra_amount is not None and schema.extra_amount > Decimal("0"): + extra_currency = (schema.extra_currency or currency or "").lower() + if not extra_currency: + raise FeeAllocationTargetInvalid("extra_currency") + allocation.extra_amounts = { + extra_currency: format( + schema.extra_amount.quantize(Decimal("0.01")), "f" + ) + } + else: + allocation.extra_amounts = None + + self.db.flush() + self.db.refresh(allocation) + return self.get_selection(invoice_id, actor_entity) + + @staticmethod + def choose_random_target(invoice_id: int, targets: list[FeeTarget]) -> FeeTarget: + rng = random.Random(f"fee-allocation:{invoice_id}") + return rng.choice( + sorted(targets, key=lambda item: (item.target_type.value, item.id)) + ) + + def _eligible_random_targets(self, currency: str | None) -> list[FeeTarget]: + budget_targets = self.list_budget_targets() + split_targets = self.list_eligible_split_targets(currency) + return budget_targets + split_targets + + def auto_select_expired_allocations( + self, + now: datetime.datetime | None = None, + ) -> int: + now = now or datetime.datetime.now() + allocations = ( + self.db.query(FeeAllocation) + .filter( + FeeAllocation.component_key == "directed", + FeeAllocation.selected_at.is_(None), + FeeAllocation.selection_deadline_at <= now, + ) + .all() + ) + selected_count = 0 + for allocation in allocations: + invoice = ( + self.db.query(Invoice) + .filter(Invoice.id == allocation.invoice_id) + .first() + ) + if invoice is None: + continue + currency = self._primary_invoice_currency(invoice) + targets = self._eligible_random_targets(currency) + if not targets: + continue + target = self.choose_random_target(allocation.invoice_id, targets) + allocation.target_type = target.target_type + allocation.target_entity_id = ( + target.id if target.target_type == FeeTargetType.ENTITY else None + ) + allocation.target_split_id = ( + target.id if target.target_type == FeeTargetType.SPLIT else None + ) + allocation.selected_by_entity_id = None + allocation.selected_at = now + allocation.auto_selected = True + self.db.flush() + self._notify_auto_selection(invoice, allocation) + selected_count += 1 + return selected_count + + def _required_invoice_amount(self, invoice: Invoice, currency: str) -> Decimal: + for amount in invoice.amounts or []: + if str(amount.get("currency", "")).lower() == currency.lower(): + return Decimal(str(amount.get("amount", "0"))).quantize(Decimal("0.01")) + return Decimal("0.00") + + def requested_extra_amount(self, invoice_id: int, currency: str) -> Decimal: + allocation = self._directed_allocation(invoice_id) + if allocation is None or not allocation.extra_amounts: + return Decimal("0.00") + amount = allocation.extra_amounts.get(currency.lower()) + if amount is None: + return Decimal("0.00") + return Decimal(str(amount)).quantize(Decimal("0.01")) + + def invoice_has_unselected_directed_allocation(self, invoice_id: int) -> bool: + allocation = self._directed_allocation(invoice_id) + return allocation is not None and allocation.selected_at is None + + def settle_fee_invoice( + self, + invoice_id: int, + currency: str, + actor_entity: Entity, + status: TransactionStatus = TransactionStatus.DRAFT, + ) -> list[Transaction]: + invoice = ( + self.db.query(Invoice) + .filter(Invoice.id == invoice_id) + .options(selectinload(Invoice.transactions)) + .first() + ) + if invoice is None: + raise FeeAllocationNotFound(invoice_id) + self._assert_selection_access(invoice, actor_entity) + if invoice.status != InvoiceStatus.PENDING: + raise FeeAllocationAlreadySettled + allocations = self._allocations_for_invoice(invoice_id) + if not allocations: + raise FeeAllocationNotFound(invoice_id) + allocations = self._ensure_base_allocation(invoice, allocations) + + normalized_currency = currency.lower() + directed = self._directed_allocation(invoice_id) + if directed is not None and directed.selected_at is None: + raise FeeAllocationTargetInvalid("directed") + + component_rows = self._settlement_component_rows( + invoice=invoice, + allocations=allocations, + currency=normalized_currency, + ) + + transactions: list[Transaction] = [] + for allocation, amount, target_entity_id in component_rows: + existing = self._allocation_transaction(allocation) + if existing is not None: + if ( + status == TransactionStatus.COMPLETED + and existing.status == TransactionStatus.DRAFT + ): + existing = self._transaction_service.update( + existing.id, + TransactionUpdateSchema(status=TransactionStatus.COMPLETED), + overrides={"actor_entity_id": actor_entity.id}, + ) + transactions.append(existing) + continue + + tx = self._transaction_service.create( + TransactionCreateSchema( + from_entity_id=invoice.from_entity_id, + to_entity_id=target_entity_id, + amount=amount, + currency=normalized_currency, + status=status, + invoice_id=invoice.id, + comment=( + f"Fee settlement for invoice #{invoice.id}: " + f"{allocation.component_key}" + ), + tag_ids=[fee_allocation_tag.id, automatic_tag.id], + ), + overrides={ + "actor_entity_id": actor_entity.id, + "_skip_invoice_validation": True, + }, + ) + allocation.allocation_transaction_id = tx.id + self.db.flush() + self._invoice_service.after_invoice_transaction_saved(tx) + transactions.append(tx) + + self._invoice_service.mark_fee_invoice_paid_if_settled(invoice_id) + return transactions + + def _ensure_base_allocation( + self, invoice: Invoice, allocations: list[FeeAllocation] + ) -> list[FeeAllocation]: + if any(allocation.component_key == "base" for allocation in allocations): + return allocations + + allocated: dict[str, Decimal] = {} + for allocation in allocations: + for currency, raw_amount in (allocation.amounts or {}).items(): + allocated[currency.lower()] = allocated.get( + currency.lower(), Decimal("0.00") + ) + Decimal(str(raw_amount)).quantize(Decimal("0.01")) + + base_amounts: dict[str, Decimal] = {} + for invoice_amount in invoice.amounts or []: + currency = str(invoice_amount.get("currency", "")).lower() + if not currency: + continue + base_amount = Decimal(str(invoice_amount.get("amount", "0"))).quantize( + Decimal("0.01") + ) - allocated.get(currency, Decimal("0.00")) + if base_amount > Decimal("0.00"): + base_amounts[currency] = base_amount + if not base_amounts: + return allocations + + base_allocation = FeeAllocation( + invoice_id=invoice.id, + component_key="base", + amounts=self._serialize_amounts(base_amounts), + target_type=FeeTargetType.ENTITY, + target_entity_id=f0_entity.id, + selected_at=invoice.created_at, + selection_deadline_at=self._selection_deadline(invoice), + ) + self.db.add(base_allocation) + self.db.flush() + return self._allocations_for_invoice(invoice.id) + + def _settlement_component_rows( + self, + *, + invoice: Invoice, + allocations: list[FeeAllocation], + currency: str, + ) -> list[tuple[FeeAllocation, Decimal, int]]: + required_total = self._required_invoice_amount(invoice, currency) + if required_total <= Decimal("0.00"): + raise FeeAllocationTargetInvalid("currency") + required_total += self.requested_extra_amount(invoice.id, currency) + + rows: list[tuple[FeeAllocation, Decimal, int]] = [] + settlement_total = Decimal("0.00") + for allocation in allocations: + if ( + allocation.component_key == "directed" + and allocation.selected_at is None + ): + raise FeeAllocationTargetInvalid("directed") + amount = self._settlement_amount(allocation, currency) + if amount is None: + raise FeeAllocationTargetInvalid("currency") + if amount <= Decimal("0.00"): + continue + target_entity_id = self._resolve_allocation_target_entity_id(allocation) + if target_entity_id is None: + raise FeeAllocationTargetInvalid("target") + rows.append((allocation, amount, target_entity_id)) + settlement_total += amount + + if settlement_total.quantize(Decimal("0.01")) != required_total.quantize( + Decimal("0.01") + ): + raise FeeAllocationTargetInvalid("amounts") + return rows + + def _settlement_amount( + self, allocation: FeeAllocation, currency: str + ) -> Decimal | None: + raw_amount = (allocation.amounts or {}).get(currency.lower()) + if raw_amount is None: + return None + amount = Decimal(str(raw_amount)).quantize(Decimal("0.01")) + if allocation.component_key == "directed": + amount += self.requested_extra_amount(allocation.invoice_id, currency) + return amount + + def _allocation_transaction(self, allocation: FeeAllocation) -> Transaction | None: + if allocation.allocation_transaction_id is None: + return None + return ( + self.db.query(Transaction) + .filter(Transaction.id == allocation.allocation_transaction_id) + .first() + ) + + def _resolve_allocation_target_entity_id( + self, allocation: FeeAllocation + ) -> int | None: + if allocation.target_type == FeeTargetType.ENTITY: + return allocation.target_entity_id + if ( + allocation.target_type == FeeTargetType.SPLIT + and allocation.target_split_id is not None + ): + split = ( + self.db.query(Split) + .filter(Split.id == allocation.target_split_id) + .first() + ) + return split.recipient_entity_id if split else None + return None + + def _format_amounts(self, amounts: dict[str, str] | dict[str, Decimal]) -> str: + return " / ".join( + f"{Decimal(str(amount)):,.2f} {currency.upper()}" + for currency, amount in sorted(amounts.items()) + ) + + def _selection_url(self, invoice_id: int) -> str: + path = f"/fee/invoices/{invoice_id}/selection" + if not self.config.ui_url: + return path + return f"{self.config.ui_url.rstrip('/')}{path}" + + def _notify_fee_invoice( + self, + invoice: Invoice, + entity: Entity, + allocation: FeeAllocation, + ) -> bool: + deadline = allocation.selection_deadline_at.strftime("%Y-%m-%d") + selection_url = self._selection_url(invoice.id) + message = ( + f"Monthly fee invoice #{invoice.id} is ready.\n" + f"Total to pay: {self._format_amounts({item['currency']: item['amount'] for item in invoice.amounts})}.\n" + f"{self._format_amounts(allocation.amounts)} of it goes to a space budget you choose.\n" + f"Pick a target by {deadline}; otherwise Refinance will choose one automatically.\n" + "If the increased fee is difficult right now, contact the finance person privately." + ) + reply_markup = json.dumps( + { + "inline_keyboard": [ + [{"text": "Choose contribution target", "url": selection_url}] + ] + } + ) + results = self._notification_service.send( + entity, + message, + telegram_reply_markup=reply_markup, + ) + return any(results.values()) + + def _notify_legacy_invoice(self, invoice: Invoice, entity: Entity) -> bool: + message = ( + f"Monthly fee invoice #{invoice.id} uses your legacy amount: " + f"{self._format_amounts({item['currency']: item['amount'] for item in invoice.amounts})}.\n" + "No monthly contribution target selection is required." + ) + results = self._notification_service.send(entity, message) + return any(results.values()) + + def _notify_auto_selection( + self, invoice: Invoice, allocation: FeeAllocation + ) -> None: + entity = ( + self.db.query(Entity).filter(Entity.id == invoice.from_entity_id).first() + ) + if entity is None: + return + target_name = self._target_name(allocation) or "selected target" + message = ( + f"Monthly fee invoice #{invoice.id}: no target was selected within 30 days.\n" + f"Refinance selected {target_name} automatically." + ) + self._notification_service.send(entity, message) + + def get_policy( + self, entity_id: int, actor_entity: Entity + ) -> FeePolicyOverrideSchema | None: + self._assert_finance_actor(actor_entity) + policy = ( + self.db.query(FeePolicyOverride) + .filter(FeePolicyOverride.entity_id == entity_id) + .first() + ) + return FeePolicyOverrideSchema.model_validate(policy) if policy else None + + def upsert_policy( + self, + entity_id: int, + schema: FeePolicyOverrideUpdateSchema, + actor_entity: Entity, + ) -> FeePolicyOverrideSchema: + self._assert_finance_actor(actor_entity) + policy = ( + self.db.query(FeePolicyOverride) + .filter(FeePolicyOverride.entity_id == entity_id) + .first() + ) + if policy is None: + policy = FeePolicyOverride( + entity_id=entity_id, + kind=schema.kind, + active=schema.active, + ) + self.db.add(policy) + else: + policy.kind = schema.kind + policy.active = schema.active + policy.modified_at = datetime.datetime.now() + self.db.flush() + self.db.refresh(policy) + return FeePolicyOverrideSchema.model_validate(policy) + + def delete_policy(self, entity_id: int, actor_entity: Entity) -> int: + self._assert_finance_actor(actor_entity) + policy = ( + self.db.query(FeePolicyOverride) + .filter(FeePolicyOverride.entity_id == entity_id) + .first() + ) + if policy is None: + return entity_id + policy.active = False + policy.modified_at = datetime.datetime.now() + self.db.flush() + return entity_id diff --git a/api/app/services/invoice.py b/api/app/services/invoice.py index 95e2ae8..67e4ca1 100644 --- a/api/app/services/invoice.py +++ b/api/app/services/invoice.py @@ -3,6 +3,7 @@ import datetime from datetime import date from decimal import Decimal +from typing import TYPE_CHECKING from app.dependencies.services import ( get_balance_service, @@ -21,8 +22,11 @@ InvoiceNotEditable, InvoiceTransactionAlreadyAttached, ) +from app.models.entity import Entity +from app.models.fee import FeeAllocation, FeeTargetType from app.models.invoice import Invoice, InvoiceStatus -from app.models.transaction import TransactionStatus +from app.models.split import Split, SplitParticipant +from app.models.transaction import Transaction, TransactionStatus from app.schemas.invoice import ( InvoiceBulkCreateReportSchema, InvoiceBulkCreateSchema, @@ -42,6 +46,9 @@ from sqlalchemy import or_ from sqlalchemy.orm import Query, Session +if TYPE_CHECKING: + from app.services.fee_allocation import FeeAllocationService + class InvoiceService(TaggableServiceMixin[Invoice], BaseService[Invoice]): model = Invoice @@ -57,6 +64,12 @@ def __init__( self._tag_service = tag_service self._balance_service = balance_service self._transaction_service = transaction_service + self._fee_allocation_service: FeeAllocationService | None = None + + def set_fee_allocation_service( + self, fee_allocation_service: "FeeAllocationService" + ) -> None: + self._fee_allocation_service = fee_allocation_service def _apply_filters( # type: ignore[override] self, query: Query[Invoice], filters: InvoiceFiltersSchema @@ -86,6 +99,7 @@ def _apply_filters( # type: ignore[override] def create( # type: ignore[override] self, schema: InvoiceCreateSchema, overrides: dict = {} ) -> Invoice: + skip_auto_pay = bool(overrides.pop("_skip_auto_pay", False)) data = schema.dump() tag_ids = data.pop("tag_ids", None) data["amounts"] = self._serialize_amounts(data.get("amounts", [])) @@ -100,7 +114,8 @@ def create( # type: ignore[override] if tag_ids is not None: self.set_tags(new_obj, tag_ids) self.db.flush() - self._try_auto_pay(new_obj) + if not skip_auto_pay: + self._try_auto_pay(new_obj) self.db.refresh(new_obj) return new_obj @@ -108,7 +123,7 @@ def update( # type: ignore[override] self, obj_id: int, schema: InvoiceUpdateSchema, overrides: dict = {} ) -> Invoice: db_obj = self.get(obj_id) - if db_obj.status != InvoiceStatus.PENDING or db_obj.transaction is not None: + if db_obj.status != InvoiceStatus.PENDING or db_obj.transactions: raise InvoiceNotEditable data = schema.dump() tag_ids = data.pop("tag_ids", None) @@ -130,7 +145,7 @@ def update( # type: ignore[override] def delete(self, obj_id: int) -> int: # type: ignore[override] db_obj = self.get(obj_id) - if db_obj.status != InvoiceStatus.PENDING or db_obj.transaction is not None: + if db_obj.status != InvoiceStatus.PENDING or db_obj.transactions: raise InvoiceNotEditable return super().delete(obj_id) @@ -169,6 +184,8 @@ def _balance_to_decimal(value: object) -> Decimal: def _select_auto_pay_amount( self, invoice: Invoice, balances: dict[str, Decimal] ) -> tuple[str, Decimal] | None: + if self._has_unselected_directed_fee_allocation(invoice.id): + return None selected_currency = None selected_amount = None selected_balance = None @@ -178,6 +195,7 @@ def _select_auto_pay_amount( if not currency: continue required_amount = Decimal(str(entry.get("amount", "0"))) + required_amount += self._requested_extra_fee_amount(invoice.id, currency) current_balance = balances.get(currency) if current_balance is None or current_balance < required_amount: continue @@ -191,9 +209,45 @@ def _select_auto_pay_amount( return selected_currency, selected_amount + def _fee_allocations_for_invoice(self, invoice_id: int) -> list[FeeAllocation]: + return ( + self.db.query(FeeAllocation) + .filter(FeeAllocation.invoice_id == invoice_id) + .order_by(FeeAllocation.id.asc()) + .all() + ) + + def _has_fee_allocations(self, invoice_id: int) -> bool: + return bool(self._fee_allocations_for_invoice(invoice_id)) + + def _has_unselected_directed_fee_allocation(self, invoice_id: int) -> bool: + allocation = ( + self.db.query(FeeAllocation) + .filter( + FeeAllocation.invoice_id == invoice_id, + FeeAllocation.component_key == "directed", + ) + .first() + ) + return allocation is not None and allocation.selected_at is None + + def _requested_extra_fee_amount(self, invoice_id: int, currency: str) -> Decimal: + allocation = ( + self.db.query(FeeAllocation) + .filter( + FeeAllocation.invoice_id == invoice_id, + FeeAllocation.component_key == "directed", + ) + .first() + ) + if allocation is None or not allocation.extra_amounts: + return Decimal("0.00") + amount = allocation.extra_amounts.get(currency.lower()) + if amount is None: + return Decimal("0.00") + return Decimal(str(amount)).quantize(Decimal("0.01")) + def _try_auto_pay(self, invoice: Invoice) -> None: - if invoice.transaction is not None: - return if invoice.status != InvoiceStatus.PENDING: return @@ -208,6 +262,26 @@ def _try_auto_pay(self, invoice: Invoice) -> None: if selection is None: return selected_currency, selected_amount = selection + if self._has_fee_allocations(invoice.id): + if self._fee_allocation_service is None: + return + actor_entity = ( + self.db.query(Entity) + .filter(Entity.id == invoice.actor_entity_id) + .first() + ) + if actor_entity is None: + return + self._fee_allocation_service.settle_fee_invoice( + invoice.id, + selected_currency, + actor_entity, + status=TransactionStatus.COMPLETED, + ) + return + + if invoice.transactions: + return tx_schema = TransactionCreateSchema( to_entity_id=invoice.to_entity_id, @@ -227,7 +301,6 @@ def _try_auto_pay(self, invoice: Invoice) -> None: def auto_pay_oldest_invoices(self) -> int: pending_filter = [ self.model.status == InvoiceStatus.PENDING, - ~self.model.transaction.has(), ] entity_ids = ( self.db.query(self.model.from_entity_id) @@ -260,6 +333,33 @@ def auto_pay_oldest_invoices(self) -> int: continue currency, amount = selection + if self._has_fee_allocations(invoice.id): + if self._fee_allocation_service is None: + continue + actor_entity = ( + self.db.query(Entity) + .filter(Entity.id == invoice.actor_entity_id) + .first() + ) + if actor_entity is None: + continue + self._fee_allocation_service.settle_fee_invoice( + invoice.id, + currency, + actor_entity, + status=TransactionStatus.COMPLETED, + ) + self.db.refresh(invoice) + if invoice.status == InvoiceStatus.PAID: + available_balances[currency] = ( + available_balances[currency] - amount + ) + paid_count += 1 + continue + + if invoice.transactions: + continue + tx_schema = TransactionCreateSchema( to_entity_id=invoice.to_entity_id, from_entity_id=invoice.from_entity_id, @@ -309,11 +409,28 @@ def validate_transaction_for_invoice( invoice = self.get(invoice_id) if invoice.status == InvoiceStatus.CANCELLED: raise InvoiceCancelledNotPayable - if invoice.status == InvoiceStatus.PAID and ( - invoice.transaction is None or invoice.transaction.id != tx_id + fee_allocations = self._fee_allocations_for_invoice(invoice_id) + if fee_allocations: + self._validate_fee_settlement_transaction( + invoice=invoice, + allocations=fee_allocations, + tx_id=tx_id, + from_entity_id=from_entity_id, + to_entity_id=to_entity_id, + amount=amount, + currency=currency, + ) + return + + invoice_transaction_ids = { + transaction.id for transaction in invoice.transactions + } + if ( + invoice.status == InvoiceStatus.PAID + and tx_id not in invoice_transaction_ids ): raise InvoiceAlreadyPaid - if invoice.transaction is not None and invoice.transaction.id != tx_id: + if invoice_transaction_ids and tx_id not in invoice_transaction_ids: raise InvoiceTransactionAlreadyAttached if ( invoice.from_entity_id != from_entity_id @@ -333,6 +450,143 @@ def validate_transaction_for_invoice( invoice.modified_at = datetime.datetime.now() self.db.flush() + def _validate_fee_settlement_transaction( + self, + *, + invoice: Invoice, + allocations: list[FeeAllocation], + tx_id: int | None, + from_entity_id: int, + to_entity_id: int, + amount: Decimal, + currency: str, + ) -> None: + if tx_id is None: + raise InvoiceTransactionAlreadyAttached + if invoice.status == InvoiceStatus.PAID and tx_id not in { + allocation.allocation_transaction_id for allocation in allocations + }: + raise InvoiceAlreadyPaid + allocation = next( + (item for item in allocations if item.allocation_transaction_id == tx_id), + None, + ) + if allocation is None: + raise InvoiceTransactionAlreadyAttached + target_entity_id = self._resolve_fee_allocation_target_entity_id(allocation) + if invoice.from_entity_id != from_entity_id or target_entity_id != to_entity_id: + raise InvoiceEntitiesMismatch + required_amount = self._fee_allocation_amount(allocation, currency) + if required_amount is None: + raise InvoiceCurrencyNotAllowed + if amount.quantize(Decimal("0.01")) != required_amount: + raise InvoiceAmountInsufficient + + def _fee_allocation_amount( + self, allocation: FeeAllocation, currency: str + ) -> Decimal | None: + raw_amount = (allocation.amounts or {}).get(currency.lower()) + if raw_amount is None: + return None + amount = Decimal(str(raw_amount)).quantize(Decimal("0.01")) + if allocation.component_key == "directed" and allocation.extra_amounts: + extra = allocation.extra_amounts.get(currency.lower()) + if extra is not None: + amount += Decimal(str(extra)).quantize(Decimal("0.01")) + return amount + + def _resolve_fee_allocation_target_entity_id( + self, allocation: FeeAllocation + ) -> int | None: + if allocation.target_type == FeeTargetType.ENTITY: + return allocation.target_entity_id + if ( + allocation.target_type == FeeTargetType.SPLIT + and allocation.target_split_id is not None + ): + split = ( + self.db.query(Split) + .filter(Split.id == allocation.target_split_id) + .first() + ) + return split.recipient_entity_id if split is not None else None + return None + + def after_invoice_transaction_saved(self, tx: Transaction) -> None: + if tx.invoice_id is None: + return + allocations = self._fee_allocations_for_invoice(tx.invoice_id) + if not allocations: + if tx.status == TransactionStatus.COMPLETED: + invoice = self.get(tx.invoice_id) + if invoice.status != InvoiceStatus.PAID: + invoice.status = InvoiceStatus.PAID + invoice.modified_at = datetime.datetime.now() + self.db.flush() + return + if tx.status == TransactionStatus.COMPLETED: + self._record_fee_split_progress(tx, allocations) + self.mark_fee_invoice_paid_if_settled(tx.invoice_id) + + def mark_fee_invoice_paid_if_settled(self, invoice_id: int) -> None: + invoice = self.get(invoice_id) + if invoice.status != InvoiceStatus.PENDING: + return + allocations = self._fee_allocations_for_invoice(invoice_id) + if not allocations: + return + transaction_by_id = { + transaction.id: transaction + for transaction in self.db.query(Transaction) + .filter(Transaction.invoice_id == invoice_id) + .all() + } + for allocation in allocations: + if allocation.allocation_transaction_id is None: + return + transaction = transaction_by_id.get(allocation.allocation_transaction_id) + if transaction is None or transaction.status != TransactionStatus.COMPLETED: + return + invoice.status = InvoiceStatus.PAID + invoice.modified_at = datetime.datetime.now() + self.db.flush() + + def _record_fee_split_progress( + self, tx: Transaction, allocations: list[FeeAllocation] + ) -> None: + allocation = next( + (item for item in allocations if item.allocation_transaction_id == tx.id), + None, + ) + if ( + allocation is None + or allocation.target_type != FeeTargetType.SPLIT + or allocation.target_split_id is None + ): + return + participant = ( + self.db.query(SplitParticipant) + .filter( + SplitParticipant.split_id == allocation.target_split_id, + SplitParticipant.entity_id == tx.from_entity_id, + ) + .first() + ) + if participant is None: + self.db.add( + SplitParticipant( + split_id=allocation.target_split_id, + entity_id=tx.from_entity_id, + fixed_amount=tx.amount, + ) + ) + self.db.flush() + return + participant.fixed_amount = ( + participant.fixed_amount or Decimal("0.00") + ) + tx.amount + self.db.flush() + def bulk_create( self, schema: InvoiceBulkCreateSchema, actor_entity_id: int ) -> InvoiceBulkCreateReportSchema: diff --git a/api/app/services/split.py b/api/app/services/split.py index c14eb84..cc76469 100644 --- a/api/app/services/split.py +++ b/api/app/services/split.py @@ -26,6 +26,7 @@ SplitUpdateSchema, ) from app.schemas.transaction import TransactionCreateSchema +from app.seeding import f0_entity from app.services.base import BaseService from app.services.entity import EntityService from app.services.mixins.taggable_mixin import TaggableServiceMixin @@ -250,6 +251,8 @@ def perform(self, obj_id: int, actor_entity: Entity) -> Split: tx_list: list[Transaction] = [] for participant_id, participant_amount in shares.items(): if participant_amount > Decimal("0.00"): + if participant_id == f0_entity.id: + continue if ( participant_id != db_obj.recipient_entity_id ): # don't create transaction to self diff --git a/api/app/services/stats.py b/api/app/services/stats.py index 80a30d0..a21e5fe 100644 --- a/api/app/services/stats.py +++ b/api/app/services/stats.py @@ -204,6 +204,7 @@ def get_resident_fee_sum_by_month( # Query paid invoices paid_invoices = ( self.db.query(Invoice) + .options(selectinload(Invoice.transactions)) .filter( Invoice.to_entity_id == hackerspace.id, Invoice.billing_period.isnot(None), @@ -225,17 +226,24 @@ def get_resident_fee_sum_by_month( .all() ) - monthly_paid_totals = defaultdict(lambda: defaultdict(Decimal)) - monthly_unpaid_totals = defaultdict(lambda: defaultdict(Decimal)) + monthly_paid_totals: defaultdict[tuple[int, int], defaultdict[str, Decimal]] = ( + defaultdict(lambda: defaultdict(Decimal)) + ) + monthly_unpaid_totals: defaultdict[ + tuple[int, int], defaultdict[str, Decimal] + ] = defaultdict(lambda: defaultdict(Decimal)) today = date.today() # Process paid invoices for invoice in paid_invoices: if invoice.billing_period is None: continue - if invoice.transaction is None: - continue - if invoice.transaction.status != TransactionStatus.COMPLETED: + completed_transactions = [ + transaction + for transaction in invoice.transactions + if transaction.status == TransactionStatus.COMPLETED + ] + if not completed_transactions: continue year = invoice.billing_period.year month = invoice.billing_period.month @@ -252,9 +260,10 @@ def get_resident_fee_sum_by_month( if not (start_month <= fee_date <= end_month): continue - monthly_paid_totals[(year, month)][ - invoice.transaction.currency.lower() - ] += invoice.transaction.amount + for transaction in completed_transactions: + monthly_paid_totals[(year, month)][ + transaction.currency.lower() + ] += transaction.amount # Process unpaid invoices for invoice in unpaid_invoices: @@ -330,8 +339,12 @@ def get_resident_fee_sum_by_month( result = [] for year, month in sorted(all_months): - paid_amounts = monthly_paid_totals.get((year, month), {}) - unpaid_amounts = monthly_unpaid_totals.get((year, month), {}) + paid_amounts: Mapping[str, Decimal] = monthly_paid_totals.get( + (year, month), {} + ) + unpaid_amounts: Mapping[str, Decimal] = monthly_unpaid_totals.get( + (year, month), {} + ) expense_amounts = monthly_expense_totals.get((year, month), {}) paid_amounts_float = {k: float(v) for k, v in paid_amounts.items()} @@ -503,7 +516,9 @@ def get_transactions_sum_by_week( .all() ) - weekly_totals = defaultdict(lambda: defaultdict(Decimal)) + weekly_totals: defaultdict[tuple[int, int], defaultdict[str, Decimal]] = ( + defaultdict(lambda: defaultdict(Decimal)) + ) for row in query_result: weekly_totals[(row.year, row.week)][row.currency] = row.total_amount @@ -690,7 +705,7 @@ def _get_top_entities( ): entity_names[int(entity_id)] = name - results = [] + results: list[dict[str, Any]] = [] for entity_id, amounts in totals.items(): amounts_float = { currency: float(amount) for currency, amount in amounts.items() @@ -705,7 +720,7 @@ def _get_top_entities( } ) - results.sort(key=lambda item: item["total_usd"], reverse=True) + results.sort(key=lambda item: float(item["total_usd"]), reverse=True) return results[:limit] def _get_top_tags( @@ -769,7 +784,7 @@ def _get_top_tags( if not totals: return [] - results = [] + results: list[dict[str, Any]] = [] for tag_id, amounts in totals.items(): amounts_float = { currency: float(amount) for currency, amount in amounts.items() @@ -784,7 +799,7 @@ def _get_top_tags( } ) - results.sort(key=lambda item: item["total_usd"], reverse=True) + results.sort(key=lambda item: float(item["total_usd"]), reverse=True) return results[:limit] def get_top_incoming_entities( diff --git a/api/app/services/transaction.py b/api/app/services/transaction.py index dfc7a0e..af500f8 100644 --- a/api/app/services/transaction.py +++ b/api/app/services/transaction.py @@ -12,7 +12,6 @@ CompletedTransactionNotEditable, TransactionWillOverdraftTreasury, ) -from app.models.entity import Entity from app.models.transaction import Transaction, TransactionStatus from app.schemas.transaction import ( TransactionCreateSchema, @@ -31,7 +30,6 @@ if TYPE_CHECKING: from app.services.invoice import InvoiceService - from app.services.stats import StatsService class TransactionService(TaggableServiceMixin[Transaction], BaseService[Transaction]): @@ -129,6 +127,7 @@ def _apply_filters( # type: ignore[override] def create( # type: ignore[override] self, schema: TransactionCreateSchema, overrides: dict = {} ) -> Transaction: + skip_invoice_validation = bool(overrides.pop("_skip_invoice_validation", False)) if ( schema.status == TransactionStatus.COMPLETED and self._treasury_service.transaction_will_overdraft_treasury( @@ -140,15 +139,16 @@ def create( # type: ignore[override] raise TransactionWillOverdraftTreasury if schema.invoice_id is not None: invoice_service = self._get_invoice_service() - invoice_service.validate_transaction_for_invoice( - invoice_id=schema.invoice_id, - tx_id=None, - from_entity_id=schema.from_entity_id, - to_entity_id=schema.to_entity_id, - amount=schema.amount, - currency=schema.currency, - status=schema.status or TransactionStatus.DRAFT, - ) + if not skip_invoice_validation: + invoice_service.validate_transaction_for_invoice( + invoice_id=schema.invoice_id, + tx_id=None, + from_entity_id=schema.from_entity_id, + to_entity_id=schema.to_entity_id, + amount=schema.amount, + currency=schema.currency, + status=schema.status or TransactionStatus.DRAFT, + ) invoice = invoice_service.get(schema.invoice_id) invoice_tag_ids = {tag.id for tag in invoice.tags} if invoice_tag_ids: @@ -163,7 +163,10 @@ def create( # type: ignore[override] schema.to_treasury_id, invalidate_stats=True, ) - return super().create(schema, overrides) + tx = super().create(schema, overrides) + if schema.invoice_id is not None: + self._get_invoice_service().after_invoice_transaction_saved(tx) + return tx def update( # type: ignore[override] self, obj_id: int, schema: TransactionUpdateSchema, overrides: dict = {} @@ -228,7 +231,7 @@ def update( # type: ignore[override] ) updated_tx = super().update(obj_id, schema, overrides) if resolved_invoice_id is not None: - invoice = self._invoice_service.get(resolved_invoice_id) + invoice = self._get_invoice_service().get(resolved_invoice_id) invoice_tag_ids = {tag.id for tag in invoice.tags} if invoice_tag_ids: current_tag_ids = {tag.id for tag in updated_tx.tags} @@ -244,6 +247,8 @@ def update( # type: ignore[override] updated_tx.comment = invoice.comment self.db.flush() self.db.refresh(updated_tx) + if resolved_invoice_id is not None: + self._get_invoice_service().after_invoice_transaction_saved(updated_tx) return updated_tx def delete(self, obj_id: int) -> int: # type: ignore[override] diff --git a/api/app/tasks/balance_reminder.py b/api/app/tasks/balance_reminder.py index 30a7005..8c53a79 100644 --- a/api/app/tasks/balance_reminder.py +++ b/api/app/tasks/balance_reminder.py @@ -20,6 +20,7 @@ from app.config import Config from app.dependencies.services import ServiceContainer from app.models.entity import Entity +from app.models.fee import FeeAllocation from app.models.invoice import Invoice, InvoiceStatus from app.services.notification import NotificationService from app.tasks import PeriodicTask @@ -90,8 +91,10 @@ def _calc_recommended_topup( def _build_reminder_message( negative_balances: dict[str, Decimal], pending_invoices: list[Invoice], + selectable_fee_invoices: list[Invoice], all_balances: dict[str, Decimal], entity_name: str, + ui_url: str | None, ) -> str: lines: list[str] = [f"{random.choice(_GREETINGS)}, {entity_name}."] @@ -126,6 +129,13 @@ def _build_reminder_message( amounts_str = _fmt_amounts(inv.amounts or []) lines.append(f" • Invoice #{inv.id} — {period} — {amounts_str}") + if selectable_fee_invoices: + lines.append("\n🎯 Monthly contribution target:") + for inv in selectable_fee_invoices: + path = f"/fee/invoices/{inv.id}/selection" + url = f"{ui_url.rstrip('/')}{path}" if ui_url else path + lines.append(f" • Invoice #{inv.id}: choose target — {url}") + return "\n".join(lines) @@ -167,19 +177,32 @@ def send_balance_reminder( ) .all() ) + selectable_fee_invoices: list[Invoice] = ( + db.query(Invoice) + .join(FeeAllocation, FeeAllocation.invoice_id == Invoice.id) + .filter( + Invoice.from_entity_id == entity.id, + FeeAllocation.component_key == "directed", + FeeAllocation.selected_at.is_(None), + ) + .order_by(Invoice.id.asc()) + .all() + ) - if not negative_balances and not pending_invoices: + if not negative_balances and not pending_invoices and not selectable_fee_invoices: return None message = _build_reminder_message( negative_balances, pending_invoices, + selectable_fee_invoices, all_balances={ currency: cd.value for currency, cd in balance.completed.items() if cd.value != Decimal(0) }, entity_name=entity.name, + ui_url=notification_service.config.ui_url, ) results = notification_service.send(entity, message) logger.info( diff --git a/api/app/tasks/fee_allocation_selection.py b/api/app/tasks/fee_allocation_selection.py new file mode 100644 index 0000000..6e3381b --- /dev/null +++ b/api/app/tasks/fee_allocation_selection.py @@ -0,0 +1,23 @@ +"""Daily random fallback for inactive directed fee allocation selections.""" + +import datetime + +from app.config import Config +from app.dependencies.services import ServiceContainer +from app.tasks import PeriodicTask + + +class FeeAllocationSelectionTask(PeriodicTask): + def next_delay(self) -> float: + now = datetime.datetime.now() + target = datetime.datetime.combine(now.date(), datetime.time(12, 30)) + if now >= target: + target += datetime.timedelta(days=1) + return (target - now).total_seconds() + + def execute(self, container: ServiceContainer, config: Config) -> int: + return container.fee_allocation_service.auto_select_expired_allocations() + + +async def schedule_fee_allocation_selection() -> None: + await FeeAllocationSelectionTask().schedule() diff --git a/api/tests/test_entity.py b/api/tests/test_entity.py index 05cdec0..9768051 100644 --- a/api/tests/test_entity.py +++ b/api/tests/test_entity.py @@ -310,9 +310,7 @@ def test_entity_with_empty_telegram_id_is_not_matched( ): """Entities with auth.telegram_id == "" must not match any numeric filter and must not cause a DB cast error.""" - self._create_entity_with_telegram_id( - test_app, token, "TgFilter EmptyStr", "" - ) + self._create_entity_with_telegram_id(test_app, token, "TgFilter EmptyStr", "") # Filtering by any numeric telegram_id must not return the empty-string entity response = test_app.get( diff --git a/api/tests/test_fee.py b/api/tests/test_fee.py index 12ab432..77a5323 100644 --- a/api/tests/test_fee.py +++ b/api/tests/test_fee.py @@ -1,8 +1,18 @@ """Tests for FeeService""" +import json from datetime import date +from decimal import Decimal -from app.seeding import fee_tag, resident_tag +from app.seeding import ( + common_consumables_entity, + crowdfunding_target_tag, + fee_tag, + general_purchase_fund_entity, + resident_tag, + safety_cushion_entity, +) +from app.services.notification import NotificationService from fastapi.testclient import TestClient @@ -135,3 +145,460 @@ def pay_invoice(invoice_id: int, from_entity_id: int) -> None: assert fees2[0]["year"] == current_year assert fees2[0]["month"] == current_month assert fees2[0]["amounts"] == {} + + +class TestDirectedFeeAllocations: + def _create_resident(self, test_app: TestClient, token: str, name: str) -> int: + response = test_app.post( + "/entities", + json={"name": name, "tag_ids": [resident_tag.id]}, + headers={"x-token": token}, + ) + assert response.status_code == 200 + return int(response.json()["id"]) + + def _issue_fee_invoice( + self, + test_app: TestClient, + token: str, + entity_id: int, + ) -> int: + period = date.today().replace(day=1).isoformat() + response = test_app.post( + "/fees/invoices/bulk", + json={ + "from_tag_ids": [resident_tag.id], + "billing_period": period, + "notify": False, + }, + headers={"x-token": token}, + ) + assert response.status_code == 200 + invoices_response = test_app.get( + "/invoices", + params={"from_entity_id": entity_id, "billing_period": period}, + headers={"x-token": token}, + ) + assert invoices_response.status_code == 200 + invoices = invoices_response.json()["items"] + assert len(invoices) == 1 + return int(invoices[0]["id"]) + + def _pay_invoice( + self, + test_app: TestClient, + token: str, + entity_id: int, + invoice_id: int, + ) -> list[int]: + response = test_app.post( + f"/fees/invoices/{invoice_id}/settlement", + json={"currency": "usd"}, + headers={"x-token": token}, + ) + assert response.status_code == 200 + transaction_ids = [int(item["id"]) for item in response.json()] + for transaction_id in transaction_ids: + confirm_response = test_app.patch( + f"/transactions/{transaction_id}", + json={"status": "completed"}, + headers={"x-token": token}, + ) + assert confirm_response.status_code == 200 + return transaction_ids + + def test_standard_invoice_creates_directed_allocation_and_notification( + self, test_app: TestClient, token: str, monkeypatch + ): + sent_messages: list[tuple[str, str | None]] = [] + + def fake_send(self, entity, message, *, telegram_reply_markup=None): + sent_messages.append((message, telegram_reply_markup)) + return {"telegram": True} + + monkeypatch.setattr(NotificationService, "send", fake_send) + + entity_id = self._create_resident(test_app, token, "Directed Fee Resident") + period = date.today().replace(day=1).isoformat() + response = test_app.post( + "/fees/invoices/bulk", + json={ + "from_tag_ids": [resident_tag.id], + "billing_period": period, + "notify": True, + }, + headers={"x-token": token}, + ) + assert response.status_code == 200 + report = response.json() + assert report["notification_count"] >= 1 + + invoices_response = test_app.get( + "/invoices", + params={"from_entity_id": entity_id, "billing_period": period}, + headers={"x-token": token}, + ) + invoice = invoices_response.json()["items"][0] + assert invoice["amounts"] == [{"currency": "usd", "amount": "50.00"}] + + payer_token = test_app.get(f"/tokens/{entity_id}").json() + selection_response = test_app.get( + f"/fees/invoices/{invoice['id']}/directed-allocation", + headers={"x-token": payer_token}, + ) + assert selection_response.status_code == 200 + selection = selection_response.json() + assert selection["has_allocation"] is True + directed = selection["directed_allocation"] + assert directed["amounts"] == {"usd": "4.00"} + assert directed["selected_at"] is None + assert directed["selection_deadline_at"][:10] > invoice["created_at"][:10] + fixed = { + item["component_key"]: item["amounts"] + for item in selection["fixed_allocations"] + } + assert fixed == { + "base": {"usd": "42.00"}, + "common_consumables": {"usd": "2.00"}, + "safety_cushion": {"usd": "2.00"}, + } + message, reply_markup = sent_messages[-1] + assert "goes to a space budget you choose" in message + assert "/fee/invoices/" not in message + assert reply_markup is not None + keyboard = json.loads(reply_markup) + button = keyboard["inline_keyboard"][0][0] + assert button["text"] == "Choose contribution target" + assert button["url"].endswith(f"/fee/invoices/{invoice['id']}/selection") + + def test_manual_selection_settles_directly_after_payment( + self, test_app: TestClient, token: str, monkeypatch + ): + sent_messages: list[str] = [] + + def fake_send(self, entity, message, *, telegram_reply_markup=None): + sent_messages.append(message) + return {"telegram": True} + + monkeypatch.setattr(NotificationService, "send", fake_send) + + entity_id = self._create_resident(test_app, token, "Manual Selection Resident") + invoice_id = self._issue_fee_invoice(test_app, token, entity_id) + payer_token = test_app.get(f"/tokens/{entity_id}").json() + + selection_response = test_app.patch( + f"/fees/invoices/{invoice_id}/directed-allocation", + json={ + "target_type": "entity", + "target_entity_id": general_purchase_fund_entity.id, + }, + headers={"x-token": payer_token}, + ) + assert selection_response.status_code == 200 + assert ( + selection_response.json()["selected_target_name"] == "general-purchase-fund" + ) + assert sent_messages == [] + + transaction_ids = self._pay_invoice( + test_app, payer_token, entity_id, invoice_id + ) + assert len(transaction_ids) == 4 + + refreshed = test_app.get( + f"/fees/invoices/{invoice_id}/directed-allocation", + headers={"x-token": payer_token}, + ).json() + assert refreshed["directed_allocation"]["allocation_transaction_id"] is not None + + transactions_response = test_app.get( + "/transactions", + params={"invoice_id": invoice_id}, + headers={"x-token": token}, + ) + assert transactions_response.status_code == 200 + transactions = transactions_response.json()["items"] + assert len(transactions) == 4 + assert all(tx["from_entity_id"] == entity_id for tx in transactions) + by_target = {tx["to_entity_id"]: Decimal(tx["amount"]) for tx in transactions} + assert by_target[1] == Decimal("42.00") + assert by_target[safety_cushion_entity.id] == Decimal("2.00") + assert by_target[common_consumables_entity.id] == Decimal("2.00") + assert by_target[general_purchase_fund_entity.id] == Decimal("4.00") + + f0_outgoing = test_app.get( + "/transactions", + params={ + "from_entity_id": 1, + "to_entity_id": general_purchase_fund_entity.id, + }, + headers={"x-token": token}, + ) + assert f0_outgoing.status_code == 200 + assert f0_outgoing.json()["total"] == 0 + + for entity_id_to_check, expected in ( + (1, Decimal("42.00")), + (safety_cushion_entity.id, Decimal("2.00")), + (common_consumables_entity.id, Decimal("2.00")), + (general_purchase_fund_entity.id, Decimal("4.00")), + ): + balance_response = test_app.get( + f"/balances/{entity_id_to_check}", + headers={"x-token": token}, + ) + assert balance_response.status_code == 200 + balance = balance_response.json()["completed"] + assert Decimal(balance["usd"]) == expected + + def test_draft_settlement_marks_invoice_paid_after_confirmation( + self, test_app: TestClient, token: str + ): + entity_id = self._create_resident(test_app, token, "Draft Settlement Resident") + invoice_id = self._issue_fee_invoice(test_app, token, entity_id) + payer_token = test_app.get(f"/tokens/{entity_id}").json() + + selection_response = test_app.patch( + f"/fees/invoices/{invoice_id}/directed-allocation", + json={ + "target_type": "entity", + "target_entity_id": general_purchase_fund_entity.id, + }, + headers={"x-token": payer_token}, + ) + assert selection_response.status_code == 200 + + settlement_response = test_app.post( + f"/fees/invoices/{invoice_id}/settlement", + json={"currency": "usd"}, + headers={"x-token": payer_token}, + ) + assert settlement_response.status_code == 200 + transactions = settlement_response.json() + assert len(transactions) == 4 + assert {tx["status"] for tx in transactions} == {"draft"} + + invoice_response = test_app.get( + f"/invoices/{invoice_id}", headers={"x-token": token} + ) + assert invoice_response.json()["status"] == "pending" + + retry_response = test_app.post( + f"/fees/invoices/{invoice_id}/settlement", + json={"currency": "usd"}, + headers={"x-token": payer_token}, + ) + assert retry_response.status_code == 200 + assert {tx["id"] for tx in retry_response.json()} == { + tx["id"] for tx in transactions + } + + reselection_response = test_app.patch( + f"/fees/invoices/{invoice_id}/directed-allocation", + json={ + "target_type": "entity", + "target_entity_id": safety_cushion_entity.id, + }, + headers={"x-token": payer_token}, + ) + assert reselection_response.status_code == 418 + + complete_response = test_app.post( + f"/fees/invoices/{invoice_id}/settlement", + json={"currency": "usd", "status": "completed"}, + headers={"x-token": payer_token}, + ) + assert complete_response.status_code == 200 + assert {tx["id"] for tx in complete_response.json()} == { + tx["id"] for tx in transactions + } + assert {tx["status"] for tx in complete_response.json()} == {"completed"} + + paid_invoice_response = test_app.get( + f"/invoices/{invoice_id}", headers={"x-token": token} + ) + assert paid_invoice_response.json()["status"] == "paid" + assert len(paid_invoice_response.json()["transaction_ids"]) == 4 + + def test_split_target_progress_uses_payer_entity( + self, test_app: TestClient, token: str + ): + entity_id = self._create_resident(test_app, token, "Split Target Resident") + invoice_id = self._issue_fee_invoice(test_app, token, entity_id) + split_response = test_app.post( + "/splits", + json={ + "recipient_entity_id": general_purchase_fund_entity.id, + "amount": "20.00", + "currency": "usd", + "comment": "Wheel fund", + "tag_ids": [crowdfunding_target_tag.id], + }, + headers={"x-token": token}, + ) + assert split_response.status_code == 200 + split_id = split_response.json()["id"] + payer_token = test_app.get(f"/tokens/{entity_id}").json() + + selection_response = test_app.patch( + f"/fees/invoices/{invoice_id}/directed-allocation", + json={"target_type": "split", "target_split_id": split_id}, + headers={"x-token": payer_token}, + ) + assert selection_response.status_code == 200 + + completed_transactions = test_app.post( + f"/fees/invoices/{invoice_id}/settlement", + json={"currency": "usd"}, + headers={"x-token": payer_token}, + ) + assert completed_transactions.status_code == 200 + for tx in completed_transactions.json(): + confirm_response = test_app.patch( + f"/transactions/{tx['id']}", + json={"status": "completed"}, + headers={"x-token": token}, + ) + assert confirm_response.status_code == 200 + + split_after = test_app.get(f"/splits/{split_id}", headers={"x-token": token}) + assert split_after.status_code == 200 + participants = split_after.json()["participants"] + payer_participant = next( + participant + for participant in participants + if participant["entity"]["id"] == entity_id + ) + assert payer_participant["fixed_amount"] == "4.00" + + def test_auto_pay_settles_direct_fee_invoice( + self, test_app: TestClient, token: str + ): + funding_entity = test_app.post( + "/entities", + json={"name": "Directed Fee Funding"}, + headers={"x-token": token}, + ).json()["id"] + entity_id = self._create_resident(test_app, token, "AutoPay Directed Resident") + credit_response = test_app.post( + "/transactions", + json={ + "from_entity_id": funding_entity, + "to_entity_id": entity_id, + "amount": "50.00", + "currency": "usd", + "status": "completed", + }, + headers={"x-token": token}, + ) + assert credit_response.status_code == 200 + + invoice_id = self._issue_fee_invoice(test_app, token, entity_id) + payer_token = test_app.get(f"/tokens/{entity_id}").json() + selection_response = test_app.patch( + f"/fees/invoices/{invoice_id}/directed-allocation", + json={ + "target_type": "entity", + "target_entity_id": general_purchase_fund_entity.id, + }, + headers={"x-token": payer_token}, + ) + assert selection_response.status_code == 200 + + auto_pay_response = test_app.post( + "/invoices/auto-pay", + headers={"x-token": token}, + ) + assert auto_pay_response.status_code == 200 + assert auto_pay_response.json()["paid"] >= 1 + + invoice_response = test_app.get( + f"/invoices/{invoice_id}", + headers={"x-token": token}, + ) + assert invoice_response.status_code == 200 + assert invoice_response.json()["status"] == "paid" + assert len(invoice_response.json()["transaction_ids"]) == 4 + + transactions = test_app.get( + "/transactions", + params={"invoice_id": invoice_id}, + headers={"x-token": token}, + ).json()["items"] + assert {tx["status"] for tx in transactions} == {"completed"} + assert all(tx["from_entity_id"] == entity_id for tx in transactions) + + def test_random_fallback_selects_once_and_excludes_currency_mismatch( + self, test_app: TestClient, token: str + ): + entity_id = self._create_resident(test_app, token, "Random Fallback Resident") + invoice_id = self._issue_fee_invoice(test_app, token, entity_id) + mismatch_split_response = test_app.post( + "/splits", + json={ + "recipient_entity_id": general_purchase_fund_entity.id, + "amount": "10.00", + "currency": "eur", + "comment": "EUR target", + "tag_ids": [crowdfunding_target_tag.id], + }, + headers={"x-token": token}, + ) + assert mismatch_split_response.status_code == 200 + mismatch_split_id = mismatch_split_response.json()["id"] + + future = date.today().replace(year=date.today().year + 1).isoformat() + first_run = test_app.post( + "/tasks/fee-allocation-selection/run", + params={"now": f"{future}T00:00:00"}, + headers={"x-token": token}, + ) + assert first_run.status_code == 200 + assert first_run.json()["result"] >= 1 + + payer_token = test_app.get(f"/tokens/{entity_id}").json() + selection = test_app.get( + f"/fees/invoices/{invoice_id}/directed-allocation", + headers={"x-token": payer_token}, + ).json() + directed = selection["directed_allocation"] + assert directed["auto_selected"] is True + assert directed["selected_at"] is not None + assert directed["target_split_id"] != mismatch_split_id + + second_run = test_app.post( + "/tasks/fee-allocation-selection/run", + params={"now": f"{future}T00:00:00"}, + headers={"x-token": token}, + ) + assert second_run.status_code == 200 + assert second_run.json()["result"] == 0 + + def test_legacy_override_creates_no_allocation( + self, test_app: TestClient, token: str + ): + entity_id = self._create_resident(test_app, token, "Legacy Fee Resident") + policy_response = test_app.put( + f"/fees/policies/{entity_id}", + json={"kind": "legacy", "active": True}, + headers={"x-token": token}, + ) + assert policy_response.status_code == 200 + + invoice_id = self._issue_fee_invoice(test_app, token, entity_id) + invoice_response = test_app.get( + f"/invoices/{invoice_id}", + headers={"x-token": token}, + ) + assert invoice_response.status_code == 200 + assert invoice_response.json()["amounts"] == [ + {"currency": "usd", "amount": "42.00"} + ] + + payer_token = test_app.get(f"/tokens/{entity_id}").json() + selection_response = test_app.get( + f"/fees/invoices/{invoice_id}/directed-allocation", + headers={"x-token": payer_token}, + ) + assert selection_response.status_code == 200 + assert selection_response.json()["has_allocation"] is False diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6d455aa..43a66e8 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -8,7 +8,7 @@ services: depends_on: - db ports: - - "8000:8000" + - "127.0.0.1:8000:8000" ui: volumes: @@ -19,7 +19,7 @@ services: depends_on: - api ports: - - "9000:9000" + - "127.0.0.1:9000:9000" db: volumes: @@ -27,4 +27,3 @@ services: volumes: db-dev-data: - diff --git a/ui/Dockerfile b/ui/Dockerfile index a83c8dc..016c94c 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -6,8 +6,7 @@ COPY pyproject.toml /opt/ui/ COPY uv.lock /opt/ui/ WORKDIR /opt/ui -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 9000 diff --git a/ui/app/controllers/entity.py b/ui/app/controllers/entity.py index 0bb5d2d..9e62583 100644 --- a/ui/app/controllers/entity.py +++ b/ui/app/controllers/entity.py @@ -1,4 +1,5 @@ from datetime import date +from decimal import Decimal from app.external.refinance import get_refinance_api_client from app.middlewares.auth import token_required @@ -360,20 +361,31 @@ def detail(id): .get("total", 0) ) - # For paid invoices, prefer the settled transaction amount/currency in compact UI. + # For paid invoices, prefer settled transaction totals in compact UI. for invoice in invoices[:6]: status = ( invoice.status.value if isinstance(invoice.status, InvoiceStatus) else str(invoice.status).lower() ) - if status != InvoiceStatus.PAID.value or not invoice.transaction_id: + transaction_ids = invoice.transaction_ids + if not transaction_ids and invoice.transaction_id: + transaction_ids = [invoice.transaction_id] + if status != InvoiceStatus.PAID.value or not transaction_ids: continue - tx_data = api.http("GET", f"transactions/{invoice.transaction_id}").json() - tx = Transaction(**tx_data) - invoice.paid_amount = tx.amount - invoice.paid_currency = tx.currency.upper() + settled_totals: dict[str, Decimal] = {} + for transaction_id in transaction_ids: + tx_data = api.http("GET", f"transactions/{transaction_id}").json() + tx = Transaction(**tx_data) + amount = Decimal(str(tx.amount)) + settled_totals[tx.currency] = ( + settled_totals.get(tx.currency, Decimal("0")) + amount + ) + if len(settled_totals) == 1: + currency, amount = next(iter(settled_totals.items())) + invoice.paid_amount = amount + invoice.paid_currency = currency.upper() def _apply_stats_bundle(bundle: dict): if not bundle or bundle.get("cached") is False: diff --git a/ui/app/controllers/fee.py b/ui/app/controllers/fee.py index 212b28f..35cd6b4 100644 --- a/ui/app/controllers/fee.py +++ b/ui/app/controllers/fee.py @@ -3,7 +3,7 @@ from app.external.refinance import get_refinance_api_client from app.schemas import Fee, MonthlyFee -from flask import Blueprint, render_template +from flask import Blueprint, redirect, render_template, request, url_for fee_bp = Blueprint("fee", __name__) @@ -155,3 +155,31 @@ def _unpaid_count(fee: Fee) -> int: current_month=current_month, current_year=current_year, ) + + +@fee_bp.route("/invoices//selection", methods=["GET", "POST"]) +def invoice_selection(id: int): + api = get_refinance_api_client() + if request.method == "POST": + target = request.form.get("target", "") + target_type, _, raw_target_id = target.partition(":") + data: dict[str, object] = {"target_type": target_type} + if target_type == "entity": + data["target_entity_id"] = int(raw_target_id) + elif target_type == "split": + data["target_split_id"] = int(raw_target_id) + extra_amount = request.form.get("extra_amount", "").strip() + extra_currency = request.form.get("extra_currency", "").strip() + if extra_amount: + data["extra_amount"] = extra_amount + data["extra_currency"] = extra_currency + api.http("PATCH", f"fees/invoices/{id}/directed-allocation", data=data) + return redirect(url_for("invoice.detail", id=id)) + + selection = api.http("GET", f"fees/invoices/{id}/directed-allocation").json() + invoice = api.http("GET", f"invoices/{id}").json() + return render_template( + "fee/allocation_selection.jinja2", + invoice=invoice, + selection=selection, + ) diff --git a/ui/app/controllers/invoice.py b/ui/app/controllers/invoice.py index c02bac8..eeadd09 100644 --- a/ui/app/controllers/invoice.py +++ b/ui/app/controllers/invoice.py @@ -2,9 +2,17 @@ from decimal import Decimal from app.config import Config +from app.exceptions.base import ApplicationError from app.external.refinance import get_refinance_api_client from app.middlewares.auth import token_required -from app.schemas import Balance, Invoice, InvoiceStatus, Tag, Transaction +from app.schemas import ( + Balance, + Invoice, + InvoiceStatus, + Tag, + Transaction, + TransactionStatus, +) from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_wtf import FlaskForm from wtforms import ( @@ -111,6 +119,10 @@ class DeleteForm(FlaskForm): delete = SubmitField("Delete") +class SettlementCompleteForm(FlaskForm): + submit = SubmitField("Complete settlement") + + class InvoiceBulkForm(FlaskForm): from_tag_ids = SelectMultipleField( "From Tags (entities with tag)", coerce=int, choices=[], validators=[Optional()] @@ -166,7 +178,7 @@ class InvoiceBulkForm(FlaskForm): def _build_amounts_from_form(form: InvoiceForm) -> list[dict[str, str]]: - amounts = [] + amounts: list[dict[str, str]] = [] for amount_field, currency_field in ( (form.amount_1, form.currency_1), (form.amount_2, form.currency_2), @@ -184,7 +196,7 @@ def _build_amounts_from_form(form: InvoiceForm) -> list[dict[str, str]]: def _build_amounts_from_bulk_form(form: InvoiceBulkForm) -> list[dict[str, str]]: - amounts = [] + amounts: list[dict[str, str]] = [] for amount_field, currency_field in ( (form.amount_1, form.currency_1), (form.amount_2, form.currency_2), @@ -209,7 +221,9 @@ def _populate_amount_fields(form: InvoiceForm, amounts: list[dict]) -> None: ] for slot, entry in zip(slots, amounts): amount_field, currency_field = slot - amount_field.data = float(entry.get("amount")) + raw_amount = entry.get("amount") + if raw_amount is not None: + amount_field.data = float(raw_amount) currency_field.data = str(entry.get("currency", "")).upper() @@ -222,9 +236,55 @@ def _normalize_billing_period(value: str | None) -> str | None: return trimmed -@invoice_bp.route("/") +def _invoice_transaction_ids(invoice: Invoice) -> list[int]: + transaction_ids = invoice.transaction_ids or [] + if not transaction_ids and invoice.transaction_id: + transaction_ids = [invoice.transaction_id] + return transaction_ids + + +def _invoice_transactions(api, invoice: Invoice) -> list[Transaction]: + return [ + Transaction(**api.http("GET", f"transactions/{transaction_id}").json()) + for transaction_id in _invoice_transaction_ids(invoice) + ] + + +def _status_value(status: str | TransactionStatus) -> str: + return status.value if isinstance(status, TransactionStatus) else str(status) + + +def _has_draft_transactions(transactions: list[Transaction]) -> bool: + return any( + _status_value(transaction.status) != TransactionStatus.COMPLETED.value + for transaction in transactions + ) + + +def _get_fee_allocation(api, invoice_id: int) -> dict | None: + try: + allocation_response = api.http( + "GET", f"fees/invoices/{invoice_id}/directed-allocation" + ).json() + except ApplicationError: + return None + if allocation_response.get("has_allocation"): + return allocation_response + return None + + +def _first_invoice_currency(invoice: Invoice) -> str: + if not invoice.amounts: + return "" + first_amount = invoice.amounts[0] + if isinstance(first_amount, dict): + return str(first_amount.get("currency", "")).lower() + return str(first_amount.currency).lower() + + +@invoice_bp.route("/", endpoint="list") @token_required -def list(): +def list_invoices(): page = request.args.get("page", 1, type=int) limit = request.args.get("limit", 20, type=int) skip = (page - 1) * limit @@ -280,18 +340,44 @@ def add(): def detail(id): api = get_refinance_api_client() invoice = Invoice(**api.http("GET", f"invoices/{id}").json()) - transaction = None - if invoice.transaction_id: - transaction = Transaction( - **api.http("GET", f"transactions/{invoice.transaction_id}").json() - ) + transactions = _invoice_transactions(api, invoice) + fee_allocation = _get_fee_allocation(api, id) return render_template( "invoice/detail.jinja2", invoice=invoice, - transaction=transaction, + transactions=transactions, + fee_allocation=fee_allocation, + has_draft_settlement_transactions=_has_draft_transactions(transactions), + settlement_complete_form=SettlementCompleteForm(), ) +@invoice_bp.route("//complete-settlement", methods=["POST"]) +@token_required +def complete_settlement(id): + form = SettlementCompleteForm() + if not form.validate_on_submit(): + return redirect(url_for("invoice.detail", id=id)) + api = get_refinance_api_client() + invoice = Invoice(**api.http("GET", f"invoices/{id}").json()) + fee_allocation = _get_fee_allocation(api, id) + if not fee_allocation: + return redirect(url_for("invoice.detail", id=id)) + + transaction_ids = _invoice_transaction_ids(invoice) + if not transaction_ids: + return redirect(url_for("invoice.pay", id=id)) + + currency = _first_invoice_currency(invoice) + if currency: + api.http( + "POST", + f"fees/invoices/{invoice.id}/settlement", + data={"currency": currency, "status": TransactionStatus.COMPLETED.value}, + ) + return redirect(url_for("invoice.detail", id=id)) + + @invoice_bp.route("//delete", methods=["GET", "POST"]) @token_required def delete(id): @@ -350,7 +436,34 @@ def pay(id): api = get_refinance_api_client() invoice_data = api.http("GET", f"invoices/{id}").json() invoice = Invoice(**invoice_data) + invoice_status = ( + invoice.status.value + if isinstance(invoice.status, InvoiceStatus) + else str(invoice.status) + ) + if invoice_status != InvoiceStatus.PENDING.value: + return redirect(url_for("invoice.detail", id=invoice.id)) + transactions = _invoice_transactions(api, invoice) amounts = invoice_data.get("amounts", []) + fee_allocation = _get_fee_allocation(api, id) + if fee_allocation: + directed = fee_allocation.get("directed_allocation") or {} + extra_amounts = directed.get("extra_amounts") or {} + adjusted_amounts = [] + for amount in amounts: + currency = str(amount.get("currency", "")).lower() + extra = extra_amounts.get(currency) + if extra is None: + adjusted_amounts.append(amount) + continue + total = Decimal(str(amount["amount"])) + Decimal(str(extra)) + adjusted_amounts.append( + { + **amount, + "amount": format(total.quantize(Decimal("0.01")), "f"), + } + ) + amounts = adjusted_amounts from_entity_balance = Balance( **api.http("GET", f"balances/{invoice.from_entity_id}").json() ).completed @@ -369,6 +482,22 @@ def pay(id): form.amount.data = float(amounts[0]["amount"]) if form.validate_on_submit(): + if fee_allocation: + directed = fee_allocation.get("directed_allocation") or {} + if directed.get("selected_at") is None: + return redirect(url_for("fee.invoice_selection", id=invoice.id)) + status = ( + TransactionStatus.COMPLETED.value + if transactions + else TransactionStatus.DRAFT.value + ) + api.http( + "POST", + f"fees/invoices/{invoice.id}/settlement", + data={"currency": form.currency.data.lower(), "status": status}, + ) + return redirect(url_for("invoice.detail", id=invoice.id)) + data = { "from_entity_id": int(form.from_entity_id.data), "to_entity_id": int(form.to_entity_id.data), @@ -387,6 +516,9 @@ def pay(id): form=form, amounts=amounts, from_entity_balance=from_entity_balance, + fee_allocation=fee_allocation, + transactions=transactions, + has_draft_settlement_transactions=_has_draft_transactions(transactions), ) @@ -401,17 +533,17 @@ def bulk_add(): fee_config = api.http("GET", "fees/config").json() fee_preset_groups: list[dict] = [] - _groups: dict[int, list[dict]] = {} - for item in fee_config: - tag_id = item["tag_id"] - _groups.setdefault(tag_id, []).append( - {"currency": item["currency"], "amount": item["amount"]} - ) - for tag_id, amounts in _groups.items(): + for item in fee_config.get("rules", []): + tag_id = item["membership_tag_id"] + amounts = [ + {"currency": currency, "amount": amount} + for currency, amount in item.get("invoice_amounts", {}).items() + ] fee_preset_groups.append( { "tag_id": tag_id, - "tag_name": tag_name_by_id.get(tag_id, f"tag {tag_id}"), + "tag_name": item.get("label") + or tag_name_by_id.get(tag_id, f"tag {tag_id}"), "amounts": sorted(amounts, key=lambda x: x["currency"]), } ) @@ -432,19 +564,14 @@ def _render(): ) if form.validate_on_submit(): - amounts = _build_amounts_from_bulk_form(form) - if not amounts or not form.from_tag_ids.data: + if not form.from_tag_ids.data: flash("Preset selection required.") return _render() data = { "from_tag_ids": form.from_tag_ids.data, - "to_entity_id": form.to_entity_id.data, - "comment": form.comment.data or None, - "amounts": amounts, - "tag_ids": form.tag_ids.data or [], "billing_period": _normalize_billing_period(form.billing_period.data), } - result = api.http("POST", "invoices/bulk", data=data).json() + result = api.http("POST", "fees/invoices/bulk", data=data).json() invoice_ids = result.get("invoice_ids", []) invoices = [ Invoice(**api.http("GET", f"invoices/{iid}").json()) for iid in invoice_ids diff --git a/ui/app/controllers/transaction.py b/ui/app/controllers/transaction.py index d0c18c7..76ae812 100644 --- a/ui/app/controllers/transaction.py +++ b/ui/app/controllers/transaction.py @@ -3,6 +3,7 @@ from typing import Dict, List from app.config import Config +from app.exceptions.base import ApplicationError from app.external.refinance import get_refinance_api_client from app.middlewares.auth import token_required from app.schemas import Tag, Transaction, TransactionStatus @@ -145,7 +146,7 @@ def _get_active_treasuries(api): def _get_entities_by_tag_id(api, tag_id: int | None, active_only: bool = False): if not tag_id: return [] - params = {"tags_ids": tag_id, "limit": 500} + params: dict[str, int | str] = {"tags_ids": tag_id, "limit": 500} if active_only: params["active"] = "true" return api.http("GET", "entities", params=params).json().get("items", []) @@ -455,9 +456,23 @@ def shortcut_reimburse(): api = get_refinance_api_client() fridge_entity_id = Config.ENTITY_IDS["fridge"] coffee_entity_id = Config.ENTITY_IDS["coffee"] + configured_entity_ids = {fridge_entity_id, coffee_entity_id} + entities_response = api.http( + "GET", "entities", params={"skip": 0, "limit": 1000} + ).json() + available_entity_ids = { + entity["id"] + for entity in entities_response.get("items", []) + if entity.get("id") in configured_entity_ids + } def _format_balance_label(entity_id: int) -> str: - balance = api.http("GET", f"balances/{entity_id}").json() + if entity_id not in available_entity_ids: + return "unavailable" + try: + balance = api.http("GET", f"balances/{entity_id}").json() + except ApplicationError: + return "unavailable" completed = balance.get("completed", {}) if isinstance(balance, dict) else {} if not completed: return "0" diff --git a/ui/app/schemas.py b/ui/app/schemas.py index 00f47a9..eb12be8 100644 --- a/ui/app/schemas.py +++ b/ui/app/schemas.py @@ -93,6 +93,7 @@ class Invoice(Base): status: InvoiceStatus tags: list[Tag] transaction_id: int | None = None + transaction_ids: list[int] = field(default_factory=list) billing_period: date | None = None paid_amount: Decimal | None = None paid_currency: str | None = None diff --git a/ui/app/templates/fee/allocation_selection.jinja2 b/ui/app/templates/fee/allocation_selection.jinja2 new file mode 100644 index 0000000..6d98da9 --- /dev/null +++ b/ui/app/templates/fee/allocation_selection.jinja2 @@ -0,0 +1,69 @@ +{% extends "base.jinja2" %} + +{% block title %}Monthly contribution{% endblock %} + +{% block content %} +{% set directed = selection.directed_allocation %} +
+
+

Monthly contribution

+ {% if directed %} +
+
+ {% for currency, amount in directed.amounts.items() %} + {{ amount }} {{ currency | upper }}{% if not loop.last %} OR {% endif %} + {% endfor %} +
+
+

Choose where this part of your fee goes.

+

Deadline: {{ directed.selection_deadline_at[:10] }}

+ + {% if directed.allocation_transaction_id %} +

This contribution has already been settled.

+ {% else %} +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ {% endif %} + {% else %} +

No monthly contribution selection is required for this invoice.

+ {% endif %} +
+
+{% endblock %} diff --git a/ui/app/templates/invoice/bulk_add.jinja2 b/ui/app/templates/invoice/bulk_add.jinja2 index 5a16b33..e3e32a9 100644 --- a/ui/app/templates/invoice/bulk_add.jinja2 +++ b/ui/app/templates/invoice/bulk_add.jinja2 @@ -3,6 +3,7 @@ {% block title %}Issue Fee Invoices{% endblock %} {% block content %} +{% set default_group = fee_preset_groups[0] if fee_preset_groups else none %}
@@ -10,13 +11,12 @@ {# Hidden fields — defaults pre-filled; JS overwrites from/preset amounts on selection #} - - - - - - - + + {% for index in range(3) %} + {% set amount = default_group.amounts[index] if default_group and default_group.amounts|length > index else none %} + + + {% endfor %} {% if fee_preset_groups %}
@@ -25,7 +25,7 @@ {% for group in fee_preset_groups %}
- +
diff --git a/ui/app/templates/invoice/detail.jinja2 b/ui/app/templates/invoice/detail.jinja2 index 7b67b68..55b07eb 100644 --- a/ui/app/templates/invoice/detail.jinja2 +++ b/ui/app/templates/invoice/detail.jinja2 @@ -62,6 +62,30 @@ {% endif %} + + {% if fee_allocation %} +

Monthly contribution

+
+
+ {% set directed = fee_allocation.directed_allocation %} + {% for currency, amount in directed.amounts.items() %} + {{ amount }} {{ currency | upper }}{% if not loop.last %} OR {% endif %} + {% endfor %} +
+

+ Target: + {% if fee_allocation.selected_target_name %} + {{ fee_allocation.selected_target_name }} + {% else %} + not selected + {% endif %} + · Deadline {{ directed.selection_deadline_at[:10] }} +

+ {% if not directed.allocation_transaction_id %} +

Choose target

+ {% endif %} +
+ {% endif %}
@@ -87,10 +111,18 @@ class="{% if not invoice.actor_entity.active %}secondary inactive{% endif %}">{{ invoice.actor_entity.name }} - {% if transaction %} + {% if transactions %} - Transaction - {{ transaction.id }} + Transactions + + {% for transaction in transactions %} +
+ #{{ transaction.id }} + · {{ "%.2f"|format(transaction.amount|float) }} {{ transaction.currency | upper }} + · {{ transaction.status }} +
+ {% endfor %} + {% endif %} @@ -100,10 +132,19 @@ {% if invoice.status == "pending" %}
- Pay + {% if fee_allocation and transactions and has_draft_settlement_transactions %} +
+ {{ settlement_complete_form.hidden_tag() }} + +
+ {% elif fee_allocation and transactions %} +

Settlement transactions have been created.

+ {% else %} + Pay + {% endif %}
Edit - {% if not invoice.transaction_id %} + {% if not invoice.transaction_ids %} Delete {% endif %}
@@ -112,4 +153,4 @@

This invoice can no longer be changed.

{% endif %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/ui/app/templates/invoice/pay.jinja2 b/ui/app/templates/invoice/pay.jinja2 index 6e7822b..293f1e0 100644 --- a/ui/app/templates/invoice/pay.jinja2 +++ b/ui/app/templates/invoice/pay.jinja2 @@ -55,10 +55,33 @@ {% endfor %}
+ {% if fee_allocation %} + {% set directed = fee_allocation.directed_allocation %} +

+ {% if transactions %} + Settlement transactions already exist. Completing settlement will complete all draft components. + {% else %} + Monthly contribution target: + {% if fee_allocation.selected_target_name %} + {{ fee_allocation.selected_target_name }} + {% else %} + not selected + {% endif %} + · change + {% endif %} +

+ {% if directed.extra_amounts %} +

The selected extra directed donation is included in the amount above.

+ {% endif %} + {% endif %}
- {{ form.submit(class_='big-button', value='Create transaction') }} + {% if fee_allocation and not fee_allocation.selected_target_name %} + Choose target + {% else %} + {{ form.submit(class_='big-button', value='Complete settlement' if fee_allocation and has_draft_settlement_transactions else 'Create settlement' if fee_allocation else 'Create transaction') }} + {% endif %}
@@ -94,4 +117,4 @@ setActive(preset); }()); -{% endblock %} \ No newline at end of file +{% endblock %}