diff --git a/prisma/migrations/20260412120000_default_splits/migration.sql b/prisma/migrations/20260412120000_default_splits/migration.sql new file mode 100644 index 00000000..6f2f64c8 --- /dev/null +++ b/prisma/migrations/20260412120000_default_splits/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE "public"."GroupDefaultSplit" ( + "groupId" INTEGER NOT NULL, + "splitType" "public"."SplitType" NOT NULL, + "shares" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "GroupDefaultSplit_pkey" PRIMARY KEY ("groupId") +); + +-- CreateTable +CREATE TABLE "public"."FriendDefaultSplit" ( + "userAId" INTEGER NOT NULL, + "userBId" INTEGER NOT NULL, + "splitType" "public"."SplitType" NOT NULL, + "shares" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FriendDefaultSplit_pkey" PRIMARY KEY ("userAId","userBId") +); + +-- AddForeignKey +ALTER TABLE "public"."GroupDefaultSplit" ADD CONSTRAINT "GroupDefaultSplit_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "public"."Group"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."FriendDefaultSplit" ADD CONSTRAINT "FriendDefaultSplit_userAId_fkey" FOREIGN KEY ("userAId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."FriendDefaultSplit" ADD CONSTRAINT "FriendDefaultSplit_userBId_fkey" FOREIGN KEY ("userBId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d02a925c..03eee3c6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,29 +39,31 @@ model Session { } model User { - id Int @id @default(autoincrement()) - name String? - email String? @unique - emailVerified DateTime? - image String? - currency String @default("USD") - preferredLanguage String @default("") - bankingId String? - obapiProviderId String? - accounts Account[] - friendBalances BalanceView[] @relation("FriendBalanceView") - userBalances BalanceView[] @relation("UserBalanceView") - cachedBankData CachedBankData[] @relation("UserCachedBankData") - addedExpenses Expense[] @relation("AddedByUser") - deletedExpenses Expense[] @relation("DeletedByUser") - paidExpenses Expense[] @relation("PaidByUser") - updatedExpenses Expense[] @relation("UpdatedByUser") - expenseNotes ExpenseNote[] - expenseParticipants ExpenseParticipant[] - groups Group[] - associatedGroups GroupUser[] - sessions Session[] - hiddenFriendIds Int[] @default([]) + id Int @id @default(autoincrement()) + name String? + email String? @unique + emailVerified DateTime? + image String? + currency String @default("USD") + preferredLanguage String @default("") + bankingId String? + obapiProviderId String? + accounts Account[] + friendBalances BalanceView[] @relation("FriendBalanceView") + userBalances BalanceView[] @relation("UserBalanceView") + cachedBankData CachedBankData[] @relation("UserCachedBankData") + addedExpenses Expense[] @relation("AddedByUser") + deletedExpenses Expense[] @relation("DeletedByUser") + paidExpenses Expense[] @relation("PaidByUser") + updatedExpenses Expense[] @relation("UpdatedByUser") + expenseNotes ExpenseNote[] + expenseParticipants ExpenseParticipant[] + groups Group[] + associatedGroups GroupUser[] + friendDefaultSplitsA FriendDefaultSplit[] @relation("FriendDefaultSplitUserA") + friendDefaultSplitsB FriendDefaultSplit[] @relation("FriendDefaultSplitUserB") + sessions Session[] + hiddenFriendIds Int[] @default([]) @@schema("public") } @@ -92,22 +94,48 @@ model Balance { } model Group { - id Int @id @default(autoincrement()) - publicId String @unique - name String - image String? - userId Int - defaultCurrency String @default("USD") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - splitwiseGroupId String? @unique - simplifyDebts Boolean @default(false) - archivedAt DateTime? - expenses Expense[] - createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade) - groupUsers GroupUser[] - groupBalances BalanceView[] + id Int @id @default(autoincrement()) + publicId String @unique + name String + image String? + userId Int + defaultCurrency String @default("USD") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + splitwiseGroupId String? @unique + simplifyDebts Boolean @default(false) + archivedAt DateTime? + expenses Expense[] + createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade) + groupUsers GroupUser[] + groupBalances BalanceView[] + groupDefaultSplit GroupDefaultSplit? + + @@schema("public") +} + +model GroupDefaultSplit { + groupId Int @id + splitType SplitType + shares Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + + @@schema("public") +} +model FriendDefaultSplit { + userAId Int + userBId Int + splitType SplitType + shares Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + userA User @relation("FriendDefaultSplitUserA", fields: [userAId], references: [id], onDelete: Cascade) + userB User @relation("FriendDefaultSplitUserB", fields: [userBId], references: [id], onDelete: Cascade) + + @@id([userAId, userBId]) @@schema("public") } diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 5c8ea0ef..1c88ac37 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -257,6 +257,8 @@ "group_info": { "actions": "Actions", "archive_group": "Archive group", + "configure_default_split": "Configure default split", + "default_split": "Default split", "edit_group": "Edit group", "archive_group_details": { "can_archive": "This group will be archived and hidden from your main groups list. You can still access it later if needed.", @@ -297,6 +299,8 @@ "total_expenses": "Total expenses" }, "messages": { + "default_split_cleared": "Default split cleared", + "default_split_updated": "Default split updated", "balances_recalculated": "Balances recalculated successfully", "group_archived": "Group archived successfully", "group_name_updated": "Updated group details" @@ -330,6 +334,16 @@ "description": "Split Expenses with your friends for free", "title": "SplitPro: Split Expenses with your friends for free" }, + "balances": { + "user_preferences": { + "title": "Preferences" + }, + "default_split": { + "cleared": "Default split cleared", + "configure": "Default split", + "updated": "Default split updated" + } + }, "navigation": { "account": "Account", "activity": "Activity", diff --git a/src/components/AddExpense/SplitTypeSection.tsx b/src/components/AddExpense/SplitTypeSection.tsx index 26b6342e..bb64c331 100644 --- a/src/components/AddExpense/SplitTypeSection.tsx +++ b/src/components/AddExpense/SplitTypeSection.tsx @@ -10,7 +10,13 @@ import { Plus, X, } from 'lucide-react'; -import React, { type ChangeEvent, type PropsWithChildren, useCallback, useMemo } from 'react'; +import React, { + type ChangeEvent, + type PropsWithChildren, + useCallback, + useEffect, + useMemo, +} from 'react'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { type Participant, useAddExpenseStore } from '~/store/addStore'; @@ -66,7 +72,14 @@ const PayerRow = ({ p, isPaying }: { p: Participant; isPaying: boolean }) => { ); }; -export const SplitExpenseForm: React.FC = ({ children }) => { +export const SplitExpenseForm: React.FC< + PropsWithChildren<{ + allowedSplitTypes?: readonly SplitType[]; + onSave?: () => void; + onOpenChange?: (open: boolean) => void; + onTriggerClick?: () => void; + }> +> = ({ children, allowedSplitTypes, onSave, onOpenChange, onTriggerClick }) => { const { t } = useTranslation(); const splitType = useAddExpenseStore((s) => s.splitType); const { setSplitType } = useAddExpenseStore((s) => s.actions); @@ -75,6 +88,14 @@ export const SplitExpenseForm: React.FC = ({ children }) => { const { setSplitScreenOpen } = useAddExpenseStore((s) => s.actions); + const handleOpenChange = useCallback( + (open: boolean) => { + setSplitScreenOpen(open); + onOpenChange?.(open); + }, + [onOpenChange, setSplitScreenOpen], + ); + const onTabChange = useCallback( (value: string) => { setSplitType(value as SplitType); @@ -82,23 +103,42 @@ export const SplitExpenseForm: React.FC = ({ children }) => { [setSplitType], ); - const splitProps = useMemo(() => getSplitProps(t), [t]); + const splitProps = useMemo(() => { + const allSplitProps = getSplitProps(t); + if (!allowedSplitTypes || 0 === allowedSplitTypes.length) { + return allSplitProps; + } + + return allSplitProps.filter((props) => allowedSplitTypes.includes(props.splitType)); + }, [allowedSplitTypes, t]); + + const activeSplitType = splitProps.some((props) => props.splitType === splitType) + ? splitType + : (splitProps[0]?.splitType ?? SplitType.EQUAL); + + useEffect(() => { + if (activeSplitType !== splitType) { + setSplitType(activeSplitType); + } + }, [activeSplitType, setSplitType, splitType]); return ( - + {splitProps.map(({ splitType, iconComponent: Icon }) => ( @@ -404,7 +444,7 @@ export const UserAndAmount: React.FC<{ user: Participant; currency: CurrencyCode 'max-w-18 truncate text-sm text-gray-400', )} > - {0n < (shareAmount ?? 0n) ? '-' : ''} {toUIString(shareAmount)} + {paidBy && 0n < (shareAmount ?? 0n) ? '-' : ''} {toUIString(shareAmount)}

diff --git a/src/components/DefaultSplit/DefaultSplitSettings.tsx b/src/components/DefaultSplit/DefaultSplitSettings.tsx new file mode 100644 index 00000000..9fc55c79 --- /dev/null +++ b/src/components/DefaultSplit/DefaultSplitSettings.tsx @@ -0,0 +1,92 @@ +import { SplitType } from '@prisma/client'; +import React, { useCallback, useMemo } from 'react'; + +import { + DEFAULT_SPLIT_TYPES, + type SerializedDefaultSplitConfig, + deserializeDefaultSplit, + isDefaultSplitType, + serializeDefaultSplit, +} from '~/lib/defaultSplit'; +import { type Participant, useAddExpenseStore } from '~/store/addStore'; + +import { SplitExpenseForm } from '../AddExpense/SplitTypeSection'; +import { Button } from '../ui/button'; + +interface DefaultSplitSettingsProps { + participants: Participant[]; + defaultSplit: SerializedDefaultSplitConfig | null | undefined; + triggerLabel: string; + disabled?: boolean; + onSave: (defaultSplit: SerializedDefaultSplitConfig) => void; +} + +export const DefaultSplitSettings: React.FC = ({ + participants, + defaultSplit, + triggerLabel, + disabled, + onSave, +}) => { + const splitType = useAddExpenseStore((s) => s.splitType); + const splitShares = useAddExpenseStore((s) => s.splitShares); + const editorParticipants = useAddExpenseStore((s) => s.participants); + const { applySplitPreset, resetState, setAmount, setParticipants } = useAddExpenseStore( + (s) => s.actions, + ); + + const parsedDefaultSplit = useMemo(() => deserializeDefaultSplit(defaultSplit), [defaultSplit]); + + const onTriggerClick = useCallback(() => { + resetState(); + setParticipants(participants); + setAmount(10000n); + + if (parsedDefaultSplit) { + applySplitPreset(parsedDefaultSplit.splitType, parsedDefaultSplit.shares); + } + }, [applySplitPreset, parsedDefaultSplit, participants, resetState, setAmount, setParticipants]); + + const onOpenChange = useCallback( + (open: boolean) => { + if (!open) { + resetState(); + } + }, + [resetState], + ); + + const onDrawerSave = useCallback(() => { + if (!isDefaultSplitType(splitType)) { + return; + } + + const shares = Object.fromEntries( + editorParticipants.map((participant) => { + const fallbackShare = splitType === SplitType.EQUAL ? 1n : 0n; + return [participant.id, splitShares[participant.id]?.[splitType] ?? fallbackShare]; + }), + ); + + onSave( + serializeDefaultSplit({ + splitType, + shares, + }), + ); + resetState(); + }, [editorParticipants, onSave, resetState, splitShares, splitType]); + + return ( + + + + ); +}; diff --git a/src/lib/defaultSplit.ts b/src/lib/defaultSplit.ts new file mode 100644 index 00000000..9714992c --- /dev/null +++ b/src/lib/defaultSplit.ts @@ -0,0 +1,98 @@ +import { z } from 'zod'; +import { SplitType } from '@prisma/client'; + +export const DEFAULT_SPLIT_TYPES = [ + SplitType.EQUAL, + SplitType.PERCENTAGE, + SplitType.SHARE, +] as const; + +export const defaultSplitInputSchema = z.object({ + splitType: z.enum(DEFAULT_SPLIT_TYPES), + shares: z.record(z.string(), z.string()), +}); + +export type DefaultSplitType = (typeof DEFAULT_SPLIT_TYPES)[number]; + +export interface DefaultSplitConfig { + splitType: DefaultSplitType; + shares: Record; +} + +export interface SerializedDefaultSplitConfig { + splitType: DefaultSplitType; + shares: Record; +} + +export const isDefaultSplitType = (splitType: SplitType): splitType is DefaultSplitType => + DEFAULT_SPLIT_TYPES.includes(splitType as DefaultSplitType); + +export const serializeDefaultSplit = ( + config: DefaultSplitConfig, +): SerializedDefaultSplitConfig => ({ + splitType: config.splitType, + shares: Object.fromEntries( + Object.entries(config.shares).map(([userId, share]) => [userId, share.toString()]), + ), +}); + +export const deserializeDefaultSplit = ( + config: + | { + splitType: SplitType; + shares: Record; + } + | null + | undefined, +): DefaultSplitConfig | null => { + if (!config) { + return null; + } + + if (!isDefaultSplitType(config.splitType)) { + return null; + } + + return { + splitType: config.splitType, + shares: Object.fromEntries( + Object.entries(config.shares) + .map(([userId, share]) => { + if ('' === share) { + return null; + } + + try { + return [Number(userId), BigInt(share)] as const; + } catch { + return null; + } + }) + .filter((entry) => null !== entry), + ), + }; +}; + +export const toSortedFriendPair = (userIdA: number, userIdB: number): [number, number] => + userIdA < userIdB ? [userIdA, userIdB] : [userIdB, userIdA]; + +export const parseSerializedDefaultSplit = ( + splitType: SplitType, + shares: unknown, +): SerializedDefaultSplitConfig | null => { + if (!isDefaultSplitType(splitType)) { + return null; + } + + const parsedShares = z.record(z.string(), z.string()).safeParse(shares); + if (!parsedShares.success) { + return null; + } + + const parsed = deserializeDefaultSplit({ splitType, shares: parsedShares.data }); + if (!parsed) { + return null; + } + + return serializeDefaultSplit(parsed); +}; diff --git a/src/pages/add.tsx b/src/pages/add.tsx index 09ff5ca6..5f034609 100644 --- a/src/pages/add.tsx +++ b/src/pages/add.tsx @@ -14,6 +14,7 @@ import { api } from '~/utils/api'; import { customServerSideTranslations } from '~/utils/i18n/server'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { toast } from 'sonner'; +import { deserializeDefaultSplit } from '~/lib/defaultSplit'; const AddPage: NextPageWithUser<{ enableSendingInvites: boolean; @@ -34,6 +35,7 @@ const AddPage: NextPageWithUser<{ resetState, setCronExpression, setFileKey, + applySplitPreset, } = useAddExpenseStore((s) => s.actions); const currentUser = useAddExpenseStore((s) => s.currentUser); @@ -83,16 +85,39 @@ const AddPage: NextPageWithUser<{ .map((gu) => gu.user) .filter((user) => user.id !== currentUser.id), ]); + const parsedDefaultSplit = deserializeDefaultSplit(groupQuery.data.defaultSplit); + if (parsedDefaultSplit) { + applySplitPreset(parsedDefaultSplit.splitType, parsedDefaultSplit.shares); + } useAddExpenseStore.setState({ showFriends: false }); } - }, [groupId, groupQuery.isPending, groupQuery.data, currentUser, setGroup, setParticipants]); + }, [ + groupId, + groupQuery.isPending, + groupQuery.data, + currentUser, + setGroup, + setParticipants, + applySplitPreset, + ]); useEffect(() => { if (friendId && currentUser && friendQuery.data) { setParticipants([currentUser, friendQuery.data]); + const parsedDefaultSplit = deserializeDefaultSplit(friendQuery.data.defaultSplit); + if (parsedDefaultSplit) { + applySplitPreset(parsedDefaultSplit.splitType, parsedDefaultSplit.shares); + } useAddExpenseStore.setState({ showFriends: false }); } - }, [friendId, friendQuery.isPending, friendQuery.data, currentUser, setParticipants]); + }, [ + friendId, + friendQuery.isPending, + friendQuery.data, + currentUser, + setParticipants, + applySplitPreset, + ]); useEffect(() => { if (!_expenseId || !expenseQuery.data) { diff --git a/src/pages/balances/[friendId].tsx b/src/pages/balances/[friendId].tsx index e75f9648..9ea69c60 100644 --- a/src/pages/balances/[friendId].tsx +++ b/src/pages/balances/[friendId].tsx @@ -1,8 +1,10 @@ -import { ChevronLeftIcon, HandCoins, PlusIcon } from 'lucide-react'; +import { ChevronLeftIcon, HandCoins, Pencil, PlusIcon } from 'lucide-react'; import Head from 'next/head'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { useMemo } from 'react'; +import { toast } from 'sonner'; +import { DefaultSplitSettings } from '~/components/DefaultSplit/DefaultSplitSettings'; import { ExpenseList } from '~/components/Expense/ExpenseList'; import { DeleteFriend } from '~/components/Friend/DeleteFriend'; import { Export } from '~/components/Friend/Export'; @@ -10,12 +12,14 @@ import { SettleUp } from '~/components/Friend/Settleup'; import MainLayout from '~/components/Layout/MainLayout'; import { EntityAvatar } from '~/components/ui/avatar'; import { Button } from '~/components/ui/button'; +import { AppDrawer } from '~/components/ui/drawer'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; import { customServerSideTranslations } from '~/utils/i18n/server'; import { type GetServerSideProps } from 'next'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { CumulatedBalances } from '~/components/Expense/CumulatedBalances'; +import { deserializeDefaultSplit } from '~/lib/defaultSplit'; const FriendPage: NextPageWithUser = ({ user }) => { const { t, displayName } = useTranslationWithUtils(); @@ -26,17 +30,19 @@ const FriendPage: NextPageWithUser = ({ user }) => { const friendQuery = api.user.getFriend.useQuery( { friendId: _friendId }, - { enabled: !!_friendId }, + { enabled: Boolean(_friendId) }, ); const expenses = api.expense.getExpensesWithFriend.useQuery( { friendId: _friendId }, - { enabled: !!_friendId }, + { enabled: Boolean(_friendId) }, ); const balances = api.user.getBalancesWithFriend.useQuery( { friendId: _friendId }, - { enabled: !!_friendId }, + { enabled: Boolean(_friendId) }, ); + const upsertFriendDefaultSplitMutation = api.user.upsertFriendDefaultSplit.useMutation(); + const clearFriendDefaultSplitMutation = api.user.clearFriendDefaultSplit.useMutation(); // Aggregate balances by currency for CumulatedBalances display const aggregatedBalances = useMemo(() => { @@ -71,7 +77,87 @@ const FriendPage: NextPageWithUser = ({ user }) => {

{displayName(friendQuery.data)}

} - actions={} + actions={ +
+ + + + + } + > + {!friendQuery.data ? null : ( +
+

{t('group_details.group_info.default_split')}

+
+ { + upsertFriendDefaultSplitMutation.mutate( + { + friendId: friendQuery.data!.id, + defaultSplit, + }, + { + onSuccess: () => { + toast.success(t('balances.default_split.updated')); + void friendQuery.refetch(); + }, + onError: () => { + toast.error(t('errors.setting_update_failed')); + }, + }, + ); + }} + /> + +
+
+ )} +
+
+ } header={
diff --git a/src/pages/groups/[groupId].tsx b/src/pages/groups/[groupId].tsx index a2cbbdc9..774c4ba5 100644 --- a/src/pages/groups/[groupId].tsx +++ b/src/pages/groups/[groupId].tsx @@ -30,11 +30,13 @@ import { Button } from '~/components/ui/button'; import { AppDrawer } from '~/components/ui/drawer'; import { Label } from '~/components/ui/label'; import { SimpleConfirmationDialog } from '~/components/SimpleConfirmationDialog'; +import { DefaultSplitSettings } from '~/components/DefaultSplit/DefaultSplitSettings'; import { Switch } from '~/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs'; import { UpdateName } from '~/components/Account/UpdateDetails'; import { env } from '~/env'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { deserializeDefaultSplit } from '~/lib/defaultSplit'; import { db } from '~/server/db'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; @@ -55,6 +57,8 @@ const BalancePage: NextPageWithUser<{ const toggleArchiveMutation = api.group.toggleArchive.useMutation(); const toggleSimplifyDebtsMutation = api.group.toggleSimplifyDebts.useMutation(); const updateGroupDetailsMutation = api.group.updateGroupDetails.useMutation(); + const upsertDefaultSplitMutation = api.group.upsertDefaultSplit.useMutation(); + const clearDefaultSplitMutation = api.group.clearDefaultSplit.useMutation(); const [isInviteCopied, setIsInviteCopied] = useState(false); @@ -261,6 +265,59 @@ const BalancePage: NextPageWithUser<{
))}
+ +
+

{t('group_details.group_info.default_split')}

+
+ groupUser.user) ?? [] + } + defaultSplit={groupDetailQuery.data?.defaultSplit} + triggerLabel={t('group_details.group_info.configure_default_split')} + disabled={isArchived || !groupDetailQuery.data} + onSave={(defaultSplit) => { + upsertDefaultSplitMutation.mutate( + { groupId, defaultSplit }, + { + onSuccess: () => { + toast.success(t('group_details.messages.default_split_updated')); + void groupDetailQuery.refetch(); + }, + onError: () => { + toast.error(t('errors.setting_update_failed')); + }, + }, + ); + }} + /> + +
+
{groupDetailQuery.data?.createdAt && (
@@ -270,7 +327,7 @@ const BalancePage: NextPageWithUser<{ )}

{t('group_details.group_info.actions')}

-
+