diff --git a/.github/workflows/release-artifacts.yaml b/.github/workflows/release-artifacts.yaml index af2edc8f..8fcbc350 100644 --- a/.github/workflows/release-artifacts.yaml +++ b/.github/workflows/release-artifacts.yaml @@ -57,7 +57,7 @@ jobs: - id: set-distros run: | # if we're running from a tag, get the full list of distros; otherwise just use debian:sid - dists='["debian:bullseye"]' + dists='["debian:bookworm"]' tags="latest $GITHUB_SHA" if [[ $GITHUB_REF == refs/tags/* ]]; then dists=$(tools/build_debian_packages.py --show-dists-json) @@ -175,7 +175,7 @@ jobs: - name: Download all workflow run artifacts uses: actions/download-artifact@v2 - name: Trigger demo deployment via webhook - run: curl ${{ secrets.DEMO_DEPLOY_WEBHOOK_URL }} -F "archive=@$(find -name 'abrechnung_*bullseye*_amd64.deb')" --fail + run: curl ${{ secrets.DEMO_DEPLOY_WEBHOOK_URL }} -F "archive=@$(find -name 'abrechnung_*bookworm*_amd64.deb')" --fail # if it's a tag, create a release and attach the artifacts to it attach-assets: diff --git a/abrechnung/application/transactions.py b/abrechnung/application/transactions.py index f6f151bf..24fa5a78 100644 --- a/abrechnung/application/transactions.py +++ b/abrechnung/application/transactions.py @@ -1164,7 +1164,6 @@ async def sync_transaction( async def sync_transactions( self, *, user: User, group_id: int, transactions: list[RawTransaction] ) -> dict[int, int]: - all_transactions_in_same_group = all( [a.group_id == group_id for a in transactions] ) diff --git a/abrechnung/config.py b/abrechnung/config.py index d76637e3..e93ff579 100644 --- a/abrechnung/config.py +++ b/abrechnung/config.py @@ -13,23 +13,23 @@ class ServiceConfig(BaseModel): class DemoConfig(BaseModel): - enabled = False - wipe_interval = timedelta(hours=1) + enabled: bool = False + wipe_interval: timedelta = timedelta(hours=1) class ApiConfig(BaseModel): secret_key: str host: str port: int - id = "default" - max_uploadable_file_size = 1024 - enable_cors = True - access_token_validity = timedelta(hours=1) + id: str = "default" + max_uploadable_file_size: int = 1024 + enable_cors: bool = True + access_token_validity: timedelta = timedelta(hours=1) class RegistrationConfig(BaseModel): - enabled = False - allow_guest_users = False + enabled: bool = False + allow_guest_users: bool = False valid_email_domains: Optional[List[str]] = None @@ -49,7 +49,7 @@ class AuthConfig(BaseModel): address: str host: str port: int - mode = "smtp" # oneof "local" "smtp-ssl" "smtp-starttls" "smtp" + mode: str = "smtp" # oneof "local" "smtp-ssl" "smtp-starttls" "smtp" auth: Optional[AuthConfig] = None @@ -59,8 +59,8 @@ class Config(BaseModel): database: DatabaseConfig email: EmailConfig # in case all params are optional this is needed to make the whole section optional - demo = DemoConfig() - registration = RegistrationConfig() + demo: DemoConfig = DemoConfig() + registration: RegistrationConfig = RegistrationConfig() def read_config(config_path: Path) -> Config: diff --git a/abrechnung/http/routers/accounts.py b/abrechnung/http/routers/accounts.py index 1344a810..c1e9708c 100644 --- a/abrechnung/http/routers/accounts.py +++ b/abrechnung/http/routers/accounts.py @@ -40,7 +40,7 @@ class BaseAccountPayload(BaseModel): date_info: Optional[date] = None tags: Optional[List[str]] = None owning_user_id: Optional[int] = None - clearing_shares: ClearingShares + clearing_shares: ClearingShares = None class CreateAccountPayload(BaseAccountPayload): diff --git a/abrechnung/http/routers/common.py b/abrechnung/http/routers/common.py index 96758a79..2a998843 100644 --- a/abrechnung/http/routers/common.py +++ b/abrechnung/http/routers/common.py @@ -16,7 +16,7 @@ class VersionResponse(BaseModel): patch_version: int class Config: - schema_extra = { + json_schema_extra = { "example": { "version": "1.3.2", "major_version": 1, diff --git a/abrechnung/http/routers/groups.py b/abrechnung/http/routers/groups.py index 1796a2d0..fcb76ebe 100644 --- a/abrechnung/http/routers/groups.py +++ b/abrechnung/http/routers/groups.py @@ -79,7 +79,7 @@ class GroupPayload(BaseModel): name: str description: str = "" currency_symbol: str - add_user_account_on_join = False + add_user_account_on_join: bool = False terms: str = "" diff --git a/abrechnung/http/routers/transactions.py b/abrechnung/http/routers/transactions.py index d44dd567..0d640fcb 100644 --- a/abrechnung/http/routers/transactions.py +++ b/abrechnung/http/routers/transactions.py @@ -298,6 +298,12 @@ async def upload_file( detail=f"Cannot read uploaded file: {e}", ) + if file.filename is None or file.content_type is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File is missing filename or content type", + ) + await transaction_service.upload_file( user=user, transaction_id=transaction_id, diff --git a/debian/changelog b/debian/changelog index 82b88a7e..6caaf302 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,14 +1,20 @@ +abrechnung (0.11.0) stable; urgency=medium + + * Abrechnung release 0.11.0 + + -- Michael Loipführer Sun, 26 Aug 2023 20:00:00 +0200 + abrechnung (0.10.1) stable; urgency=medium * Abrechnung release 0.10.1 - -- Michael Loipführer Sun, 1 Jan 2022 18:00:00 +0100 + -- Michael Loipführer Sun, 1 Jan 2023 18:00:00 +0100 abrechnung (0.10.0) stable; urgency=medium * Abrechnung release 0.10.0 - -- Michael Loipführer Sun, 1 Jan 2022 15:00:00 +0100 + -- Michael Loipführer Sun, 1 Jan 2023 15:00:00 +0100 abrechnung (0.9.0) stable; urgency=medium diff --git a/debian/copyright b/debian/copyright index 7cd585c6..83bbd352 100644 --- a/debian/copyright +++ b/debian/copyright @@ -2,4 +2,4 @@ Upstream-Name: abrechnung Upstream-Contact: Michael Loipführer, Files: * -Copyright: 2021, Jonas Jelten , Michael Enßlin , Michael Loipführer +Copyright: 2023, Jonas Jelten , Michael Enßlin , Michael Loipführer diff --git a/docs/conf.py b/docs/conf.py index 8f245b3c..1be28672 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,10 +15,6 @@ import sys from pathlib import Path -import pydantic -import yaml -from pydantic.fields import ModelField - HERE = Path(__file__).parent sys.path[:0] = [str(HERE.parent), str(HERE / "_ext")] BUILD_DIR = HERE / "_build" @@ -115,50 +111,4 @@ def generate_openapi_json(): json.dump(api.api.openapi(), f) -def _generate_config_doc_rec(field: ModelField): - if issubclass(field.type_, pydantic.BaseModel): - sub = {} - for subfield in field.type_.__fields__.values(): - sub[subfield.name] = _generate_config_doc_rec(subfield) - return sub - - if ( - field.outer_type_ is not None - and "List" in str(field.outer_type_) - or "list" in str(field.outer_type_) - ): - verbose_type = f"" - else: - verbose_type = f"<{field.type_.__name__}>" - - out = f"{verbose_type}" - extra_info = [] - # if description := field.metadata.get("description"): - # extra_info.append(description) - - if field.default: - extra_info.append(f"default={field.default}") - - if field.required is not None and not field.required: - extra_info.append("optional") - - if not extra_info: - return out - - return f"{out} # {'; '.join(extra_info)}" - - -def generate_config_doc_yaml(): - output = {} - for field in Config.__fields__.values(): - output[field.name] = _generate_config_doc_rec(field) - - output_string = yaml.dump(output).replace("'", "").replace('"', "") - - BUILD_DIR.mkdir(parents=True, exist_ok=True) - with open(BUILD_DIR / "config_schema.yaml", "w+", encoding="utf-8") as f: - f.write(output_string) - - generate_openapi_json() -generate_config_doc_yaml() diff --git a/docs/usage/configuration.rst b/docs/usage/configuration.rst index 723b351c..25786e39 100644 --- a/docs/usage/configuration.rst +++ b/docs/usage/configuration.rst @@ -121,10 +121,3 @@ Possible config options are "imprintURL": "", "sourceCodeURL": "https://github.com/SFTtech/abrechnung" } - - -All Configuration Options -------------------------- - -.. literalinclude :: ../_build/config_schema.yaml - :language: yaml diff --git a/docs/usage/installation.rst b/docs/usage/installation.rst index c4e0e70b..b2d9f791 100644 --- a/docs/usage/installation.rst +++ b/docs/usage/installation.rst @@ -8,7 +8,7 @@ Installation .. _abrechnung-installation-debian: -Debian Buster, Bullseye, Bookworm and Sid +Debian Bullseye, Bookworm, Trixie and Sid ----------------------------------------- This is the recommended installation method as it also installs the prebuilt abrechnung web app. @@ -25,7 +25,7 @@ as well as static web assets in ``/usr/share/abrechnung_web`` are installed. The only remaining work to be done is to setup the database and customize the configuration (see :ref:`abrechnung-config`). -Ubuntu Focal, Hirsute and Impish +Ubuntu Jammy -------------------------------- Follow the installation instructions for :ref:`Debian `, just make sure to choose the correct diff --git a/frontend/apps/web/src/pages/accounts/AccountDetail/AccountInfo.tsx b/frontend/apps/web/src/pages/accounts/AccountDetail/AccountInfo.tsx index 58668f29..5d3c4acf 100644 --- a/frontend/apps/web/src/pages/accounts/AccountDetail/AccountInfo.tsx +++ b/frontend/apps/web/src/pages/accounts/AccountDetail/AccountInfo.tsx @@ -12,17 +12,17 @@ import { Account, AccountValidator } from "@abrechnung/types"; import { ChevronLeft, Delete, Edit } from "@mui/icons-material"; import { Button, Chip, Divider, Grid, IconButton, LinearProgress, TableCell } from "@mui/material"; import React from "react"; -import { Link as RouterLink, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; import { typeToFlattenedError, z } from "zod"; -import { DeleteAccountModal } from "../../../components/accounts/DeleteAccountModal"; -import { DateInput } from "../../../components/DateInput"; -import { ShareSelect } from "../../../components/ShareSelect"; -import { TagSelector } from "../../../components/TagSelector"; -import { TextInput } from "../../../components/TextInput"; -import { api } from "../../../core/api"; -import { selectAccountSlice, selectGroupSlice, useAppDispatch, useAppSelector } from "../../../store"; -import { getAccountLink, getAccountListLink } from "../../../utils"; +import { DeleteAccountModal } from "@/components/accounts/DeleteAccountModal"; +import { DateInput } from "@/components/DateInput"; +import { ShareSelect } from "@/components/ShareSelect"; +import { TagSelector } from "@/components/TagSelector"; +import { TextInput } from "@/components/TextInput"; +import { api } from "@/core/api"; +import { selectAccountSlice, selectGroupSlice, useAppDispatch, useAppSelector } from "@/store"; +import { getAccountLink, getAccountListLink } from "@/utils"; interface Props { groupId: number; @@ -87,7 +87,7 @@ export const AccountInfo: React.FC = ({ groupId, accountId }) => { .then(({ oldAccountId, account }) => { setShowProgress(false); if (oldAccountId !== account.id) { - navigate(getAccountLink(groupId, "clearing", account.id) + "?no-redirect=true"); + navigate(getAccountLink(groupId, "clearing", account.id) + "?no-redirect=true", { replace: true }); } }) .catch((err) => { diff --git a/frontend/apps/web/src/components/accounts/Balances.tsx b/frontend/apps/web/src/pages/accounts/Balances.tsx similarity index 95% rename from frontend/apps/web/src/components/accounts/Balances.tsx rename to frontend/apps/web/src/pages/accounts/Balances.tsx index e01b33ae..eb380e74 100644 --- a/frontend/apps/web/src/components/accounts/Balances.tsx +++ b/frontend/apps/web/src/pages/accounts/Balances.tsx @@ -4,6 +4,7 @@ import { Alert, AlertTitle, Box, + Button, Divider, List, ListItemText, @@ -14,10 +15,10 @@ import { } from "@mui/material"; import { TabContext, TabList, TabPanel } from "@mui/lab"; import { useTheme } from "@mui/material/styles"; -import { useNavigate } from "react-router-dom"; -import BalanceTable from "./BalanceTable"; -import { MobilePaper } from "../style/mobile"; -import ListItemLink from "../style/ListItemLink"; +import { useNavigate, Link as RouterLink } from "react-router-dom"; +import BalanceTable from "../../components/accounts/BalanceTable"; +import { MobilePaper } from "../../components/style/mobile"; +import ListItemLink from "../../components/style/ListItemLink"; import { useTitle } from "../../core/utils"; import { selectGroupAccountsFiltered, selectGroupById, selectAccountBalances } from "@abrechnung/redux"; import { useAppSelector, selectAccountSlice, selectGroupSlice } from "../../store"; @@ -202,6 +203,12 @@ export const Balances: React.FC = ({ groupId }) => { + + + + ); }; diff --git a/frontend/apps/web/src/pages/accounts/SettlementPlanDisplay.tsx b/frontend/apps/web/src/pages/accounts/SettlementPlanDisplay.tsx new file mode 100644 index 00000000..ee0ea7bb --- /dev/null +++ b/frontend/apps/web/src/pages/accounts/SettlementPlanDisplay.tsx @@ -0,0 +1,71 @@ +import { MobilePaper } from "@/components/style/mobile"; +import { selectAccountSlice, selectGroupSlice, useAppDispatch, useAppSelector } from "@/store"; +import { + createTransaction, + selectAccountIdToAccountMap, + selectGroupCurrencySymbol, + selectSettlementPlan, +} from "@abrechnung/redux"; +import { Button, List, ListItem, ListItemSecondaryAction, ListItemText, Typography } from "@mui/material"; +import * as React from "react"; +import { SettlementPlanItem } from "@abrechnung/core"; +import { useNavigate } from "react-router-dom"; + +interface Props { + groupId: number; +} + +export const SettlementPlanDisplay: React.FC = ({ groupId }) => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const settlementPlan = useAppSelector((state) => selectSettlementPlan({ state, groupId })); + const currencySymbol = useAppSelector((state) => + selectGroupCurrencySymbol({ state: selectGroupSlice(state), groupId }) + ); + const accountMap = useAppSelector((state) => + selectAccountIdToAccountMap({ state: selectAccountSlice(state), groupId }) + ); + + const onSettleClicked = (planItem: SettlementPlanItem) => { + dispatch( + createTransaction({ + type: "transfer", + groupId, + data: { + name: "Settlement", + value: planItem.paymentAmount, + creditorShares: { [planItem.creditorId]: 1 }, + debitorShares: { [planItem.debitorId]: 1 }, + }, + }) + ) + .unwrap() + .then(({ transaction }) => { + navigate(`/groups/${groupId}/transactions/${transaction.id}?no-redirect=true`); + }); + }; + + return ( + + Settle this groups balances + + {settlementPlan.map((planItem) => ( + + + {accountMap[planItem.creditorId].name} pays {accountMap[planItem.debitorId].name}{" "} + {planItem.paymentAmount.toFixed(2)} + {currencySymbol} + + } + /> + + + + + ))} + + + ); +}; diff --git a/frontend/apps/web/src/pages/auth/Login.tsx b/frontend/apps/web/src/pages/auth/Login.tsx index 644b0980..2a287f98 100644 --- a/frontend/apps/web/src/pages/auth/Login.tsx +++ b/frontend/apps/web/src/pages/auth/Login.tsx @@ -55,7 +55,7 @@ export const Login: React.FC = () => { setSubmitting(false); }) .catch((err) => { - toast.error(err); + toast.error(err.message); setSubmitting(false); }); }; diff --git a/frontend/apps/web/src/pages/groups/Group.tsx b/frontend/apps/web/src/pages/groups/Group.tsx index 3a03ee7e..70a6257b 100644 --- a/frontend/apps/web/src/pages/groups/Group.tsx +++ b/frontend/apps/web/src/pages/groups/Group.tsx @@ -12,7 +12,7 @@ import React, { Suspense } from "react"; import { batch } from "react-redux"; import { Navigate, Route, Routes, useParams } from "react-router-dom"; import { toast } from "react-toastify"; -import Balances from "../../components/accounts/Balances"; +import Balances from "../accounts/Balances"; import Loading from "../../components/style/Loading"; import { api, ws } from "../../core/api"; import { @@ -26,6 +26,7 @@ import { AccountDetail } from "../accounts/AccountDetail"; import { PersonalAccountList } from "../accounts/PersonalAccountList"; import { ClearingAccountList } from "../accounts/ClearingAccountList"; import { TransactionList } from "../transactions/TransactionList"; +import { SettlementPlanDisplay } from "../accounts/SettlementPlanDisplay"; import GroupInvites from "./GroupInvites"; import GroupLog from "./GroupLog"; import GroupMemberList from "./GroupMemberList"; @@ -147,6 +148,14 @@ export const Group: React.FC = () => { } /> + }> + + + } + /> = ({ groupId }) => { .then(({ oldTransactionId, transactionContainer }) => { setShowProgress(false); if (oldTransactionId !== transactionContainer.transaction.id) { - navigate(`/groups/${groupId}/transactions/${transactionContainer.transaction.id}?no-redirect=true`); + navigate( + `/groups/${groupId}/transactions/${transactionContainer.transaction.id}?no-redirect=true`, + { replace: true } + ); } }) .catch((err) => { diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionMetadata.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionMetadata.tsx index caae46b7..aa860ca2 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionMetadata.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionMetadata.tsx @@ -125,7 +125,6 @@ export const TransactionMetadata: React.FC = ({ name="description" variant="standard" margin="dense" - autoFocus fullWidth error={!!validationErrors.fieldErrors.description} helperText={validationErrors.fieldErrors.description} @@ -139,7 +138,6 @@ export const TransactionMetadata: React.FC = ({ name="value" variant="standard" margin="dense" - autoFocus fullWidth error={!!validationErrors.fieldErrors.value} helperText={validationErrors.fieldErrors.value} diff --git a/frontend/apps/web/tsconfig.json b/frontend/apps/web/tsconfig.json index 4a69b264..576a22f9 100644 --- a/frontend/apps/web/tsconfig.json +++ b/frontend/apps/web/tsconfig.json @@ -11,7 +11,16 @@ "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@abrechnung/api": ["../../libs/api/src/index.ts"], + "@abrechnung/core": ["../../libs/core/src/index.ts"], + "@abrechnung/redux": ["../../libs/redux/src/index.ts"], + "@abrechnung/types": ["../../libs/types/src/index.ts"], + "@abrechnung/utils": ["../../libs/utils/src/index.ts"], + "@/*": ["src/*"] + } }, "files": [], "include": [], diff --git a/frontend/libs/core/src/lib/accounts.test.ts b/frontend/libs/core/src/lib/accounts.test.ts index 5b72cf89..2beded13 100644 --- a/frontend/libs/core/src/lib/accounts.test.ts +++ b/frontend/libs/core/src/lib/accounts.test.ts @@ -1,5 +1,5 @@ import { Purchase, Account, AccountBalanceMap } from "@abrechnung/types"; -import { computeAccountBalances } from "./accounts"; +import { computeAccountBalances, computeGroupSettlement } from "./accounts"; const purchaseTemplate = { groupID: 0, @@ -348,3 +348,120 @@ describe("computeAccountBalances", () => { }); }); }); + +describe("computeGroupSettlement", () => { + it("should solve a simple 3 way inequality", () => { + const balances: AccountBalanceMap = { + 1: { + balance: 50, + beforeClearing: 50, + totalConsumed: 0, + totalPaid: 50, + clearingResolution: {}, + }, + 2: { + balance: -25, + beforeClearing: -25, + totalConsumed: 25, + totalPaid: 0, + clearingResolution: {}, + }, + 3: { + balance: -25, + beforeClearing: -25, + totalConsumed: 25, + totalPaid: 0, + clearingResolution: {}, + }, + }; + + const settlement = computeGroupSettlement(balances); + expect(settlement.length).toBe(2); + expect(settlement).toContainEqual({ creditorId: 2, debitorId: 1, paymentAmount: 25 }); + expect(settlement).toContainEqual({ creditorId: 3, debitorId: 1, paymentAmount: 25 }); + }); + it("should solve a more complex group balance with matching one to one payments", () => { + const balances: AccountBalanceMap = { + 1: { + balance: 100, + beforeClearing: 100, + totalConsumed: 0, + totalPaid: 100, + clearingResolution: {}, + }, + 2: { + balance: -50, + beforeClearing: -50, + totalConsumed: 50, + totalPaid: 0, + clearingResolution: {}, + }, + 3: { + balance: -30, + beforeClearing: -30, + totalConsumed: 30, + totalPaid: 0, + clearingResolution: {}, + }, + 4: { + balance: -80, + beforeClearing: -80, + totalConsumed: 80, + totalPaid: 0, + clearingResolution: {}, + }, + 5: { + balance: 50, + beforeClearing: 50, + totalConsumed: 0, + totalPaid: 50, + clearingResolution: {}, + }, + 6: { + balance: -45, + beforeClearing: -45, + totalConsumed: 45, + totalPaid: 0, + clearingResolution: {}, + }, + 7: { + balance: 65, + beforeClearing: 65, + totalConsumed: 0, + totalPaid: 65, + clearingResolution: {}, + }, + 8: { + balance: -10, + beforeClearing: -10, + totalConsumed: 10, + totalPaid: 0, + clearingResolution: {}, + }, + 9: { + balance: -60, + beforeClearing: -60, + totalConsumed: 60, + totalPaid: 0, + clearingResolution: {}, + }, + 10: { + balance: 60, + beforeClearing: 60, + totalConsumed: 0, + totalPaid: 60, + clearingResolution: {}, + }, + }; + + const settlement = computeGroupSettlement(balances); + expect(settlement.length).toBe(7); + expect(settlement).toContainEqual({ creditorId: 9, debitorId: 10, paymentAmount: 60 }); + expect(settlement).toContainEqual({ creditorId: 2, debitorId: 5, paymentAmount: 50 }); + expect(settlement).toContainEqual({ creditorId: 4, debitorId: 1, paymentAmount: 80 }); + expect(settlement).toContainEqual({ creditorId: 6, debitorId: 7, paymentAmount: 45 }); + expect(settlement).toContainEqual({ creditorId: 3, debitorId: 7, paymentAmount: 20 }); + expect(settlement).toContainEqual({ creditorId: 3, debitorId: 1, paymentAmount: 10 }); + expect(settlement).toContainEqual({ creditorId: 8, debitorId: 1, paymentAmount: 10 }); + }); +}); diff --git a/frontend/libs/core/src/lib/accounts.ts b/frontend/libs/core/src/lib/accounts.ts index a4370ffa..2d0d1e1d 100644 --- a/frontend/libs/core/src/lib/accounts.ts +++ b/frontend/libs/core/src/lib/accounts.ts @@ -247,3 +247,81 @@ export const computeAccountBalanceHistory = ( return accumulatedBalanceChanges; }; + +export type SettlementPlanItem = { creditorId: number; debitorId: number; paymentAmount: number }; +export type SettlementPlan = Array; +type SimplifiedBalances = Array<[number, number]>; // map of account ids to balances + +const balanceSortCompareFn = (a: [number, number], b: [number, number]) => { + return Math.abs(a[1]) - Math.abs(b[1]); +}; + +// assumes a sorted balance list +const extractOneToOneSettlements = ( + creditors: SimplifiedBalances, + debitors: SimplifiedBalances, + result: SettlementPlan +): void => { + // all input parameters are also output parameters + let credI = 0; + let debI = 0; + while (credI < creditors.length && debI < debitors.length) { + const [creditor, creditorBalance] = creditors[credI]; + const [debitor, debitorBalance] = debitors[debI]; + if (Math.abs(creditorBalance) === Math.abs(debitorBalance)) { + creditors.splice(credI, 1); + debitors.splice(debI, 1); + result.push({ creditorId: debitor, debitorId: creditor, paymentAmount: creditorBalance }); + continue; + } else if (debI == debitors.length - 1) { + credI++; + } else if (credI == creditors.length - 1) { + debI++; + } else if (Math.abs(creditorBalance) < Math.abs(debitorBalance)) { + credI++; + } else { + debI++; + } + } +}; + +export const computeGroupSettlement = (balances: AccountBalanceMap): SettlementPlan => { + const b: SimplifiedBalances = Object.entries(balances).map(([accountId, balance]) => { + return [Number(accountId), balance.balance]; + }, {}); + const creditors = b.filter(([, balance]) => balance > 0); + const debitors = b.filter(([, balance]) => balance < 0); + + const result: SettlementPlan = []; + while (creditors.length > 0 && debitors.length > 0) { + creditors.sort(balanceSortCompareFn); + debitors.sort(balanceSortCompareFn); + extractOneToOneSettlements(creditors, debitors, result); + const nextDebitor = debitors.pop(); + const nextCreditor = creditors.pop(); + if (nextDebitor == undefined || nextCreditor == undefined) { + break; + } + const [debitor, debitorBalance] = nextDebitor; + const [creditor, creditorBalance] = nextCreditor; + + let amount; + if (Math.abs(debitorBalance) > Math.abs(creditorBalance)) { + amount = Math.abs(creditorBalance); + } else { + amount = Math.abs(debitorBalance); + } + result.push({ creditorId: debitor, debitorId: creditor, paymentAmount: amount }); + const newDebitorBalance = debitorBalance + amount; + if (newDebitorBalance < 0) { + debitors.push([debitor, newDebitorBalance]); + } + + const newCreditorBalance = creditorBalance - amount; + if (newCreditorBalance > 0) { + creditors.push([creditor, newCreditorBalance]); + } + } + + return result.filter((planItem) => Math.round((planItem.paymentAmount + Number.EPSILON) * 100) / 100 !== 0); +}; diff --git a/frontend/libs/redux/src/lib/accounts/accountSlice.ts b/frontend/libs/redux/src/lib/accounts/accountSlice.ts index a854a2d8..6f10a01d 100644 --- a/frontend/libs/redux/src/lib/accounts/accountSlice.ts +++ b/frontend/libs/redux/src/lib/accounts/accountSlice.ts @@ -209,6 +209,19 @@ export const selectClearingAccountsInvolvingAccounts = memoize( } ); +export const selectAccountIdToAccountMapInternal = (args: { + state: AccountSliceState; + groupId: number; +}): { [k: number]: Account } => { + const accounts = selectGroupAccountsInternal(args); + return accounts.reduce<{ [k: number]: Account }>((map, account) => { + map[account.id] = account; + return map; + }, {}); +}; + +export const selectAccountIdToAccountMap = memoize(selectAccountIdToAccountMapInternal); + export const selectAccountIdToNameMapInternal = (args: { state: AccountSliceState; groupId: number; diff --git a/frontend/libs/redux/src/lib/selectors.ts b/frontend/libs/redux/src/lib/selectors.ts index 9e6e9d6d..813fda02 100644 --- a/frontend/libs/redux/src/lib/selectors.ts +++ b/frontend/libs/redux/src/lib/selectors.ts @@ -2,7 +2,9 @@ import { BalanceHistoryEntry, computeAccountBalanceHistory, computeAccountBalances, + computeGroupSettlement, getTransactionSortFunc, + SettlementPlan, TransactionSortMode, } from "@abrechnung/core"; import { AccountBalanceMap, Transaction } from "@abrechnung/types"; @@ -139,3 +141,9 @@ export const selectSortedTransactions = memoize( }, { size: 5 } ); + +export const selectSettlementPlan = memoize((args: { state: IRootState; groupId: number }): SettlementPlan => { + const { state, groupId } = args; + const balances = selectAccountBalancesInternal({ state, groupId }); + return computeGroupSettlement(balances); +}); diff --git a/frontend/libs/redux/src/lib/transactions/transactionSlice.ts b/frontend/libs/redux/src/lib/transactions/transactionSlice.ts index cc5be7dd..16564230 100644 --- a/frontend/libs/redux/src/lib/transactions/transactionSlice.ts +++ b/frontend/libs/redux/src/lib/transactions/transactionSlice.ts @@ -9,6 +9,7 @@ import { TransactionContainer, TransactionPosition, TransactionType, + TransactionTypeMap, } from "@abrechnung/types"; import { toISODateString } from "@abrechnung/utils"; import { createAsyncThunk, createSlice, Draft, PayloadAction } from "@reduxjs/toolkit"; @@ -417,9 +418,26 @@ export const fetchTransaction = createAsyncThunk< export const createTransaction = createAsyncThunk< { transaction: Transaction }, - { groupId: number; type: TransactionType }, + { + groupId: number; + type: TransactionType; + data?: Partial< + Omit< + Transaction, + | "id" + | "type" + | "positions" + | "groupId" + | "hasLocalChanges" + | "isWip" + | "lastChanged" + | "deleted" + | "attachments" + > + >; + }, { state: ITransactionRootState } ->("createPurchase", async ({ groupId, type }, { getState, dispatch }) => { +>("createPurchase", async ({ groupId, type, data }, { getState, dispatch }) => { const state = getState(); const transactionId = state.transactions.nextLocalTransactionId; const transactionBase = { @@ -440,6 +458,7 @@ export const createTransaction = createAsyncThunk< hasLocalChanges: true, isWip: true, lastChanged: new Date().toISOString(), + ...data, }; let transaction: Transaction; if (type === "purchase") { diff --git a/frontend/libs/types/src/lib/accounts.ts b/frontend/libs/types/src/lib/accounts.ts index fcab4a3c..d8d47bae 100644 --- a/frontend/libs/types/src/lib/accounts.ts +++ b/frontend/libs/types/src/lib/accounts.ts @@ -75,7 +75,7 @@ export interface AccountBalance { beforeClearing: number; totalConsumed: number; totalPaid: number; - clearingResolution: { [k: number]: number }; // TODO: make plain old object + clearingResolution: { [k: number]: number }; } export type AccountBalanceMap = { [k: number]: AccountBalance }; diff --git a/frontend/libs/types/src/lib/transactions.ts b/frontend/libs/types/src/lib/transactions.ts index 66bba965..b604c059 100644 --- a/frontend/libs/types/src/lib/transactions.ts +++ b/frontend/libs/types/src/lib/transactions.ts @@ -131,6 +131,11 @@ interface TransactionMetadata { export type Purchase = PurchaseBase & TransactionMetadata; export type Transfer = TransferBase & TransactionMetadata; +export type TransactionTypeMap = { + purchase: Purchase; + transfer: Transfer; +}; + export type Transaction = Purchase | Transfer; /** diff --git a/pyproject.toml b/pyproject.toml index 7319a174..c47a3dcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,8 @@ classifiers = [ ] requires-python = ">=3.9" dependencies = [ - "fastapi~=0.88", + "fastapi~=0.102", + "pydantic[email]~=2.3.0", "uvicorn[standard]~=0.20", "python-jose[cryptography]~=3.3", "asyncpg~=0.27", @@ -28,7 +29,6 @@ dependencies = [ "websockets~=10.4", "python-multipart~=0.0.5", "PyYAML~=6.0", - "email-validator~=1.3", "schema~=0.7", ] @@ -41,9 +41,9 @@ test = [ ] dev = [ "black", - "mypy~=0.991", + "mypy~=1.5", "types-PyYAML~=6.0", - "pylint~=2.15", + "pylint~=2.17", ] docs = [ "sphinx", diff --git a/tests/test_transaction_logic.py b/tests/test_transaction_logic.py index 28b81a7a..8d609354 100644 --- a/tests/test_transaction_logic.py +++ b/tests/test_transaction_logic.py @@ -183,7 +183,10 @@ async def test_file_upload(self): self.assertEqual(file_id, transaction.committed_files[0].id) self.assertIsNotNone(transaction.committed_files[0].blob_id) - (mime_type, _,) = await self.transaction_service.read_file_contents( + ( + mime_type, + _, + ) = await self.transaction_service.read_file_contents( user=self.user, file_id=file_id, blob_id=transaction.committed_files[0].blob_id, diff --git a/tools/build_debian_packages.py b/tools/build_debian_packages.py index dcfeec8c..ee9331a4 100755 --- a/tools/build_debian_packages.py +++ b/tools/build_debian_packages.py @@ -24,6 +24,7 @@ DISTS = ( "debian:bullseye", "debian:bookworm", + "debian:trixie", "debian:sid", "ubuntu:jammy", # 22.04 ) @@ -56,7 +57,7 @@ def run_build(self, dist: str, skip_tests=False): if self._failed: print("not building %s due to earlier failure" % (dist,)) - raise Exception("failed") + raise RuntimeError("failed") try: self._inner_build(dist, skip_tests)