Merge branch 'group-currency-code' of github.com:whimcomp/spliit into whimcomp-group-currency-code

This commit is contained in:
Peter Smit
2025-09-05 10:46:04 +02:00
30 changed files with 4701 additions and 251 deletions

View File

@@ -1,4 +1,5 @@
import { Balances } from '@/lib/balances'
import { Currency } from '@/lib/currency'
import { cn, formatCurrency } from '@/lib/utils'
import { Participant } from '@prisma/client'
import { useLocale } from 'next-intl'
@@ -6,7 +7,7 @@ import { useLocale } from 'next-intl'
type Props = {
balances: Balances
participants: Participant[]
currency: string
currency: Currency
}
export function BalancesList({ balances, participants, currency }: Props) {

View File

@@ -10,6 +10,7 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { getCurrencyFromGroup } from '@/lib/utils'
import { trpc } from '@/trpc/client'
import { useTranslations } from 'next-intl'
import { Fragment, useEffect } from 'react'
@@ -47,7 +48,7 @@ export default function BalancesAndReimbursements() {
<BalancesList
balances={balancesData.balances}
participants={group?.participants}
currency={group?.currency}
currency={getCurrencyFromGroup(group)}
/>
)}
</CardContent>
@@ -66,7 +67,7 @@ export default function BalancesAndReimbursements() {
<ReimbursementList
reimbursements={balancesData.reimbursements}
participants={group?.participants}
currency={group?.currency}
currency={getCurrencyFromGroup(group)}
groupId={groupId}
/>
)}

View File

@@ -1,12 +1,13 @@
'use client'
import { Money } from '@/components/money'
import { getBalances } from '@/lib/balances'
import { Currency } from '@/lib/currency'
import { useActiveUser } from '@/lib/hooks'
import { useTranslations } from 'next-intl'
type Props = {
groupId: string
currency: string
currency: Currency
expense: Parameters<typeof getBalances>[0][number]
}

View File

@@ -26,7 +26,7 @@ import {
import { ToastAction } from '@/components/ui/toast'
import { useToast } from '@/components/ui/use-toast'
import { useMediaQuery } from '@/lib/hooks'
import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils'
import { formatCurrency, formatDate, formatFileSize, getCurrencyFromGroup } from '@/lib/utils'
import { trpc } from '@/trpc/client'
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
@@ -202,7 +202,7 @@ function ReceiptDialogContent() {
receiptInfo.amount ? (
<>
{formatCurrency(
group.currency,
getCurrencyFromGroup(group),
receiptInfo.amount,
locale,
true,

View File

@@ -4,6 +4,7 @@ import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
import { DocumentsCount } from '@/app/groups/[groupId]/expenses/documents-count'
import { Button } from '@/components/ui/button'
import { getGroupExpenses } from '@/lib/api'
import { Currency } from '@/lib/currency'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { ChevronRight } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
@@ -45,7 +46,7 @@ function Participants({
type Props = {
expense: Expense
currency: string
currency: Currency
groupId: string
participantCount: number
}

View File

@@ -41,12 +41,18 @@ import {
expenseFormSchema,
} from '@/lib/schemas'
import { calculateShare } from '@/lib/totals'
import { cn } from '@/lib/utils'
import {
amountAsDecimal,
amountAsMinorUnits,
cn,
formatCurrency,
getCurrencyFromGroup,
} from '@/lib/utils'
import { AppRouterOutput } from '@/trpc/routers/_app'
import { zodResolver } from '@hookform/resolvers/zod'
import { RecurrenceRule } from '@prisma/client'
import { Save } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react'
@@ -155,6 +161,7 @@ export function ExpenseForm({
runtimeFeatureFlags: RuntimeFeatureFlags
}) {
const t = useTranslations('ExpenseForm')
const locale = useLocale()
const isCreate = expense === undefined
const searchParams = useSearchParams()
@@ -172,18 +179,25 @@ export function ExpenseForm({
return field?.value as RecurrenceRule
}
const defaultSplittingOptions = getDefaultSplittingOptions(group)
const groupCurrency = getCurrencyFromGroup(group)
const form = useForm<ExpenseFormValues>({
resolver: zodResolver(expenseFormSchema),
defaultValues: expense
? {
title: expense.title,
expenseDate: expense.expenseDate ?? new Date(),
amount: String(expense.amount / 100) as unknown as number, // hack
amount: String(
amountAsDecimal(expense.amount, groupCurrency),
) as unknown as number, // hack
category: expense.categoryId,
paidBy: expense.paidById,
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
participant: participantId,
shares: String(shares / 100) as unknown as number,
shares: String(
expense.splitMode === 'BY_AMOUNT'
? amountAsDecimal(shares, groupCurrency)
: shares / 100,
) as unknown as number,
})),
splitMode: expense.splitMode,
saveDefaultSplittingOptions: false,
@@ -197,7 +211,10 @@ export function ExpenseForm({
title: t('reimbursement'),
expenseDate: new Date(),
amount: String(
(Number(searchParams.get('amount')) || 0) / 100,
amountAsDecimal(
Number(searchParams.get('amount')) || 0,
groupCurrency,
),
) as unknown as number, // hack
category: 1, // category with Id 1 is Payment
paidBy: searchParams.get('from') ?? undefined,
@@ -250,6 +267,16 @@ export function ExpenseForm({
const submit = async (values: ExpenseFormValues) => {
await persistDefaultSplittingOptions(group.id, values)
// Store monetary amounts in minor units (cents)
values.amount = amountAsMinorUnits(values.amount, groupCurrency)
values.paidFor = values.paidFor.map(({ participant, shares }) => ({
participant,
shares:
values.splitMode === 'BY_AMOUNT'
? amountAsMinorUnits(shares, groupCurrency)
: shares,
}))
return onSubmit(values, activeUserId ?? undefined)
}
@@ -303,7 +330,9 @@ export function ExpenseForm({
return {
...participant,
shares: String(
Number(amountPerRemaining.toFixed(2)),
Number(
amountPerRemaining.toFixed(groupCurrency.decimal_digits),
),
) as unknown as number,
}
}
@@ -644,11 +673,14 @@ export function ExpenseForm({
) &&
!form.watch('isReimbursement') && (
<span className="text-muted-foreground ml-2">
({group.currency}{' '}
{(
(
{formatCurrency(
groupCurrency,
calculateShare(id, {
amount:
Number(form.watch('amount')) * 100, // Convert to cents
amount: amountAsMinorUnits(
Number(form.watch('amount')),
groupCurrency,
), // Convert to cents
paidFor: field.value.map(
({ participant, shares }) => ({
participant: {
@@ -658,10 +690,14 @@ export function ExpenseForm({
},
shares:
form.watch('splitMode') ===
'BY_PERCENTAGE' ||
form.watch('splitMode') ===
'BY_AMOUNT'
'BY_PERCENTAGE'
? Number(shares) * 100 // Convert percentage to basis points (e.g., 50% -> 5000)
: form.watch('splitMode') ===
'BY_AMOUNT'
? amountAsMinorUnits(
shares,
groupCurrency,
)
: shares,
expenseId: '',
participantId: '',
@@ -670,8 +706,9 @@ export function ExpenseForm({
splitMode: form.watch('splitMode'),
isReimbursement:
form.watch('isReimbursement'),
}) / 100
).toFixed(2)}
}),
locale,
)}
)
</span>
)}

View File

@@ -4,6 +4,7 @@ import { getGroupExpensesAction } from '@/app/groups/[groupId]/expenses/expense-
import { Button } from '@/components/ui/button'
import { SearchBar } from '@/components/ui/search-bar'
import { Skeleton } from '@/components/ui/skeleton'
import { getCurrencyFromGroup } from '@/lib/utils'
import { trpc } from '@/trpc/client'
import dayjs, { type Dayjs } from 'dayjs'
import { useTranslations } from 'next-intl'
@@ -170,7 +171,7 @@ const ExpenseListForSearch = ({
<ExpenseCard
key={expense.id}
expense={expense}
currency={group.currency}
currency={getCurrencyFromGroup(group)}
groupId={groupId}
participantCount={group.participants.length}
/>

View File

@@ -1,3 +1,4 @@
import { formatAmountAsDecimal, getCurrencyFromGroup } from '@/lib/utils'
import { Parser } from '@json2csv/plainjs'
import { PrismaClient } from '@prisma/client'
import contentDisposition from 'content-disposition'
@@ -30,6 +31,7 @@ export async function GET(
id: true,
name: true,
currency: true,
currencyCode: true,
expenses: {
select: {
expenseDate: true,
@@ -91,12 +93,14 @@ export async function GET(
})),
]
const currency = getCurrencyFromGroup(group)
const expenses = group.expenses.map((expense) => ({
date: formatDate(expense.expenseDate),
title: expense.title,
categoryName: expense.category?.name || '',
currency: group.currency,
amount: (expense.amount / 100).toFixed(2),
currency: group.currencyCode ?? group.currency,
amount: formatAmountAsDecimal(expense.amount, currency),
isReimbursement: expense.isReimbursement ? 'Yes' : 'No',
splitMode: splitModeLabel[expense.splitMode],
...Object.fromEntries(
@@ -113,10 +117,10 @@ export async function GET(
)
const isPaidByParticipant = expense.paidById === participant.id
const participantAmountShare = +(
((expense.amount / totalShares) * participantShare) /
100
).toFixed(2)
const participantAmountShare = +formatAmountAsDecimal(
(expense.amount / totalShares) * participantShare,
currency,
)
return [
participant.name,

View File

@@ -12,6 +12,7 @@ export async function GET(
id: true,
name: true,
currency: true,
currencyCode: true,
expenses: {
select: {
createdAt: true,

View File

@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button'
import { Reimbursement } from '@/lib/balances'
import { Currency } from '@/lib/currency'
import { formatCurrency } from '@/lib/utils'
import { Participant } from '@prisma/client'
import { useLocale, useTranslations } from 'next-intl'
@@ -8,7 +9,7 @@ import Link from 'next/link'
type Props = {
reimbursements: Reimbursement[]
participants: Participant[]
currency: string
currency: Currency
groupId: string
}

View File

@@ -1,9 +1,10 @@
import { Currency } from '@/lib/currency'
import { formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
type Props = {
totalGroupSpendings: number
currency: string
currency: Currency
}
export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {

View File

@@ -1,4 +1,5 @@
'use client'
import { Currency } from '@/lib/currency'
import { cn, formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
@@ -7,7 +8,7 @@ export function TotalsYourShare({
currency,
}: {
totalParticipantShare?: number
currency: string
currency: Currency
}) {
const locale = useLocale()
const t = useTranslations('Stats.Totals')

View File

@@ -1,4 +1,5 @@
'use client'
import { Currency } from '@/lib/currency'
import { cn, formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
@@ -7,7 +8,7 @@ export function TotalsYourSpendings({
currency,
}: {
totalParticipantSpendings?: number
currency: string
currency: Currency
}) {
const locale = useLocale()
const t = useTranslations('Stats.Totals')

View File

@@ -4,6 +4,7 @@ import { TotalsYourShare } from '@/app/groups/[groupId]/stats/totals-your-share'
import { TotalsYourSpendings } from '@/app/groups/[groupId]/stats/totals-your-spending'
import { Skeleton } from '@/components/ui/skeleton'
import { useActiveUser } from '@/lib/hooks'
import { getCurrencyFromGroup } from '@/lib/utils'
import { trpc } from '@/trpc/client'
import { useCurrentGroup } from '../current-group-context'
@@ -33,21 +34,23 @@ export function Totals() {
totalParticipantSpendings,
} = data
const currency = getCurrencyFromGroup(group)
return (
<>
<TotalsGroupSpending
totalGroupSpendings={totalGroupSpendings}
currency={group.currency}
currency={currency}
/>
{participantId && (
<>
<TotalsYourSpendings
totalParticipantSpendings={totalParticipantSpendings}
currency={group.currency}
currency={currency}
/>
<TotalsYourShare
totalParticipantShare={totalParticipantShare}
currency={group.currency}
currency={currency}
/>
</>
)}