Compare commits

..

No commits in common. "4761f0c25cad709aeb7ae7f417a17265c8b99ccf" and "a34358bfa019534dff510a0a83b2ba314d5bb0f9" have entirely different histories.

20 changed files with 133 additions and 1011 deletions

View file

@ -26,7 +26,7 @@ jobs:
run: | run: |
yarn install yarn install
PORT=5001 yarn build PORT=5001 yarn build
yarn prisma generate yarn prisma db push
- name: 🚀 Deploy - name: 🚀 Deploy
run: | run: |

View file

@ -1,25 +1,3 @@
### [1.1.1](https://github.com/nzambello/explit/compare/v1.1.0...v1.1.1) (2022-02-22)
### Maintenance
* disable gh action ([96c66b0](https://github.com/nzambello/explit/commit/96c66b0d1a6425cf278889536323e3173b10b468))
## [1.1.0](https://github.com/nzambello/explit/compare/v1.0.0...v1.1.0) (2022-02-22)
### Features
* add expenses list + filters ([6740469](https://github.com/nzambello/explit/commit/6740469f9470c412ca76a2c3bf865ab2f5f1f9a7))
* add income based balances ([60b5518](https://github.com/nzambello/explit/commit/60b551801a2a2cd5070f23e5e0439b857b075a72))
* add statistics ([b6674bc](https://github.com/nzambello/explit/commit/b6674bccd7a96983d2e66a988a759b89016d1d5f))
* edit expense, fix navigation on expense forms ([0fabc1e](https://github.com/nzambello/explit/commit/0fabc1edcc198d49602e9075d597674bfc6b36c9))
### Bug Fixes
* wf prisma generation ([0c269f6](https://github.com/nzambello/explit/commit/0c269f6e433e7879c38e8c6a979bd120e0b7e6c7))
## 1.0.0 (2022-02-21) ## 1.0.0 (2022-02-21)

View file

@ -4,54 +4,10 @@ Track and split shared expenses with friends and family.
![Explit](https://github.com/nzambello/explit/raw/main/public/explit.png) ![Explit](https://github.com/nzambello/explit/raw/main/public/explit.png)
## Features - [Remix Docs](https://remix.run/docs)
### User management
When signing up, you can choose to create a new user or to sign in with an existing user.
You can choose an icon, which can be an emoji or a letter to represent your user.
When entering the team, you can choose to create a new team or to join an existing team by its name.
Once logged in, in `/account/preferences` you can select a theme for the app and in `/account/manage` you can change your team, icon, and password.
### Track expenses
Once logged in, you can see a list of all your expenses and the balance of the team.
From the homepage, you can add an expense or transfer money to another user.
For every expense, you can see the amount, the date, the user who paid, and a description.
From the expense page, you can edit the amount and the description or delete the item.
### Balance equality
The goal of the app is to split equally the expenses among the team.
When you add an expense, the amount is added to the balance of the team.
Then, the balance of the team is calculated to show the amount of money each user should have or give.
#### Balance based on income
If enabled, the balance of the amount of money each user should have or give is based on each user's income.
There's an option in the `/team` page to enable this feature.
To have an equal split based on everyone's income, you can select
this option. If a team 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.
Otherwise, every user will have the same base amount of money.
### Statistics
The statistics page can show you how much you're spending currently and how much you've spent in previous months.
## Development ## Development
- [Remix Docs](https://remix.run/docs)
From your terminal: From your terminal:
```sh ```sh

View file

@ -1,55 +1,27 @@
const Filter = ({ const Filter = () => (
className,
active = false,
}: {
className?: string;
active?: boolean;
}) => (
<svg <svg
width="24" width="24"
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className={className}
> >
{active ? ( <path
<> d="M5.40002 2.09998H18.6C19.7 2.09998 20.6 2.99998 20.6 4.09998V6.29998C20.6 7.09998 20.1 8.09998 19.6 8.59998L15.3 12.4C14.7 12.9 14.3 13.9 14.3 14.7V19C14.3 19.6 13.9 20.4 13.4 20.7L12 21.6C10.7 22.4 8.90002 21.5 8.90002 19.9V14.6C8.90002 13.9 8.50002 13 8.10002 12.5L4.30002 8.49998C3.80002 7.99998 3.40002 7.09998 3.40002 6.49998V4.19998C3.40002 2.99998 4.30002 2.09998 5.40002 2.09998Z"
<path stroke="currentColor"
d="M20.7206 18.24L19.7806 17.3C20.2706 16.56 20.5606 15.67 20.5606 14.71C20.5606 12.11 18.4506 10 15.8506 10C13.2506 10 11.1406 12.11 11.1406 14.71C11.1406 17.31 13.2506 19.42 15.8506 19.42C16.8106 19.42 17.6906 19.13 18.4406 18.64L19.3806 19.58C19.5706 19.77 19.8106 19.86 20.0606 19.86C20.3106 19.86 20.5506 19.77 20.7406 19.58C21.0906 19.22 21.0906 18.62 20.7206 18.24Z" strokeWidth="1.5"
fill="currentColor" strokeMiterlimit="10"
/> strokeLinecap="round"
<path strokeLinejoin="round"
d="M19.5799 4.02V6.24C19.5799 7.05 19.0799 8.06 18.5799 8.57L18.3999 8.73C18.2599 8.86 18.0499 8.89 17.8699 8.83C17.6699 8.76 17.4699 8.71 17.2699 8.66C16.8299 8.55 16.3599 8.5 15.8799 8.5C12.4299 8.5 9.62992 11.3 9.62992 14.75C9.62992 15.89 9.93992 17.01 10.5299 17.97C11.0299 18.81 11.7299 19.51 12.4899 19.98C12.7199 20.13 12.8099 20.45 12.6099 20.63C12.5399 20.69 12.4699 20.74 12.3999 20.79L10.9999 21.7C9.69992 22.51 7.90992 21.6 7.90992 19.98V14.63C7.90992 13.92 7.50992 13.01 7.10992 12.51L3.31992 8.47C2.81992 7.96 2.41992 7.05 2.41992 6.45V4.12C2.41992 2.91 3.31992 2 4.40992 2H17.5899C18.6799 2 19.5799 2.91 19.5799 4.02Z" />
fill="currentColor" <path
/> d="M10.93 2.09998L6 9.99998"
</> stroke="#292D32"
) : ( strokeWidth="1.5"
<> strokeMiterlimit="10"
<path strokeLinecap="round"
d="M14.3201 19.07C14.3201 19.68 13.92 20.48 13.41 20.79L12.0001 21.7C10.6901 22.51 8.87006 21.6 8.87006 19.98V14.63C8.87006 13.92 8.47006 13.01 8.06006 12.51L4.22003 8.47C3.71003 7.96 3.31006 7.06001 3.31006 6.45001V4.13C3.31006 2.92 4.22008 2.01001 5.33008 2.01001H18.67C19.78 2.01001 20.6901 2.92 20.6901 4.03V6.25C20.6901 7.06 20.1801 8.07001 19.6801 8.57001" strokeLinejoin="round"
stroke="currentColor" />
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16.07 16.52C17.8373 16.52 19.27 15.0873 19.27 13.32C19.27 11.5527 17.8373 10.12 16.07 10.12C14.3027 10.12 12.87 11.5527 12.87 13.32C12.87 15.0873 14.3027 16.52 16.07 16.52Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19.87 17.12L18.87 16.12"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)}
</svg> </svg>
); );

View file

@ -61,13 +61,6 @@ 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;
@ -76,14 +69,12 @@ 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;
}; };
}; };
@ -100,7 +91,6 @@ 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,
@ -108,12 +98,6 @@ 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),
@ -123,19 +107,14 @@ 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 ( if (typeof value === "string" && key !== "confirmPassword")
(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,
@ -320,48 +299,6 @@ 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>
@ -373,7 +310,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 ?? data.user?.teamId} defaultValue={actionData?.fields?.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

@ -30,7 +30,7 @@ export default function ExpensesRoute() {
return ( return (
<> <>
<Header user={data.user} route="/expenses" /> <Header user={data.user} route="/expenses" />
<main className="container mx-auto px-2"> <main>
<Outlet /> <Outlet />
</main> </main>
</> </>

View file

@ -134,17 +134,11 @@ export default function ExpenseRoute() {
{data.isOwner && ( {data.isOwner && (
<> <>
<div className="flex justify-center align-center mt-6"> <div className="flex justify-center align-center mt-6">
<Link
to={`/expenses/${data.expense.id}/edit`}
className="btn btn-default mr-4"
>
Edit
</Link>
<label <label
htmlFor="delete-expense-modal" htmlFor="delete-expense-modal"
className="btn btn-error modal-button" className="btn btn-error modal-button"
> >
Delete Delete expense
</label> </label>
</div> </div>
<input <input

View file

@ -1,255 +0,0 @@
import { Expense } from "@prisma/client";
import type { ActionFunction, LoaderFunction } from "remix";
import {
useActionData,
redirect,
json,
useCatch,
Link,
Form,
useTransition,
useLoaderData,
} from "remix";
import { db } from "~/utils/db.server";
import { requireUserId, getUser } from "~/utils/session.server";
function validateExpenseDescription(description: string) {
if (description.length < 2) {
return `That expense's description is too short`;
}
}
type ActionData = {
formError?: string;
fieldErrors?: {
description: string | undefined;
amount?: string | undefined;
user?: string | undefined;
};
fields?: {
description: string;
amount: number;
};
};
type LoaderData = {
userId: string;
expense: Expense;
};
const badRequest = (data: ActionData) => json(data, { status: 400 });
export const loader: LoaderFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
if (!userId) {
throw new Response("Unauthorized", { status: 401 });
}
const expense = await db.expense.findUnique({
where: { id: params.expenseId },
});
if (!expense) {
throw new Response("What an expense! Not found.", { status: 404 });
}
const data: LoaderData = { userId, expense };
return data;
};
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
const user = await getUser(request);
const form = await request.formData();
const description = form.get("description");
const amount = parseInt(form.get("amount")?.toString() || "0", 10);
if (
typeof description !== "string" ||
typeof amount !== "number" ||
user === null
) {
return badRequest({
formError: `Form not submitted correctly.`,
});
}
const fieldErrors = {
description: validateExpenseDescription(description),
};
const fields = { description, amount };
if (Object.values(fieldErrors).some(Boolean)) {
return badRequest({ fieldErrors, fields });
}
const expense = await db.expense.create({
data: { ...fields, userId: userId, teamId: user.teamId },
});
if (!expense) {
return badRequest({
formError: `Could not create expense.`,
});
}
return redirect(`/expenses/${expense.id}`);
};
export default function EditExpenseRoute() {
const data = useLoaderData<LoaderData>();
const actionData = useActionData<ActionData>();
const transition = useTransition();
if (transition.submission) {
const description = transition.submission.formData.get("description");
const amount = transition.submission.formData.get("content");
if (
typeof description === "string" &&
typeof amount === "number" &&
!validateExpenseDescription(description)
) {
return (
<div>
<p>Description: {description}</p>
<p>Amount: {amount}</p>
<p>User: {data.userId}</p>
</div>
);
}
}
return (
<>
<div className="container mx-auto min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="card bg-base-200 w-full shadow-lg max-w-lg">
<div className="card-body w-full">
<h1 className="card-title">Edit expense</h1>
<Form
method="post"
className="mt-5"
aria-describedby={
actionData?.formError ? "form-error-message" : undefined
}
>
<div className="form-control mb-3">
<label className="label" htmlFor="description-input">
<span className="label-text">Description</span>
</label>
<input
type="text"
className="input input-bordered"
name="description"
id="description-input"
defaultValue={
actionData?.fields?.description ?? data.expense.description
}
aria-invalid={
Boolean(actionData?.fieldErrors?.description) || undefined
}
aria-describedby={
actionData?.fieldErrors?.description
? "description-error"
: undefined
}
/>
{actionData?.fieldErrors?.description && (
<div className="alert alert-error mt-3" 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="description-error">
{actionData?.fieldErrors.description}
</label>
</div>
</div>
)}
</div>
<div className="form-control mb-3">
<label className="label" htmlFor="amount-input">
<span className="label-text">Amount</span>
</label>
<input
type="number"
className="input input-bordered"
id="amount-input"
name="amount"
defaultValue={
actionData?.fields?.amount ?? data.expense.amount
}
/>
</div>
{actionData?.formError && (
<div className="alert alert-error mt-5" 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="form-error-message">
{actionData?.formError}
</label>
</div>
</div>
)}
<div className="text-center max-w-xs mx-auto mt-10">
<button type="submit" className="btn btn-primary btn-block">
Save
</button>
</div>
</Form>
</div>
</div>
</div>
<ul
className="menu px-3 menu-horizontal rounded-box max-w-xs mx-auto flex items-center justify-evenly"
role="navigation"
>
<li>
<Link
to={`/expenses/${data.expense.id}`}
className="btn btn-outline btn-accent"
>
Back
</Link>
</li>
</ul>
</>
);
}
export function CatchBoundary() {
const caught = useCatch();
if (caught.status === 401) {
return (
<div className="error-container">
<p>You must be logged in to submit an expense.</p>
<Link to="/login">Login</Link>
</div>
);
}
}
export function ErrorBoundary() {
return (
<div className="error-container">
Something unexpected went wrong. Sorry about that.
</div>
);
}

View file

@ -15,7 +15,6 @@ type LoaderData = {
count: number; count: number;
spent: number; spent: number;
dueAmount: number; dueAmount: number;
avgIncome: number;
}[]; }[];
totalExpenses: { totalExpenses: {
count: number; count: number;
@ -53,33 +52,19 @@ 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, avgIncome }) => ({ (acc, { count, spent }) => ({
count: acc.count + count, count: acc.count + count,
amount: acc.amount + spent, amount: acc.amount + spent,
incomes: acc.incomes + avgIncome,
}), }),
{ count: 0, amount: 0, incomes: 0 } { count: 0, amount: 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: user.team.balanceByIncome dueAmount: avgPerUser - userData.spent,
? quotaPerUser[userData.id] - userData.spent
: avgPerUser - userData.spent,
})); }));
const data: LoaderData = { const data: LoaderData = {
@ -138,7 +123,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">Team balance</h2> <h2 className="card-title">Who needs to pay who</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}>
@ -148,14 +133,11 @@ export default function ExpensesIndexRoute() {
</div> </div>
</div> </div>
<div <div
className={`stat-value text-2xl ${ className={`stat-value ${
user.dueAmount > 0 ? "text-error" : "text-success" user.dueAmount > 0 ? "text-error" : "text-success"
}`} }`}
> >
{user.dueAmount !== Math.round(user.dueAmount) {Math.abs(user.dueAmount)}
? Math.abs(user.dueAmount).toFixed(2)
: Math.abs(user.dueAmount)}{" "}
</div> </div>
<div className="stat-title text-lg">{user.username}</div> <div className="stat-title text-lg">{user.username}</div>
<div className="stat-desc text-info"> <div className="stat-desc text-info">

View file

@ -1,262 +1,33 @@
import type { User, Team, Expense } from "@prisma/client"; import { Link } from "remix";
import type { LoaderFunction } from "remix";
import { redirect, useLoaderData, useCatch, Link, Form } from "remix";
import Filter from "~/icons/Filter";
import { db } from "~/utils/db.server";
import { getUser, requireUserId } from "~/utils/session.server";
type LoaderData = {
user: (User & { team: Team & { members: User[] } }) | null;
expenses: (Expense & { user: User & { team: Team } })[];
expensesCount: number;
page: number;
filters: {
description: string | null | undefined;
dateFrom: string | null | undefined;
dateTo: string | null | undefined;
user: string | null | undefined;
};
};
export const loader: LoaderFunction = async ({ request }) => {
const userId = requireUserId(request);
const user = await getUser(request);
if (!user?.id || !userId) {
return redirect("/login");
}
const expensesCount = await db.expense.count({
where: { teamId: user.teamId },
});
const searchParams = new URL(request.url)?.searchParams;
const page = parseInt(searchParams.get("page") || "1", 10);
const description = searchParams.get("description");
const dateFrom = searchParams.get("dateFrom");
const dateTo = searchParams.get("dateTo");
const userIdParam = searchParams.get("user");
const filters = {
description:
description && description.length > 0 ? description : undefined,
dateFrom: dateFrom && dateFrom.length > 0 ? dateFrom : undefined,
dateTo: dateTo && dateTo.length > 0 ? dateTo : undefined,
user: userIdParam && userIdParam?.length > 0 ? userIdParam : undefined,
};
const expensesFilters = {
...(filters.description && {
description: { contains: filters.description },
}),
...((filters.dateFrom || filters.dateTo) && {
createdAt: {
...(filters.dateFrom && {
gte: new Date(`${filters.dateFrom}T00:00:00+0100`),
}),
...(filters.dateTo && {
lte: new Date(`${filters.dateTo}T00:00:00+0100`),
}),
},
}),
...(filters.user && { userId: filters.user }),
};
console.log("FILTERS", filters);
const expenses = await db.expense.findMany({
where: {
teamId: user.teamId,
...expensesFilters,
},
take: 10,
skip: (page - 1) * 10,
orderBy: {
createdAt: "desc",
},
include: {
user: {
include: {
team: true,
},
},
},
});
const data: LoaderData = {
user,
expenses,
expensesCount,
page,
filters,
};
return data;
};
export default function ListExpensesRoute() { export default function ListExpensesRoute() {
const data = useLoaderData<LoaderData>();
const hasFilters = Object.values(data.filters).some(
(value) => value !== undefined && value !== null
);
return ( return (
<> <div className="hero py-40 bg-base-200 my-8 rounded-box">
<h1 className="mb-6 mt-6 text-4xl font-bold">List expenses</h1> <div className="text-center hero-content">
<div className="max-w-md">
<label htmlFor="filters-modal" className="btn modal-button"> <h1 className="mb-5 text-5xl font-bold">Work in progress</h1>
<Filter <p className="mb-5">
className={`w-6 h-6 mr-2${hasFilters ? " text-primary" : ""}`} <button className="btn btn-lg loading"></button>
active={hasFilters} This page is under construction.
/> </p>
Filters <Link to="/expenses" className="btn btn-primary">
</label> <svg
xmlns="http://www.w3.org/2000/svg"
<input type="checkbox" id="filters-modal" className="modal-toggle" /> fill="none"
<div className="modal"> viewBox="0 0 24 24"
<div className="modal-box"> className="inline-block w-6 h-6 mr-2 stroke-current rotate-180"
<h2 className="font-bold text-lg">Filters</h2> >
<Form className="my-4"> <path
<div className="form-control w-full max-w-xs mb-4"> stroke-linecap="round"
<label className="label" htmlFor="filter-description"> stroke-linejoin="round"
<span className="label-text">Text</span> stroke-width="2"
</label> d="M9 5l7 7-7 7"
<input ></path>
type="text" </svg>
id="filter-description" Back
name="description" </Link>
placeholder="Search by description"
className="input input-bordered w-full max-w-xs"
defaultValue={data.filters.description ?? ""}
/>
</div>
<div className="form-control w-full max-w-xs mb-4">
<label className="label" htmlFor="filter-dateFrom">
<span className="label-text">Date from</span>
</label>
<input
type="date"
id="filter-dateFrom"
name="dateFrom"
placeholder={new Intl.DateTimeFormat("it", {
dateStyle: "short",
}).format(new Date())}
className="input input-bordered w-full max-w-xs"
defaultValue={data.filters.dateFrom ?? ""}
/>
</div>
<div className="form-control w-full max-w-xs mb-4">
<label className="label" htmlFor="filter-dateTo">
<span className="label-text">Date to</span>
</label>
<input
type="date"
id="filter-dateTo"
name="dateTo"
placeholder={new Intl.DateTimeFormat("it", {
dateStyle: "short",
}).format(new Date())}
className="input input-bordered w-full max-w-xs"
defaultValue={data.filters.dateTo ?? ""}
/>
</div>
<div className="form-control w-full max-w-xs mb-4">
<label className="label" htmlFor="filter-user">
<span className="label-text">User</span>
</label>
<select
name="user"
id="filter-user"
className="select select-bordered w-full max-w-xs"
defaultValue={data.filters.user ?? ""}
>
<option value="">Choose an user</option>
{data.user?.team?.members?.map((user) => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
</div>
<div className="mt-6 text-center">
<label htmlFor="filters-modal" className="btn btn-default mr-4">
Close
</label>
<Link to={`?page=${data.page}`} className="btn btn-default mr-4">
Clear
</Link>
<button type="submit" className="btn btn-primary">
Apply
</button>
</div>
</Form>
</div> </div>
</div> </div>
</div>
<div className="overflow-x-auto bg-base-100 shadow-xl rounded-box mt-6 mb-10">
<table className="table table-zebra w-full">
<thead>
<tr>
<th></th>
<th>Date</th>
<th>User</th>
<th>Amount</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{data.expenses?.map((exp) => (
<tr key={exp.id}>
<td className="sticky left-0 z-10 shadow-lg">
<Link
to={`/expenses/${exp.id}`}
className="btn btn-sm btn-primary"
>
See
</Link>
</td>
<td>
{new Intl.DateTimeFormat("it", {
dateStyle: "short",
}).format(new Date(exp.createdAt))}
</td>
<td>{exp.user.username}</td>
<td>{exp.amount} </td>
<td>{exp.description}</td>
</tr>
))}
</tbody>
</table>
</div>
{data.expensesCount > 10 && (
<div className="btn-group justify-center my-8">
{[...new Array(Math.ceil(data.expensesCount / 10)).keys()].map(
(p) => (
<Link
to={`?page=${p + 1}`}
key={p}
className={`btn${data.page === p + 1 ? " btn-active" : ""}`}
>
{p + 1}
</Link>
)
)}
</div>
)}
</>
); );
} }
export function CatchBoundary() {
const caught = useCatch();
if (caught.status === 401) {
return redirect("/login");
}
if (caught.status === 404) {
return <div className="error-container">There is no data to display.</div>;
}
throw new Error(`Unexpected caught response with status: ${caught.status}`);
}
export function ErrorBoundary() {
return <div className="error-container">I did a whoopsies.</div>;
}

View file

@ -202,10 +202,7 @@ export default function NewExpenseRoute() {
</div> </div>
</div> </div>
</div> </div>
<ul <ul className="menu px-3 menu-horizontal rounded-box max-w-xs mx-auto flex items-center justify-evenly">
className="menu px-3 menu-horizontal rounded-box max-w-xs mx-auto flex items-center justify-evenly"
role="navigation"
>
<li> <li>
<Link to="/expenses" className="btn btn-outline btn-accent"> <Link to="/expenses" className="btn btn-outline btn-accent">
Back Back

View file

@ -86,10 +86,6 @@ export const action: ActionFunction = async ({ request }) => {
teamId: user.teamId, teamId: user.teamId,
}, },
}); });
if (!expenseFrom || !expenseTo)
return json("Internal Server Error", { status: 500 });
return redirect(`/expenses`); return redirect(`/expenses`);
}; };
@ -185,10 +181,7 @@ export default function NewExpenseRoute() {
</div> </div>
</div> </div>
</div> </div>
<ul <ul className="menu px-3 menu-horizontal rounded-box max-w-xs mx-auto flex items-center justify-evenly">
className="menu px-3 menu-horizontal rounded-box max-w-xs mx-auto flex items-center justify-evenly"
role="navigation"
>
<li> <li>
<Link to="/expenses" className="btn btn-outline btn-accent"> <Link to="/expenses" className="btn btn-outline btn-accent">
Back Back

View file

@ -34,7 +34,7 @@ function validateConfirmPassword(confirmPassword: unknown, password: string) {
} }
function validateIcon(icon: unknown) { function validateIcon(icon: unknown) {
if (typeof icon !== "string") { if (typeof icon !== "string" || icon.length > 2) {
return `Icons must be a single character, e.g. "A" or "😎"`; return `Icons must be a single character, e.g. "A" or "😎"`;
} }
} }
@ -70,7 +70,9 @@ 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 = form.get("icon"); const 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 (
@ -78,7 +80,6 @@ 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({
@ -86,12 +87,12 @@ export const action: ActionFunction = async ({ request }) => {
}); });
} }
const fields = { username, password, icon, confirmPassword, teamId }; const fields = { username, password, 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,97 +1,21 @@
import type { User, Team, Expense } from "@prisma/client"; import type { User, Team } from "@prisma/client";
import type { LoaderFunction } from "remix"; import type { LoaderFunction } from "remix";
import { redirect, useLoaderData, useCatch, Link } from "remix"; import { redirect, Link, useLoaderData, useCatch } from "remix";
import { db } from "~/utils/db.server"; import { getUser } from "~/utils/session.server";
import { getUser, requireUserId } from "~/utils/session.server";
import Header from "../components/Header"; import Header from "../components/Header";
type LoaderData = { type LoaderData = {
user: (User & { team: Team & { members: User[] } }) | null; user: (User & { team: Team & { members: User[] } }) | null;
thisMonth: {
count: number;
amount: number;
};
count: number;
avg: number;
statsByMonth: {
[month: string]: {
count: number;
amount: number;
};
};
}; };
export const loader: LoaderFunction = async ({ request }) => { export const loader: LoaderFunction = async ({ request }) => {
const userId = requireUserId(request);
const user = await getUser(request); const user = await getUser(request);
if (!user?.id || !userId) { if (!user?.id) {
return redirect("/login"); return redirect("/login");
} }
const expenses = await db.expense.aggregate({
_avg: {
amount: true,
},
_count: {
_all: true,
},
where: { userId: user.id },
orderBy: {
createdAt: "asc",
},
});
let thisMonth = new Date();
thisMonth.setDate(0);
const thisMonthExp = await db.expense.aggregate({
_avg: {
amount: true,
},
_count: {
_all: true,
},
where: { userId: user.id, createdAt: { gt: thisMonth } },
orderBy: {
createdAt: "asc",
},
});
const allExpenses = await db.expense.findMany({
where: { userId: user.id },
});
const statsByMonth = allExpenses.reduce(
(
acc: { [key: string]: { count: number; amount: number } },
exp: Expense
) => {
const month = new Intl.DateTimeFormat("it", {
month: "2-digit",
year: "numeric",
}).format(new Date(exp.createdAt));
if (!acc[month]) {
acc[month] = {
count: 0,
amount: 0,
};
}
acc[month].count += 1;
acc[month].amount += exp.amount;
return acc;
},
{}
);
const data: LoaderData = { const data: LoaderData = {
user, user,
thisMonth: {
count: thisMonthExp?._count?._all ?? 0,
amount: thisMonthExp?._avg?.amount ?? 0,
},
count: expenses._count._all ?? 0,
avg: expenses._avg?.amount ?? 0,
statsByMonth,
}; };
return data; return data;
}; };
@ -102,54 +26,33 @@ export default function ListExpensesRoute() {
return ( return (
<> <>
<Header user={data.user} route="/expenses" /> <Header user={data.user} route="/expenses" />
<main className="container mx-auto"> <div className="hero py-40 bg-base-200 my-8 rounded-box">
<h1 className="mb-10 mt-6 text-4xl font-bold">Statistics</h1> <div className="text-center hero-content">
<div className="max-w-md">
<div className="shadow-xl flex flex-wrap w-full rounded-box bg-base-100 overflow-hidden"> <h1 className="mb-5 text-5xl font-bold">Work in progress</h1>
<div className="stat w-full sm:w-1/2 md:w-1/3"> <p className="mb-5">
<div className="stat-title">Expenses count</div> <button className="btn btn-lg loading"></button>
<div className="stat-value">{data.count}</div> This page is under construction.
<div className="stat-desc"></div> </p>
</div> <Link to="/expenses" className="btn btn-primary">
<svg
<div className="stat w-full sm:w-1/2 md:w-1/3"> xmlns="http://www.w3.org/2000/svg"
<div className="stat-title">Average per month</div> fill="none"
<div className="stat-value">{data.avg.toFixed(2)} </div> viewBox="0 0 24 24"
</div> className="inline-block w-6 h-6 mr-2 stroke-current rotate-180"
>
<div className="stat w-full sm:w-1/2 md:w-1/3"> <path
<div className="stat-title">This month</div> stroke-linecap="round"
<div className="stat-value"> stroke-linejoin="round"
{data.thisMonth.amount.toFixed(2)} stroke-width="2"
</div> d="M9 5l7 7-7 7"
<div className="stat-desc">{data.thisMonth.count} expenses</div> ></path>
</svg>
Back
</Link>
</div> </div>
</div> </div>
</div>
<h2 className="mt-12 mb-8 text-2xl font-bold">Expenses by month</h2>
<div className="shadow-xl bg-base-100 rounded-box overflow-hidden">
<div className="overflow-x-auto">
<table className="table table-zebra w-full">
<thead>
<tr>
<th>Month</th>
<th>Total</th>
<th>Count</th>
</tr>
</thead>
<tbody>
{Object.keys(data.statsByMonth)?.map((month) => (
<tr key={month}>
<td className="capitalize">{month}</td>
<td>{data.statsByMonth[month].amount} </td>
<td>{data.statsByMonth[month].count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</main>
</> </>
); );
} }

View file

@ -1,9 +1,8 @@
import type { LoaderFunction, ActionFunction } from "remix"; import type { LoaderFunction } from "remix";
import { useLoaderData, useCatch, redirect, Link, Form } from "remix"; import { useLoaderData, useCatch, redirect } 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 & {
@ -22,43 +21,9 @@ 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" />
@ -87,64 +52,6 @@ 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,7 +19,6 @@ type UpdateUserForm = {
password?: string; password?: string;
icon?: string; icon?: string;
teamId?: string; teamId?: string;
avgIncome?: number;
}; };
export async function register({ export async function register({
@ -39,12 +38,7 @@ export async function register({
}); });
} }
const user = await db.user.create({ const user = await db.user.create({
data: { data: { username, passwordHash, icon: icon ?? username[0], teamId },
username,
passwordHash,
icon: icon && icon.length > 0 ? icon : username[0],
teamId,
},
}); });
return user; return user;
} }
@ -68,7 +62,6 @@ 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=./build/entry.worker.js --minify --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'", "build:worker": "esbuild ./app/entry.worker.tsx --outfile=./public/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=./build/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch", "dev:worker": "esbuild ./app/entry.worker.tsx --outfile=./public/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",
@ -47,5 +47,5 @@
"node": ">=14" "node": ">=14"
}, },
"sideEffects": false, "sideEffects": false,
"version": "1.1.1" "version": "1.0.0"
} }

View file

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

View file

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