From ed84659e8282485f8af96e9b76663e907843e2cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Sun, 23 Apr 2023 10:56:11 +0200 Subject: [PATCH 01/11] feat: implement group settlements --- .../accounts/Balances.tsx | 12 +- .../pages/accounts/SettlementPlanDisplay.tsx | 42 +++++++ frontend/apps/web/src/pages/groups/Group.tsx | 11 +- frontend/libs/core/src/lib/accounts.test.ts | 119 +++++++++++++++++- frontend/libs/core/src/lib/accounts.ts | 77 ++++++++++++ .../redux/src/lib/accounts/accountSlice.ts | 13 ++ frontend/libs/redux/src/lib/selectors.ts | 8 ++ frontend/libs/types/src/lib/accounts.ts | 2 +- 8 files changed, 277 insertions(+), 7 deletions(-) rename frontend/apps/web/src/{components => pages}/accounts/Balances.tsx (96%) create mode 100644 frontend/apps/web/src/pages/accounts/SettlementPlanDisplay.tsx diff --git a/frontend/apps/web/src/components/accounts/Balances.tsx b/frontend/apps/web/src/pages/accounts/Balances.tsx similarity index 96% rename from frontend/apps/web/src/components/accounts/Balances.tsx rename to frontend/apps/web/src/pages/accounts/Balances.tsx index e01b33ae..49ef2057 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"; @@ -86,6 +87,9 @@ export const Balances: React.FC = ({ groupId }) => { return ( + setSelectedTab(idx)} centered> 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..955d0577 --- /dev/null +++ b/frontend/apps/web/src/pages/accounts/SettlementPlanDisplay.tsx @@ -0,0 +1,42 @@ +import { selectAccountIdToAccountMap, selectGroupCurrencySymbol, selectSettlementPlan } from "@abrechnung/redux"; +import { Button, List, ListItem, ListItemSecondaryAction, ListItemText } from "@mui/material"; +import * as React from "react"; +import { MobilePaper } from "../../components/style/mobile"; +import { selectAccountSlice, selectGroupSlice, useAppSelector } from "../../store"; + +interface Props { + groupId: number; +} + +export const SettlementPlanDisplay: React.FC = ({ groupId }) => { + 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 }) + ); + + return ( + + + {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/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 = () => { } /> + }> + + + } + /> { }); }); }); + +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..2c55f087 100644 --- a/frontend/libs/core/src/lib/accounts.ts +++ b/frontend/libs/core/src/lib/accounts.ts @@ -247,3 +247,80 @@ export const computeAccountBalanceHistory = ( return accumulatedBalanceChanges; }; + +export type SettlementPlan = Array<{ creditorId: number; debitorId: number; paymentAmount: number }>; +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; +}; 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/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 }; From bda7ea456e19c9ab90b266e5c3c61804c12d82e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Sat, 26 Aug 2023 18:33:38 +0200 Subject: [PATCH 02/11] fix(web): correct back button behavior and auto focus --- .../web/src/pages/accounts/AccountDetail/AccountInfo.tsx | 2 +- .../transactions/TransactionDetail/TransactionActions.tsx | 2 +- .../transactions/TransactionDetail/TransactionDetail.tsx | 5 ++++- .../transactions/TransactionDetail/TransactionMetadata.tsx | 2 -- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/apps/web/src/pages/accounts/AccountDetail/AccountInfo.tsx b/frontend/apps/web/src/pages/accounts/AccountDetail/AccountInfo.tsx index 58668f29..488de7e7 100644 --- a/frontend/apps/web/src/pages/accounts/AccountDetail/AccountInfo.tsx +++ b/frontend/apps/web/src/pages/accounts/AccountDetail/AccountInfo.tsx @@ -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/pages/transactions/TransactionDetail/TransactionActions.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionActions.tsx index ee199dc2..845d8c7d 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionActions.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionActions.tsx @@ -12,7 +12,7 @@ import { LinearProgress, } from "@mui/material"; import React, { useState } from "react"; -import { Link as RouterLink, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { selectTransactionSlice, useAppSelector } from "../../../store"; interface Props { diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx index be81b214..052a4dd5 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx @@ -137,7 +137,10 @@ export const TransactionDetail: 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} From b21e88dd240f688c26405ce43ec433b0a40d0ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Sat, 26 Aug 2023 18:34:28 +0200 Subject: [PATCH 03/11] fix(web): correct error display in login --- frontend/apps/web/src/pages/auth/Login.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); }); }; From 5130ce95eba483b1b164e8b2b701623a5958317d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Sat, 26 Aug 2023 18:34:50 +0200 Subject: [PATCH 04/11] feat(core): upgrade to latest fastapi --- abrechnung/config.py | 22 +++++++++++----------- abrechnung/http/routers/accounts.py | 2 +- abrechnung/http/routers/common.py | 2 +- abrechnung/http/routers/groups.py | 2 +- pyproject.toml | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) 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/pyproject.toml b/pyproject.toml index 7319a174..c2c49e91 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", ] From f11e1148c47532104eca9bd5d650ff2ad8462483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Sat, 26 Aug 2023 19:56:42 +0200 Subject: [PATCH 05/11] feat(web): prettify settlement plan --- .../accounts/AccountDetail/AccountInfo.tsx | 18 ++++----- .../apps/web/src/pages/accounts/Balances.tsx | 9 +++-- .../pages/accounts/SettlementPlanDisplay.tsx | 39 ++++++++++++++++--- .../TransactionDetail/TransactionDetail.tsx | 10 ++--- frontend/apps/web/tsconfig.json | 11 +++++- frontend/libs/core/src/lib/accounts.ts | 5 ++- .../src/lib/transactions/transactionSlice.ts | 23 ++++++++++- frontend/libs/types/src/lib/transactions.ts | 5 +++ 8 files changed, 93 insertions(+), 27 deletions(-) diff --git a/frontend/apps/web/src/pages/accounts/AccountDetail/AccountInfo.tsx b/frontend/apps/web/src/pages/accounts/AccountDetail/AccountInfo.tsx index 488de7e7..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; diff --git a/frontend/apps/web/src/pages/accounts/Balances.tsx b/frontend/apps/web/src/pages/accounts/Balances.tsx index 49ef2057..eb380e74 100644 --- a/frontend/apps/web/src/pages/accounts/Balances.tsx +++ b/frontend/apps/web/src/pages/accounts/Balances.tsx @@ -87,9 +87,6 @@ export const Balances: React.FC = ({ groupId }) => { return ( - setSelectedTab(idx)} centered> @@ -206,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 index 955d0577..ee0ea7bb 100644 --- a/frontend/apps/web/src/pages/accounts/SettlementPlanDisplay.tsx +++ b/frontend/apps/web/src/pages/accounts/SettlementPlanDisplay.tsx @@ -1,14 +1,23 @@ -import { selectAccountIdToAccountMap, selectGroupCurrencySymbol, selectSettlementPlan } from "@abrechnung/redux"; -import { Button, List, ListItem, ListItemSecondaryAction, ListItemText } from "@mui/material"; +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 { MobilePaper } from "../../components/style/mobile"; -import { selectAccountSlice, selectGroupSlice, useAppSelector } from "../../store"; +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 }) @@ -17,8 +26,28 @@ export const SettlementPlanDisplay: React.FC = ({ groupId }) => { 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) => ( @@ -32,7 +61,7 @@ export const SettlementPlanDisplay: React.FC = ({ groupId }) => { } /> - + ))} diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx index 052a4dd5..91321300 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx @@ -15,11 +15,11 @@ import * as React from "react"; import { Navigate, useNavigate, useParams } from "react-router-dom"; import { toast } from "react-toastify"; import { typeToFlattenedError, z } from "zod"; -import Loading from "../../../components/style/Loading"; -import { MobilePaper } from "../../../components/style/mobile"; -import { api } from "../../../core/api"; -import { useQuery, useTitle } from "../../../core/utils"; -import { selectGroupSlice, selectTransactionSlice, useAppDispatch, useAppSelector } from "../../../store"; +import Loading from "@/components/style/Loading"; +import { MobilePaper } from "@/components/style/mobile"; +import { api } from "@/core/api"; +import { useQuery, useTitle } from "@/core/utils"; +import { selectGroupSlice, selectTransactionSlice, useAppDispatch, useAppSelector } from "@/store"; import { TransactionPositions, ValidationErrors as PositionValidationErrors } from "./purchase/TransactionPositions"; import { TransactionActions } from "./TransactionActions"; import { TransactionMetadata } from "./TransactionMetadata"; 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.ts b/frontend/libs/core/src/lib/accounts.ts index 2c55f087..2d0d1e1d 100644 --- a/frontend/libs/core/src/lib/accounts.ts +++ b/frontend/libs/core/src/lib/accounts.ts @@ -248,7 +248,8 @@ export const computeAccountBalanceHistory = ( return accumulatedBalanceChanges; }; -export type SettlementPlan = Array<{ creditorId: number; debitorId: number; paymentAmount: number }>; +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]) => { @@ -322,5 +323,5 @@ export const computeGroupSettlement = (balances: AccountBalanceMap): SettlementP } } - return result; + return result.filter((planItem) => Math.round((planItem.paymentAmount + Number.EPSILON) * 100) / 100 !== 0); }; 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/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; /** From 36364436d77e5e604e9011cefa9275e5724a54b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Sat, 26 Aug 2023 20:21:47 +0200 Subject: [PATCH 06/11] chore: bump everything to bookworm as new default --- .github/workflows/release-artifacts.yaml | 4 ++-- debian/changelog | 10 ++++++++-- debian/copyright | 2 +- docs/usage/installation.rst | 4 ++-- tools/build_debian_packages.py | 1 + 5 files changed, 14 insertions(+), 7 deletions(-) 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/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/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/tools/build_debian_packages.py b/tools/build_debian_packages.py index dcfeec8c..1c4aac77 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 ) From ae962f1b4bdac42a70252e53f4897da752e197b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Sat, 26 Aug 2023 20:23:22 +0200 Subject: [PATCH 07/11] fix: formatting --- abrechnung/application/transactions.py | 1 - tests/test_transaction_logic.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) 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/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, From 399917dff57a28e6381cdc618eca546b95692d47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Sat, 26 Aug 2023 20:27:11 +0200 Subject: [PATCH 08/11] docs: remove extensive config doc due to new pydantic versions --- docs/conf.py | 47 +----------------------------------- docs/usage/configuration.rst | 7 ------ 2 files changed, 1 insertion(+), 53 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 8f245b3c..419f4a7d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,7 +17,7 @@ import pydantic import yaml -from pydantic.fields import ModelField +from pydantic.fields import FieldInfo HERE = Path(__file__).parent sys.path[:0] = [str(HERE.parent), str(HERE / "_ext")] @@ -115,50 +115,5 @@ 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 From 019c277a4950971315dff6eb004dd957ea3b1661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Sat, 26 Aug 2023 20:28:35 +0200 Subject: [PATCH 09/11] fix(core): correct type checks for file upload --- abrechnung/http/routers/transactions.py | 6 ++++++ 1 file changed, 6 insertions(+) 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, From cc9c577fde86be4fd491844a28c21c7012729051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Sat, 26 Aug 2023 20:29:06 +0200 Subject: [PATCH 10/11] fix: formatting --- docs/conf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 419f4a7d..8680cb58 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -115,5 +115,4 @@ def generate_openapi_json(): json.dump(api.api.openapi(), f) - generate_openapi_json() From cf8d4df77e3f78fa5d783c7bd6c56145d3fd6f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Sat, 26 Aug 2023 20:33:16 +0200 Subject: [PATCH 11/11] fix: linters and formatting --- docs/conf.py | 4 ---- pyproject.toml | 4 ++-- tools/build_debian_packages.py | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 8680cb58..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 FieldInfo - HERE = Path(__file__).parent sys.path[:0] = [str(HERE.parent), str(HERE / "_ext")] BUILD_DIR = HERE / "_build" diff --git a/pyproject.toml b/pyproject.toml index c2c49e91..c47a3dcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/tools/build_debian_packages.py b/tools/build_debian_packages.py index 1c4aac77..ee9331a4 100755 --- a/tools/build_debian_packages.py +++ b/tools/build_debian_packages.py @@ -57,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)