void;
- // addMultipleExpenses: () => void;
- // multipleTransactions: TransactionAddInputModel[];
- // setMultipleTransactions: (a: TransactionAddInputModel[]) => void;
- // isTransactionLoading: boolean;
+ addAllMultipleExpenses: () => void;
+ addOneByOneMultipleExpenses: () => void;
+ multipleTransactions: TransactionAddInputModel[];
+ setMultipleTransactions: (a: TransactionAddInputModel[]) => void;
+ isTransactionLoading: boolean;
bankConnectionEnabled: boolean;
children: React.ReactNode;
- // clearFields: () => void;
+ clearFields: () => void;
}> = ({
add,
- // addMultipleExpenses,
- // multipleTransactions,
- // setMultipleTransactions,
- // isTransactionLoading,
+ addAllMultipleExpenses,
+ addOneByOneMultipleExpenses,
+ multipleTransactions,
+ setMultipleTransactions,
+ isTransactionLoading,
bankConnectionEnabled,
children,
- // clearFields,
+ clearFields,
}) => {
const { t } = useTranslationWithUtils();
const [open, setOpen] = React.useState(false);
+ const [showMultipleTransactionModal, setShowMultipleTransactionModal] = React.useState(false);
const userQuery = api.user.me.useQuery();
const transactions = api.bankTransactions.getTransactions.useQuery(
@@ -74,45 +79,75 @@ export const BankingTransactionList: React.FC<{
const transactionsArray = returnTransactionsArray();
const onTransactionRowClick = useCallback(
- (item: TransactionWithPendingStatus) => {
- const transactionData = {
+ (item: TransactionWithPendingStatus, multiple: boolean) => {
+ if (!isCurrencyCode(item.transactionAmount.currency)) {
+ console.warn(`Invalid currency code: ${item.transactionAmount.currency}`);
+ return;
+ }
+
+ const normalizedAmount = item.transactionAmount.amount.replaceAll('-', '').replace(',', '.');
+ const parsedAmount = Number(normalizedAmount);
+
+ if (isNaN(parsedAmount) || !isFinite(parsedAmount)) {
+ console.warn(`Invalid amount: ${item.transactionAmount.amount}`);
+ return;
+ }
+
+ const bigIntAmount = BigInt(Math.round(parsedAmount * 100));
+
+ const transactionData: TransactionAddInputModel = {
date: new Date(item.bookingDate),
- amount: item.transactionAmount.amount.replace('-', ''),
- currency: item.transactionAmount.currency,
+ amountStr: item.transactionAmount.amount.replaceAll('-', ''),
+ amount: bigIntAmount,
+ currency: parseCurrencyCode(item.transactionAmount.currency),
description: item.description,
transactionId: item.transactionId,
};
- // if (multiple) {
- // clearFields();
- // const isInMultipleTransactions = multipleTransactions?.some(
- // (cItem) => cItem.transactionId === item.transactionId,
- // );
-
- // setMultipleTransactions(
- // isInMultipleTransactions
- // ? multipleTransactions.filter((cItem) => cItem.transactionId !== item.transactionId)
- // : [...multipleTransactions, transactionData],
- // );
- // } else {
- if (alreadyAdded(item.transactionId)) {
- return;
+ if (multiple) {
+ clearFields();
+ const isInMultipleTransactions = multipleTransactions?.some(
+ (cItem) => cItem.transactionId === item.transactionId,
+ );
+
+ setMultipleTransactions(
+ isInMultipleTransactions
+ ? multipleTransactions.filter((cItem) => cItem.transactionId !== item.transactionId)
+ : [...multipleTransactions, transactionData as TransactionAddInputModel],
+ );
+ } else {
+ if (alreadyAdded(item.transactionId)) {
+ return;
+ }
+ add(transactionData);
+ setOpen(false);
+ document.getElementById('mainlayout')?.scrollTo({ top: 0, behavior: 'instant' });
}
- add(transactionData);
- setOpen(false);
- document.getElementById('mainlayout')?.scrollTo({ top: 0, behavior: 'instant' });
- // }
},
- [add, alreadyAdded],
+ [add, alreadyAdded, multipleTransactions, setMultipleTransactions, clearFields],
);
- const setOpenClose = useCallback((open: boolean) => {
- setOpen(open);
- // if (!open) {
- // setMultipleTransactions([]);
- // }
+ const setOpenClose = useCallback(
+ (open: boolean) => {
+ setOpen(open);
+ if (!open) {
+ setMultipleTransactions([]);
+ }
+ },
+ [setMultipleTransactions],
+ );
+
+ const handleAddMultipleExpenses = useCallback(() => {
+ setShowMultipleTransactionModal(true);
}, []);
+ const hasMultipleTransactions = multipleTransactions.length > 0;
+
+ const handleAddOneByOneMultipleExpenses = useCallback(() => {
+ setOpen(false);
+ addOneByOneMultipleExpenses();
+ }, [addOneByOneMultipleExpenses]);
+
if (!bankConnectionEnabled || !userQuery.data?.obapiProviderId) {
return null;
}
@@ -124,13 +159,9 @@ export const BankingTransactionList: React.FC<{
open={open}
onOpenChange={setOpenClose}
className="h-[80vh]"
- // actionTitle={t('expense_details.submit_all')}
- // actionOnClick={addMultipleExpenses}
- // actionDisabled={
- // (multipleTransactions?.length || 0) === 0 ||
- // isTransactionLoading
- // }
- shouldCloseOnAction
+ actionTitle={hasMultipleTransactions ? t('expense_details.submit_all') : undefined}
+ actionOnClick={hasMultipleTransactions ? handleAddMultipleExpenses : undefined}
+ actionDisabled={(multipleTransactions?.length || 0) === 0 || isTransactionLoading}
>
{transactions?.isLoading ? (
@@ -152,12 +183,18 @@ export const BankingTransactionList: React.FC<{
alreadyAdded={alreadyAdded(item.transactionId)}
onTransactionRowClick={onTransactionRowClick}
groupName={returnGroupName(item.transactionId)}
- // multipleTransactions={multipleTransactions}
+ multipleTransactions={multipleTransactions}
/>
))}
>
)}
+
);
};
diff --git a/src/components/AddExpense/BankTransactions/MultipleTransactionModal.tsx b/src/components/AddExpense/BankTransactions/MultipleTransactionModal.tsx
new file mode 100644
index 00000000..713ace42
--- /dev/null
+++ b/src/components/AddExpense/BankTransactions/MultipleTransactionModal.tsx
@@ -0,0 +1,56 @@
+import { useCallback } from 'react';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '~/components/ui/alert-dialog';
+import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils';
+
+export const MultipleTransactionModal = ({
+ modalOpen,
+ setModalOpen,
+ onAddAll,
+ onAddOneByOne,
+}: {
+ modalOpen: boolean;
+ setModalOpen: (open: boolean) => void;
+ onAddAll: () => void;
+ onAddOneByOne: () => void;
+}) => {
+ const { t } = useTranslationWithUtils();
+
+ const handleOnAddAll = useCallback(() => {
+ setModalOpen(false);
+ onAddAll();
+ }, [setModalOpen, onAddAll]);
+
+ const handleOnAddOneByOne = useCallback(() => {
+ setModalOpen(false);
+ onAddOneByOne();
+ }, [setModalOpen, onAddOneByOne]);
+
+ return (
+
+
+
+ {t('expense_details.multiple_transactions.title')}
+
+ {t('expense_details.multiple_transactions.description')}
+
+
+
+
+ {t('expense_details.multiple_transactions.add_all')}
+
+
+ {t('expense_details.multiple_transactions.add_one_by_one')}
+
+
+
+
+ );
+};
diff --git a/src/components/Friend/GroupSettleup.tsx b/src/components/Friend/GroupSettleup.tsx
index 49ba8e5f..30c88819 100644
--- a/src/components/Friend/GroupSettleup.tsx
+++ b/src/components/Friend/GroupSettleup.tsx
@@ -49,25 +49,27 @@ export const GroupSettleUp: React.FC<{
}
addExpenseMutation.mutate(
- {
- name: t('ui.settle_up_name'),
- currency: currency,
- amount,
- splitType: SplitType.SETTLEMENT,
- groupId,
- participants: [
- {
- userId: sender.id,
- amount,
- },
- {
- userId: receiver.id,
- amount: -amount,
- },
- ],
- paidBy: sender.id,
- category: DEFAULT_CATEGORY,
- },
+ [
+ {
+ name: t('ui.settle_up_name'),
+ currency: currency,
+ amount,
+ splitType: SplitType.SETTLEMENT,
+ groupId,
+ participants: [
+ {
+ userId: sender.id,
+ amount,
+ },
+ {
+ userId: receiver.id,
+ amount: -amount,
+ },
+ ],
+ paidBy: sender.id,
+ category: DEFAULT_CATEGORY,
+ },
+ ],
{
onSuccess: () => {
utils.group.invalidate().catch(console.error);
diff --git a/src/components/Friend/Settleup.tsx b/src/components/Friend/Settleup.tsx
index 94909fbc..bd056276 100644
--- a/src/components/Friend/Settleup.tsx
+++ b/src/components/Friend/Settleup.tsx
@@ -66,25 +66,27 @@ export const SettleUp: React.FC<
}
addExpenseMutation.mutate(
- {
- name: t('ui.settle_up_name'),
- currency: balanceToSettle.currency,
- amount,
- splitType: SplitType.SETTLEMENT,
- participants: [
- {
- userId: currentUser.id,
- amount: isCurrentUserPaying ? amount : -amount,
- },
- {
- userId: friend.id,
- amount: isCurrentUserPaying ? -amount : amount,
- },
- ],
- paidBy: isCurrentUserPaying ? currentUser.id : friend.id,
- category: DEFAULT_CATEGORY,
- groupId: null,
- },
+ [
+ {
+ name: t('ui.settle_up_name'),
+ currency: balanceToSettle.currency,
+ amount,
+ splitType: SplitType.SETTLEMENT,
+ participants: [
+ {
+ userId: currentUser.id,
+ amount: isCurrentUserPaying ? amount : -amount,
+ },
+ {
+ userId: friend.id,
+ amount: isCurrentUserPaying ? -amount : amount,
+ },
+ ],
+ paidBy: isCurrentUserPaying ? currentUser.id : friend.id,
+ category: DEFAULT_CATEGORY,
+ groupId: null,
+ },
+ ],
{
onSuccess: () => {
utils.user.invalidate().catch(console.error);
diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts
index 70a0e7f4..01d9b388 100644
--- a/src/server/api/routers/expense.ts
+++ b/src/server/api/routers/expense.ts
@@ -80,63 +80,73 @@ export const expenseRouter = createTRPCRouter({
}),
addOrEditExpense: protectedProcedure
- .input(createExpenseSchema)
- .mutation(async ({ input, ctx }) => {
- if (input.expenseId) {
- await validateEditExpensePermission(input.expenseId, ctx.session.user.id);
- }
- if (input.splitType === SplitType.CURRENCY_CONVERSION) {
- throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid split type' });
- }
-
- if (input.groupId) {
- const group = await db.group.findUnique({
- where: { id: input.groupId },
- select: { archivedAt: true },
- });
- if (!group) {
- throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' });
+ .input(z.array(createExpenseSchema))
+ .mutation(async ({ input: expenses, ctx }) => {
+ const results = [];
+ for (const input of expenses) {
+ if (input.expenseId) {
+ await validateEditExpensePermission(input.expenseId, ctx.session.user.id);
}
- if (group.archivedAt) {
- throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' });
+ if (input.splitType === SplitType.CURRENCY_CONVERSION) {
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid split type' });
}
- }
- try {
- const expense = input.expenseId
- ? await editExpense(input, ctx.session.user.id)
- : await createExpense(input, ctx.session.user.id);
-
- if (expense && input.cronExpression) {
- const [{ schedule }] = await createRecurringExpenseJob(expense.id, input.cronExpression);
- console.log('Created recurring expense job with jobid:', schedule);
-
- await db.expense.update({
- where: { id: expense.id },
- data: {
- recurrence: {
- upsert: {
- create: {
- job: {
- connect: { jobid: schedule },
+ if (input.groupId) {
+ const group = await db.group.findUnique({
+ where: { id: input.groupId },
+ select: { archivedAt: true },
+ });
+ if (!group) {
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' });
+ }
+ if (group.archivedAt) {
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' });
+ }
+ }
+
+ try {
+ const expense = input.expenseId
+ ? await editExpense(input, ctx.session.user.id)
+ : await createExpense(input, ctx.session.user.id);
+
+ if (expense && input.cronExpression) {
+ const [{ schedule }] = await createRecurringExpenseJob(
+ expense.id,
+ input.cronExpression,
+ );
+ console.log('Created recurring expense job with jobid:', schedule);
+
+ await db.expense.update({
+ where: { id: expense.id },
+ data: {
+ recurrence: {
+ upsert: {
+ create: {
+ job: {
+ connect: { jobid: schedule },
+ },
},
- },
- update: {
- job: {
- connect: { jobid: schedule },
+ update: {
+ job: {
+ connect: { jobid: schedule },
+ },
},
},
},
},
- },
+ });
+ }
+
+ results.push(expense);
+ } catch (error) {
+ console.error(error);
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: 'Failed to create expense',
});
}
-
- return expense;
- } catch (error) {
- console.error(error);
- throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create expense' });
}
+ return results;
}),
addOrEditCurrencyConversion: protectedProcedure
diff --git a/src/types.ts b/src/types.ts
index 3e3f82b3..88c2ac39 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,6 +1,7 @@
import { type NextPage } from 'next';
import { type User } from 'next-auth';
import { z } from 'zod';
+import { type CurrencyCode } from './lib/currency';
export type NextPageWithUser
= NextPage<{ user: User } & T> & { auth: boolean };
@@ -39,8 +40,9 @@ export interface SplitwiseGroup {
export interface TransactionAddInputModel {
date: Date;
description: string;
- amount: string;
- currency: string;
+ amountStr: string;
+ amount: bigint;
+ currency: CurrencyCode;
transactionId?: string;
expenseId?: string;
}