-
-
+
+
-
+
+ and spliited
+
+
+ {/*
-
- user.id!)}
- />
-
-
+
Owed to
{participants.map((user) => (
@@ -142,7 +100,7 @@ export default function ExpenseSplit({
))}
-
+ */}
);
}
diff --git a/src/app/_components/expense/paid-by-custom-split.tsx b/src/app/_components/expense/paid-by-custom-split.tsx
index 4936ced..2449049 100644
--- a/src/app/_components/expense/paid-by-custom-split.tsx
+++ b/src/app/_components/expense/paid-by-custom-split.tsx
@@ -17,37 +17,74 @@ import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/number-input";
import { EuroIcon } from "lucide-react";
import { cn } from "@/lib/utils";
+import { useExpenseStore } from "@/lib/store/expense-store";
-function PaidByCustomSplit({
- users,
- payments,
-}: {
- users: Array
;
- payments: Array;
-}) {
+function PaidByCustomSplit() {
+ const [open, setOpen] = React.useState(false);
const [customAmounts, setCustomAmounts] = React.useState<
Record
>({});
- const error = Object.values(customAmounts).reduce(
- (acc, curr) => acc + curr,
- 0
- );
- console.log(error);
+ const [error, setError] = React.useState("");
+ const amount = useExpenseStore((state) => state.amount);
+ const users = useExpenseStore((state) => state.participants);
+ const setPayments = useExpenseStore((state) => state.setPayments);
+ const payments = useExpenseStore((state) => state.payments);
+ const handleSaveSplits = () => {
+ const customAmount = Object.values(customAmounts).reduce(
+ (acc, curr) => acc + curr,
+ 0
+ );
+ if (customAmount !== amount) {
+ setError(
+ `Please enter the same amount as the expense. You entered ${customAmount}€ but the expense is ${amount}€`
+ );
+ } else {
+ setOpen(false);
+ setError("");
+ var payments: Array = [];
+ Object.keys(customAmounts).forEach((key) => {
+ const currentAmount = customAmounts[key];
+ if (Number(currentAmount) <= 0) return;
+ payments.push({ amount: currentAmount ?? 0, userId: key });
+ });
+ setPayments(payments);
+ }
+ };
return (
-
+
);
})}
-
+
+
);
diff --git a/src/app/_components/expense/paid-by.tsx b/src/app/_components/expense/paid-by.tsx
index 9884583..15950d4 100644
--- a/src/app/_components/expense/paid-by.tsx
+++ b/src/app/_components/expense/paid-by.tsx
@@ -2,7 +2,6 @@ import React from "react";
import {
Dialog,
DialogContent,
- DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -11,40 +10,42 @@ import { Button } from "@/components/ui/button";
import { DialogClose } from "@radix-ui/react-dialog";
import type { PublicUser } from "next-auth";
import Avatar from "@/components/avatar";
-import type { ExpenseSplitHook } from "@/hooks/use-expense-split";
import { Icons } from "@/components/icons";
import { cn } from "@/lib/utils";
import PaidByCustomSplit from "./paid-by-custom-split";
+import { useExpenseStore } from "@/lib/store/expense-store";
/* saved result is an array of objects with user and amount */
export default function PaidByInput({
sessionUser,
- expenseSplitHook,
}: {
sessionUser: PublicUser;
- expenseSplitHook: ExpenseSplitHook;
}) {
- const { amount, payments, setPayments, participants } = expenseSplitHook;
-
+ const amount = useExpenseStore((state) => state.amount);
+ const payments = useExpenseStore((state) => state.payments);
+ const setPayments = useExpenseStore((state) => state.setPayments);
+ const participants = useExpenseStore((state) => state.participants);
return (
-
- Paid by
-
+
+
+
-
+
Who paid?
@@ -52,6 +53,8 @@ export default function PaidByInput({
+
+
{participants.map((user) => {
const selected = payments.find(({ userId }) => userId === user.id);
return (
@@ -87,8 +90,10 @@ export default function PaidByInput({
);
})}
-
-
+ -
+
+
+
);
diff --git a/src/app/_components/expense/split-to.tsx b/src/app/_components/expense/split-to.tsx
new file mode 100644
index 0000000..02516d2
--- /dev/null
+++ b/src/app/_components/expense/split-to.tsx
@@ -0,0 +1,39 @@
+"use client";
+import React from "react";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { useExpenseStore } from "@/lib/store/expense-store";
+function SplitTo() {
+ const splitType = useExpenseStore((state) => state.splitType);
+ return (
+
+
+
+
+
+
+
+ Who paid?
+
+
+
+
+
+
+
+ );
+}
+
+export default SplitTo;
diff --git a/src/components/combobox.tsx b/src/components/combobox.tsx
index c0b3225..c26f386 100644
--- a/src/components/combobox.tsx
+++ b/src/components/combobox.tsx
@@ -47,6 +47,7 @@ export function Combobox({
className,
hideSearch,
buttonProps,
+
onSelect,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
diff --git a/src/hooks/use-expense-split.ts b/src/hooks/use-expense-split.ts
deleted file mode 100644
index d173ac5..0000000
--- a/src/hooks/use-expense-split.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-"use client";
-
-import React from "react";
-import { api } from "@/trpc/react";
-import type { Session } from "next-auth";
-import type { PublicUser } from "next-auth";
-import { calculateSplits, type Payment } from "@/lib/utils/expense";
-
-export enum SplitTypeEnum {
- Fixed = "fixed",
- Percentage = "percentage",
- Equal = "equal",
-}
-export type SplitType = keyof typeof SplitTypeEnum;
-export const splitTypeKeys = Object.keys(SplitTypeEnum);
-
-export const useExpenseSplit = (amount: number, session: Session) => {
- const [friends] = api.friend.getAll.useSuspenseQuery();
- const [friendTarget, setFriendTarget] = React.useState
();
- const [participants, setParticipants] = React.useState>([
- session.user,
- ]);
- const [activeSplitType, setActiveSplitType] =
- React.useState("Equal");
- const [payments, setPayments] = React.useState>([
- { amount: amount, userId: session.user.id },
- ]);
-
- const [splits, setSplits] = React.useState<
- Record
- >({});
-
- React.useEffect(() => {
- // const generateSplitsAmount = () => {
- // var newSplits: Record = {};
- // participants.forEach((user) => {
- // newSplits[user.id!] = splits[user.id!] ?? {};
- // });
- // console.log(newSplits);
- // return newSplits;
- // };
-
- setSplits(
- calculateSplits(
- {
- amount,
- users: participants.map((user) => user.id!),
- splitType: activeSplitType,
- }
- // generateSplitsAmount()
- )
- );
- }, [participants, activeSplitType, amount]);
-
- const addParticipant = (newUser: PublicUser) => {
- setParticipants((prev) =>
- prev.find((user) => user.id === newUser.id) ? prev : [...prev, newUser]
- );
- };
- const removeParticipant = (userId: string) => {
- setParticipants((prev) => prev.filter((user) => user.id !== userId));
- };
-
- return {
- friends,
- amount,
- participants,
- addParticipant,
- removeParticipant,
- friendTarget,
- setFriendTarget,
- splitType: activeSplitType,
- setSplitType: setActiveSplitType,
- payments,
- setPayments,
- //
- splits,
- };
-};
-
-export type ExpenseSplitHook = ReturnType;
diff --git a/src/lib/store/expense-store.ts b/src/lib/store/expense-store.ts
new file mode 100644
index 0000000..178d394
--- /dev/null
+++ b/src/lib/store/expense-store.ts
@@ -0,0 +1,96 @@
+"use client";
+import { create } from "zustand";
+import { calculateSplits, type Payment } from "@/lib/utils/expense";
+import type { Session } from "next-auth";
+import type { PublicUser } from "next-auth";
+
+export enum SplitTypeEnum {
+ Fixed = "fixed",
+ Percentage = "percentage",
+ Equal = "equal",
+}
+export type SplitType = keyof typeof SplitTypeEnum;
+export const splitTypeKeys = Object.keys(SplitTypeEnum);
+
+interface ExpenseStore {
+ amount: number;
+ session: Session | null;
+
+ participants: PublicUser[];
+ friendTarget?: string;
+ splitType: SplitType;
+ payments: Payment[];
+ splits: Record;
+
+ setAmount: (amount: number) => void;
+ setSession: (session: Session) => void;
+
+ addParticipant: (user: PublicUser) => void;
+ removeParticipant: (userId: string) => void;
+
+ setFriendTarget: (id?: string) => void;
+ setSplitType: (type: SplitType) => void;
+ setPayments: (payments: Array) => void;
+ recalculateSplits: () => void;
+ resetExpenseStore: () => void;
+}
+
+const defaultValues: Pick<
+ ExpenseStore,
+ | "amount"
+ | "participants"
+ | "friendTarget"
+ | "splitType"
+ | "payments"
+ | "splits"
+> = {
+ amount: 0,
+ participants: [],
+ friendTarget: undefined,
+ splitType: "Equal",
+ payments: [],
+ splits: {},
+};
+
+export const useExpenseStore = create((set, get) => ({
+ ...defaultValues,
+ session: null,
+ setAmount: (amount) => set({ amount }),
+ setSession: (session) =>
+ set(
+ {
+ session,
+ participants: [session.user],
+ payments: [{ amount: get().amount, userId: session.user.id }],
+ },
+ false
+ ),
+
+ addParticipant: (newUser) => {
+ const { participants } = get();
+ if (!participants.find((user) => user.id === newUser.id)) {
+ set({ participants: [...participants, newUser] });
+ }
+ },
+
+ removeParticipant: (userId) => {
+ const filtered = get().participants.filter((user) => user.id !== userId);
+ set({ participants: filtered });
+ },
+
+ setFriendTarget: (id) => set({ friendTarget: id }),
+ setSplitType: (type) => set({ splitType: type }),
+ setPayments: (payments) => set({ payments }),
+
+ recalculateSplits: () => {
+ const { amount, participants, splitType } = get();
+ const splits = calculateSplits({
+ amount,
+ users: participants.map((u) => u.id!),
+ splitType,
+ });
+ set({ splits });
+ },
+
+ resetExpenseStore: () => set(defaultValues),
+}));
diff --git a/src/lib/utils/expense.ts b/src/lib/utils/expense.ts
index f2efd75..2cbfaaf 100644
--- a/src/lib/utils/expense.ts
+++ b/src/lib/utils/expense.ts
@@ -1,10 +1,13 @@
-import type { SplitType } from "@/hooks/use-expense-split";
+import type { SplitType } from "@/lib/store/expense-store";
+import type { z } from "zod";
+import { expenseSplitSchema } from "../validations/expense";
-export type Debt = {
- from: string;
- to: string;
- amount: number;
-};
+const debtSchema = expenseSplitSchema.pick({
+ owedFromId: true,
+ owedToId: true,
+ amount: true,
+});
+export type Debt = z.infer;
export type Payment = {
userId: string;
@@ -18,8 +21,6 @@ export function calculateSplits(args: {
}): Record {
const result: Record = {};
const { splitType = "Equal", amount, users, payments } = args;
- // If splits are provided, validate them
- // const amount = Object.values(payments).reduce((sum, value) => sum + value, 0);
switch (splitType) {
case "Equal":
@@ -69,7 +70,7 @@ export function calculateDepts(
total: number,
users: string[],
payments: Array
-): Debt[] {
+): Array {
console.log("Payments: ", payments);
const share = total / users.length;
@@ -90,6 +91,8 @@ export function calculateDepts(
const debtors = Object.entries(balances)
.filter(([_, balance]) => balance < 0)
.map(([userId, balance]) => ({ userId, balance: -balance }));
+ console.log("debtors: ", debtors);
+ console.log("creditors: ", creditors);
const debts: Debt[] = [];
@@ -103,8 +106,8 @@ export function calculateDepts(
const payment = Math.min(amountToPay, creditor.balance);
debts.push({
- from: debtor.userId,
- to: creditor.userId,
+ owedToId: debtor.userId,
+ owedFromId: creditor.userId,
amount: parseFloat(payment.toFixed(2)), // for rounding safety
});
diff --git a/src/lib/validations/expense.ts b/src/lib/validations/expense.ts
index 1043c87..9be5a88 100644
--- a/src/lib/validations/expense.ts
+++ b/src/lib/validations/expense.ts
@@ -1,14 +1,15 @@
import { z } from "zod";
+const amount = z.number().min(0.01);
export const expenseSchema = z.object({
- amount: z.number().min(0.01),
- description: z.string().optional(),
+ amount,
+ description: z.string().nonempty({ message: "Description is required" }),
});
export const expenseSplitSchema = z.object({
expenseId: z.string(),
- paidById: z.string().min(1),
- owedById: z.string().min(1),
- amount: z.number().min(1),
+ owedToId: z.string().min(1),
+ owedFromId: z.string().min(1),
+ amount,
status: z.string(),
});
diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts
index 9a76926..f1ad7d3 100644
--- a/src/server/api/routers/expense.ts
+++ b/src/server/api/routers/expense.ts
@@ -1,18 +1,57 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
-import { expenses } from "@/server/db/schema";
+import { expenses, expenseSplits, type ExpenseSplit } from "@/server/db/schema";
import { expenseSchema, expenseSplitSchema } from "@/lib/validations/expense";
+import { eq, or } from "drizzle-orm";
export const expenseRouter = createTRPCRouter({
+ getAll: protectedProcedure.query(async ({ ctx }) => {
+ return await ctx.db.query.expenseSplits.findMany({
+ where: or(
+ eq(expenseSplits.owedFromId, ctx.session.user.id),
+ eq(expenseSplits.owedToId, ctx.session.user.id)
+ ),
+ with: {
+ expense: true,
+ },
+ });
+ }),
create: protectedProcedure
.input(
- z.object({ expense: expenseSchema, split: z.array(expenseSplitSchema) })
+ z.object({
+ expense: expenseSchema,
+ debs: z.array(
+ expenseSplitSchema.pick({
+ owedFromId: true,
+ owedToId: true,
+ amount: true,
+ })
+ ),
+ })
)
.mutation(async ({ ctx, input }) => {
- const [expense] = await ctx.db.insert(expenses).values({
- createdById: ctx.session.user.id,
- ...input.expense,
- });
+ const [expense] = await ctx.db
+ .insert(expenses)
+ .values({
+ createdById: ctx.session.user.id,
+ ...input.expense,
+ amount: input.expense.amount.toString(),
+ })
+ .returning({ id: expenses.id });
+ if (!expense?.id?.length) throw new Error("Expense cant get created");
+
+ await ctx.db
+ .insert(expenseSplits)
+ .values(
+ input.debs.map((deb) => ({
+ ...deb,
+ amount: deb.amount.toString(),
+ expenseId: expense.id,
+ status: "unpaid",
+ }))
+ )
+ .returning({ id: expenseSplits.id });
+ return expense;
}),
});
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index 9159ae9..72e6f28 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -24,7 +24,7 @@ export const expenses = createTable(
.uuid()
.notNull()
.references(() => users.id),
- amount: d.integer().notNull(),
+ amount: d.numeric({ scale: 2 }),
currency: d.varchar({ length: 3 }).notNull().default("EUR"), // Default to EUR
description: d.varchar({ length: 512 }),
createdAt: d
@@ -69,15 +69,15 @@ export const expenseSplits = createTable(
.uuid()
.notNull()
.references(() => expenses.id, { onDelete: "cascade" }),
- paidById: d
+ owedToId: d
.uuid()
.notNull()
.references(() => users.id),
- owedById: d
+ owedFromId: d
.uuid()
.notNull()
.references(() => users.id),
- amount: d.integer().notNull(),
+ amount: d.numeric({ scale: 2 }),
status: d.varchar({ length: 10 }).notNull().default("unpaid"), // 'unpaid' | 'settled'
createdAt: d
.timestamp({ withTimezone: true })
@@ -92,12 +92,12 @@ export const expenseSplitRelations = relations(expenseSplits, ({ one }) => ({
fields: [expenseSplits.expenseId],
references: [expenses.id],
}),
- paidBy: one(users, {
- fields: [expenseSplits.paidById],
+ owedTo: one(users, {
+ fields: [expenseSplits.owedToId],
references: [users.id],
}),
- owedBy: one(users, {
- fields: [expenseSplits.paidById],
+ owedFrom: one(users, {
+ fields: [expenseSplits.owedFromId],
references: [users.id],
}),
}));
@@ -123,7 +123,7 @@ export const settlements = createTable(
.uuid()
.notNull()
.references(() => users.id),
- amount: d.integer().notNull(),
+ amount: d.numeric().notNull(),
currency: d.varchar({ length: 3 }).notNull().default("EUR"),
createdAt: d
.timestamp({ withTimezone: true })