feat: add income based balances

This commit is contained in:
Nicola Zambello 2022-02-21 18:08:35 +01:00
parent a34358bfa0
commit 60b551801a
9 changed files with 236 additions and 52 deletions

View file

@ -61,6 +61,13 @@ function validateTeamId(teamId: unknown) {
} }
} }
function validateIncome(income: unknown) {
if (typeof income !== "number") {
console.log(income, typeof income);
return "Income must be a positive number or zero";
}
}
type ActionData = { type ActionData = {
formError?: string; formError?: string;
success?: string; success?: string;
@ -69,12 +76,14 @@ type ActionData = {
teamId: string | undefined; teamId: string | undefined;
password: string | undefined; password: string | undefined;
confirmPassword: string | undefined; confirmPassword: string | undefined;
avgIncome: string | undefined;
}; };
fields?: { fields?: {
password?: string; password?: string;
confirmPassword?: string; confirmPassword?: string;
teamId?: string; teamId?: string;
icon?: string; icon?: string;
avgIncome?: number;
}; };
}; };
@ -91,6 +100,7 @@ export const action: ActionFunction = async ({ request }) => {
const confirmPassword = form.get("confirmPassword"); const confirmPassword = form.get("confirmPassword");
const icon = form.get("icon") ?? (user ? user.username[0] : undefined); const icon = form.get("icon") ?? (user ? user.username[0] : undefined);
const teamId = form.get("teamId"); const teamId = form.get("teamId");
const avgIncome = form.get("avgIncome");
const fields = { const fields = {
icon: typeof icon === "string" ? icon : undefined, icon: typeof icon === "string" ? icon : undefined,
@ -98,6 +108,12 @@ export const action: ActionFunction = async ({ request }) => {
confirmPassword: confirmPassword:
typeof confirmPassword === "string" ? confirmPassword : undefined, typeof confirmPassword === "string" ? confirmPassword : undefined,
teamId: typeof teamId === "string" ? teamId : undefined, teamId: typeof teamId === "string" ? teamId : undefined,
avgIncome:
typeof avgIncome === "number"
? Math.round(avgIncome)
: typeof avgIncome === "string"
? parseInt(avgIncome, 10)
: undefined,
}; };
const fieldErrors = { const fieldErrors = {
password: validatePassword(password), password: validatePassword(password),
@ -107,14 +123,19 @@ export const action: ActionFunction = async ({ request }) => {
), ),
icon: validateIcon(icon), icon: validateIcon(icon),
teamId: validateTeamId(teamId), teamId: validateTeamId(teamId),
avgIncome: validateIncome(fields.avgIncome),
}; };
if (Object.values(fieldErrors).some(Boolean)) if (Object.values(fieldErrors).some(Boolean))
return badRequest({ fieldErrors, fields }); return badRequest({ fieldErrors, fields });
const nonEmptyFields = Object.entries(fields).reduce((acc, [key, value]) => { const nonEmptyFields = Object.entries(fields).reduce((acc, [key, value]) => {
if (typeof value === "string" && key !== "confirmPassword") if (
(typeof value === "string" && key !== "confirmPassword") ||
(typeof value === "number" && key === "avgIncome")
)
return { ...acc, [key]: value ?? undefined }; return { ...acc, [key]: value ?? undefined };
else return acc;
return acc;
}, {}); }, {});
const userUpdated = await updateUser({ const userUpdated = await updateUser({
id: user.id, id: user.id,
@ -299,6 +320,48 @@ export default function AccountPreferencesRoute() {
</div> </div>
)} )}
</div> </div>
<div className="form-control mb-3">
<label htmlFor="avgIncome-input" className="label">
<span className="label-text">Average monthly income</span>
</label>
<input
type="number"
id="avgIncome-input"
name="avgIncome"
className={`input input-bordered${
Boolean(actionData?.fieldErrors?.avgIncome) ? " input-error" : ""
}`}
defaultValue={
actionData?.fields?.avgIncome ?? data.user?.avgIncome ?? 0
}
aria-invalid={Boolean(actionData?.fieldErrors?.avgIncome)}
aria-describedby={
actionData?.fieldErrors?.avgIncome ? "avgIncome-error" : undefined
}
/>
{actionData?.fieldErrors?.avgIncome && (
<div className="alert alert-error mt-2" role="alert">
<div className="flex-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="w-6 h-6 mx-2 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
></path>
</svg>
<label id="avgIncome-error">
{actionData?.fieldErrors.avgIncome}
</label>
</div>
</div>
)}
</div>
<div className="form-control mb-3"> <div className="form-control mb-3">
<label htmlFor="teamId-input" className="label"> <label htmlFor="teamId-input" className="label">
<span className="label-text">Team</span> <span className="label-text">Team</span>
@ -310,7 +373,7 @@ export default function AccountPreferencesRoute() {
className={`input input-bordered${ className={`input input-bordered${
Boolean(actionData?.fieldErrors?.teamId) ? " input-error" : "" Boolean(actionData?.fieldErrors?.teamId) ? " input-error" : ""
}`} }`}
defaultValue={actionData?.fields?.teamId} defaultValue={actionData?.fields?.teamId ?? data.user?.teamId}
aria-invalid={Boolean(actionData?.fieldErrors?.teamId)} aria-invalid={Boolean(actionData?.fieldErrors?.teamId)}
aria-describedby={ aria-describedby={
actionData?.fieldErrors?.teamId ? "teamid-error" : undefined actionData?.fieldErrors?.teamId ? "teamid-error" : undefined

View file

@ -15,6 +15,7 @@ type LoaderData = {
count: number; count: number;
spent: number; spent: number;
dueAmount: number; dueAmount: number;
avgIncome: number;
}[]; }[];
totalExpenses: { totalExpenses: {
count: number; count: number;
@ -52,19 +53,33 @@ export const loader: LoaderFunction = async ({ request }) => {
icon: m.icon, icon: m.icon,
count: teamExpenses.find((e) => e.userId === m.id)?._count?._all ?? 0, count: teamExpenses.find((e) => e.userId === m.id)?._count?._all ?? 0,
spent: teamExpenses.find((e) => e.userId === m.id)?._sum?.amount ?? 0, spent: teamExpenses.find((e) => e.userId === m.id)?._sum?.amount ?? 0,
avgIncome: m.avgIncome ?? 0,
dueAmount: 0, dueAmount: 0,
})); }));
let totalExpenses = expensesByUser.reduce( let totalExpenses = expensesByUser.reduce(
(acc, { count, spent }) => ({ (acc, { count, spent, avgIncome }) => ({
count: acc.count + count, count: acc.count + count,
amount: acc.amount + spent, amount: acc.amount + spent,
incomes: acc.incomes + avgIncome,
}), }),
{ count: 0, amount: 0 } { count: 0, amount: 0, incomes: 0 }
); );
const avgPerUser = totalExpenses.amount / user.team.members.length; const avgPerUser = totalExpenses.amount / user.team.members.length;
const quotaPerUser = expensesByUser.reduce(
(acc: { [key: string]: number }, userData) => {
acc[userData.id] =
(userData.avgIncome / totalExpenses.incomes) * totalExpenses.amount;
return acc;
},
{}
);
let teamCounts = expensesByUser.map((userData) => ({ let teamCounts = expensesByUser.map((userData) => ({
...userData, ...userData,
dueAmount: avgPerUser - userData.spent, dueAmount: user.team.balanceByIncome
? quotaPerUser[userData.id] - userData.spent
: avgPerUser - userData.spent,
})); }));
const data: LoaderData = { const data: LoaderData = {
@ -123,7 +138,7 @@ export default function ExpensesIndexRoute() {
</div> </div>
<div className="col-span-2 md:col-span-1 card shadow-lg compact side bg-base-100"> <div className="col-span-2 md:col-span-1 card shadow-lg compact side bg-base-100">
<div className="flex-column items-center card-body !py-6"> <div className="flex-column items-center card-body !py-6">
<h2 className="card-title">Who needs to pay who</h2> <h2 className="card-title">Team balance</h2>
<div className="w-full shadow stats grid-cols-2 grid-flow-row-dense"> <div className="w-full shadow stats grid-cols-2 grid-flow-row-dense">
{data.teamCounts?.map((user) => ( {data.teamCounts?.map((user) => (
<div className="stat col-span-1" key={user.id}> <div className="stat col-span-1" key={user.id}>

View file

@ -34,7 +34,7 @@ function validateConfirmPassword(confirmPassword: unknown, password: string) {
} }
function validateIcon(icon: unknown) { function validateIcon(icon: unknown) {
if (typeof icon !== "string" || icon.length > 2) { if (typeof icon !== "string") {
return `Icons must be a single character, e.g. "A" or "😎"`; return `Icons must be a single character, e.g. "A" or "😎"`;
} }
} }
@ -70,9 +70,7 @@ export const action: ActionFunction = async ({ request }) => {
const username = form.get("username"); const username = form.get("username");
const password = form.get("password"); const password = form.get("password");
const confirmPassword = form.get("confirmPassword"); const confirmPassword = form.get("confirmPassword");
const icon = const icon = form.get("icon");
form.get("icon") ??
(typeof username === "string" ? username[0] : undefined);
const teamId = form.get("teamId"); const teamId = form.get("teamId");
const redirectTo = form.get("redirectTo") || "/expenses"; const redirectTo = form.get("redirectTo") || "/expenses";
if ( if (
@ -80,6 +78,7 @@ export const action: ActionFunction = async ({ request }) => {
typeof password !== "string" || typeof password !== "string" ||
typeof confirmPassword !== "string" || typeof confirmPassword !== "string" ||
typeof teamId !== "string" || typeof teamId !== "string" ||
typeof icon !== "string" ||
typeof redirectTo !== "string" typeof redirectTo !== "string"
) { ) {
return badRequest({ return badRequest({
@ -87,12 +86,12 @@ export const action: ActionFunction = async ({ request }) => {
}); });
} }
const fields = { username, password, confirmPassword, teamId }; const fields = { username, password, icon, confirmPassword, teamId };
const fieldErrors = { const fieldErrors = {
username: validateUsername(username), username: validateUsername(username),
password: validatePassword(password), password: validatePassword(password),
confirmPassword: validateConfirmPassword(confirmPassword, password), confirmPassword: validateConfirmPassword(confirmPassword, password),
icon: validateIcon(icon), icon: validateIcon(icon ?? ""),
teamId: validateTeamId(teamId), teamId: validateTeamId(teamId),
}; };
if (Object.values(fieldErrors).some(Boolean)) if (Object.values(fieldErrors).some(Boolean))

View file

@ -1,8 +1,9 @@
import type { LoaderFunction } from "remix"; import type { LoaderFunction, ActionFunction } from "remix";
import { useLoaderData, useCatch, redirect } from "remix"; import { useLoaderData, useCatch, redirect, Link, Form } from "remix";
import type { Team, User } from "@prisma/client"; import type { Team, User } from "@prisma/client";
import { getUser } from "~/utils/session.server";
import Header from "~/components/Header"; import Header from "~/components/Header";
import { db } from "~/utils/db.server";
import { requireUserId, getUser } from "~/utils/session.server";
type LoaderData = { type LoaderData = {
user: User & { user: User & {
@ -21,9 +22,43 @@ export const loader: LoaderFunction = async ({ request }) => {
return data; return data;
}; };
export const action: ActionFunction = async ({ request }) => {
const form = await request.formData();
if (form.get("_method") === "patch") {
const userId = await requireUserId(request);
const user = await getUser(request);
const balanceByIncomeField = form.get("balanceByIncome");
const balanceByIncome =
typeof balanceByIncomeField === "boolean"
? balanceByIncomeField
: typeof balanceByIncomeField === "string"
? Boolean(balanceByIncomeField)
: false;
const team = await db.team.findUnique({
where: { id: user?.teamId },
});
if (!team) {
throw new Response("Can't update what does not exist", { status: 404 });
}
if (user?.teamId !== team.id) {
throw new Response("Pssh, nice try. That's not your expense", {
status: 401,
});
}
await db.team.update({ where: { id: team.id }, data: { balanceByIncome } });
return redirect("/expenses");
}
};
export default function JokesIndexRoute() { export default function JokesIndexRoute() {
const data = useLoaderData<LoaderData>(); const data = useLoaderData<LoaderData>();
const allUsersHaveSetAvgIncome = data.user.team.members?.every(
(m) => m.avgIncome && m.avgIncome > 0
);
return ( return (
<> <>
<Header user={data.user} route="/account" /> <Header user={data.user} route="/account" />
@ -52,6 +87,64 @@ export default function JokesIndexRoute() {
</div> </div>
)) ))
)} )}
<div className="mt-10">
<h2 className="mb-4 text-xl">Balance based on income</h2>
<p>
To have an equal split based on everyone's income, you can select
this option. If of two people, one earns 1.5 times as much as the
other, then his or her share in the common expenses will be 1.5
times as much as the other's.
</p>
<Form method="post">
<input type="hidden" name="_method" value="patch" />
<fieldset disabled={!allUsersHaveSetAvgIncome}>
<div className="form-control mt-4">
<label className="cursor-pointer label justify-start">
<input
type="checkbox"
name="balanceByIncome"
defaultChecked={data.user.team.balanceByIncome ?? false}
className="checkbox checkbox-primary"
/>
<span className="label-text ml-2">
Enable balance based on income
</span>
</label>
</div>
</fieldset>
{!allUsersHaveSetAvgIncome ? (
<div className="alert shadow-lg">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="stroke-info-content flex-shrink-0 w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>
Not all users from this team set their average income.
</span>
</div>
<div className="flex-none">
<Link to="/account/manage" className="btn btn-sm">
Check in the Account page
</Link>
</div>
</div>
) : (
<button type="submit" className="mt-6 btn btn-primary">
Save
</button>
)}
</Form>
</div>
</main> </main>
</> </>
); );

View file

@ -10,7 +10,7 @@ type LoginForm = {
type RegisterForm = { type RegisterForm = {
username: string; username: string;
password: string; password: string;
icon: string; icon?: string;
teamId: string; teamId: string;
}; };
@ -19,6 +19,7 @@ type UpdateUserForm = {
password?: string; password?: string;
icon?: string; icon?: string;
teamId?: string; teamId?: string;
avgIncome?: number;
}; };
export async function register({ export async function register({
@ -38,7 +39,12 @@ export async function register({
}); });
} }
const user = await db.user.create({ const user = await db.user.create({
data: { username, passwordHash, icon: icon ?? username[0], teamId }, data: {
username,
passwordHash,
icon: icon && icon.length > 0 ? icon : username[0],
teamId,
},
}); });
return user; return user;
} }
@ -62,6 +68,7 @@ export async function updateUser({ id, ...data }: UpdateUserForm) {
...(data?.icon ? { icon: data.icon } : {}), ...(data?.icon ? { icon: data.icon } : {}),
...(data?.password ? { passwordHash } : {}), ...(data?.password ? { passwordHash } : {}),
...(data?.teamId ? { teamId: data.teamId } : {}), ...(data?.teamId ? { teamId: data.teamId } : {}),
...(data?.avgIncome ? { avgIncome: data.avgIncome } : {}),
}, },
where: { id }, where: { id },
}); });

View file

@ -8,9 +8,9 @@
"scripts": { "scripts": {
"build": "npm run build:css && npm run build:worker && remix build", "build": "npm run build:css && npm run build:worker && remix build",
"build:css": "tailwindcss -o ./app/tailwind.css", "build:css": "tailwindcss -o ./app/tailwind.css",
"build:worker": "esbuild ./app/entry.worker.tsx --outfile=./public/entry.worker.js --minify --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'", "build:worker": "esbuild ./app/entry.worker.tsx --outfile=./build/entry.worker.js --minify --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'",
"dev": "concurrently \"npm run dev:css\" \"npm run dev:worker\" \"remix dev\"", "dev": "concurrently \"npm run dev:css\" \"npm run dev:worker\" \"remix dev\"",
"dev:worker": "esbuild ./app/entry.worker.tsx --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch", "dev:worker": "esbuild ./app/entry.worker.tsx --outfile=./build/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch",
"dev:css": "tailwindcss -o ./app/tailwind.css --watch", "dev:css": "tailwindcss -o ./app/tailwind.css --watch",
"postinstall": "remix setup node", "postinstall": "remix setup node",
"prepare": "husky install", "prepare": "husky install",

View file

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "balanceByIncome" BOOLEAN DEFAULT false;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "avgIncome" INTEGER;

View file

@ -11,10 +11,11 @@ datasource db {
} }
model Team { model Team {
id String @id id String @id
icon String icon String
description String? description String?
members User[] members User[]
balanceByIncome Boolean? @default(value: false)
} }
model User { model User {
@ -28,6 +29,7 @@ model User {
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
expenses Expense[] expenses Expense[]
theme String? theme String?
avgIncome Int?
} }
model Expense { model Expense {

View file

@ -2,29 +2,29 @@ import { PrismaClient } from "@prisma/client";
const db = new PrismaClient(); const db = new PrismaClient();
async function seed() { async function seed() {
const famiglia = await db.team.create({ const team = await db.team.create({
data: { data: {
id: "Famiglia", id: "Family",
description: "La mia famiglia", description: "My family",
icon: "♥️", icon: "♥️",
}, },
}); });
const nicola = await db.user.create({ const user1 = await db.user.create({
data: { data: {
username: "nicola", username: "user1",
passwordHash: passwordHash:
"$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u", "$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u", // twixrox
teamId: famiglia.id, teamId: team.id,
icon: "🧑‍💻", icon: "🧑‍💻",
theme: "dark", theme: "dark",
}, },
}); });
const shahra = await db.user.create({ const user2 = await db.user.create({
data: { data: {
username: "shahra", username: "user2",
passwordHash: passwordHash:
"$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u", "$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u",
teamId: famiglia.id, teamId: team.id,
icon: "💃", icon: "💃",
theme: "emerald", theme: "emerald",
}, },
@ -32,43 +32,43 @@ async function seed() {
const expenses = [ const expenses = [
{ {
description: "Spesa", description: "Groceries",
amount: 100, amount: 100,
userId: nicola.id, userId: user1.id,
teamId: famiglia.id, teamId: team.id,
}, },
{ {
description: "Spesa", description: "Groceries",
amount: 70, amount: 70,
userId: shahra.id, userId: user2.id,
teamId: famiglia.id, teamId: team.id,
}, },
{ {
description: "Affitto", description: "Rent",
amount: 500, amount: 500,
userId: shahra.id, userId: user2.id,
teamId: famiglia.id, teamId: team.id,
}, },
// transaction between users // transaction between users
{ {
description: "Affitto", description: "Rent",
amount: 250, amount: 250,
userId: nicola.id, userId: user1.id,
teamId: famiglia.id, teamId: team.id,
}, },
{ {
description: "Affitto", description: "Rent",
amount: -250, amount: -250,
userId: shahra.id, userId: user2.id,
teamId: famiglia.id, teamId: team.id,
}, },
{ {
description: "Cena", description: "Dinner out",
amount: 50, amount: 50,
userId: nicola.id, userId: user1.id,
teamId: famiglia.id, teamId: team.id,
}, },
]; ];