mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-13 19:16:13 +01:00
Merge branch 'currency-conversion' of github.com:whimcomp/spliit into whimcomp-currency-conversion
# Conflicts: # src/app/groups/[groupId]/expenses/expense-form.tsx
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { CategorySelector } from '@/components/category-selector'
|
||||
import { CurrencySelector } from '@/components/currency-selector'
|
||||
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
|
||||
import { SubmitButton } from '@/components/submit-button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -32,9 +33,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Locale } from '@/i18n'
|
||||
import { randomId } from '@/lib/api'
|
||||
import { defaultCurrencyList, getCurrency } from '@/lib/currency'
|
||||
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
|
||||
import { useActiveUser } from '@/lib/hooks'
|
||||
import { useActiveUser, useCurrencyRate } from '@/lib/hooks'
|
||||
import {
|
||||
ExpenseFormValues,
|
||||
SplittingOptions,
|
||||
@@ -51,7 +54,7 @@ import {
|
||||
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { RecurrenceRule } from '@prisma/client'
|
||||
import { Save } from 'lucide-react'
|
||||
import { ChevronRight, Save } from 'lucide-react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
@@ -161,7 +164,7 @@ export function ExpenseForm({
|
||||
runtimeFeatureFlags: RuntimeFeatureFlags
|
||||
}) {
|
||||
const t = useTranslations('ExpenseForm')
|
||||
const locale = useLocale()
|
||||
const locale = useLocale() as Locale
|
||||
const isCreate = expense === undefined
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
@@ -187,6 +190,15 @@ export function ExpenseForm({
|
||||
title: expense.title,
|
||||
expenseDate: expense.expenseDate ?? new Date(),
|
||||
amount: amountAsDecimal(expense.amount, groupCurrency),
|
||||
originalCurrency:
|
||||
expense.originalCurrency ??
|
||||
group.currencyCode ??
|
||||
('' as unknown as undefined),
|
||||
originalAmount:
|
||||
expense.originalAmount ?? ('' as unknown as undefined),
|
||||
conversionRate: expense.conversionRate
|
||||
? expense.conversionRate.toNumber()
|
||||
: ('' as unknown as undefined),
|
||||
category: expense.categoryId,
|
||||
paidBy: expense.paidById,
|
||||
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
|
||||
@@ -208,9 +220,12 @@ export function ExpenseForm({
|
||||
title: t('reimbursement'),
|
||||
expenseDate: new Date(),
|
||||
amount: amountAsDecimal(
|
||||
Number(searchParams.get('amount')) || 0,
|
||||
groupCurrency,
|
||||
Number(searchParams.get('amount')) || 0,
|
||||
groupCurrency,
|
||||
),
|
||||
originalCurrency: group.currencyCode ?? ('' as unknown as undefined),
|
||||
originalAmount: '' as unknown as undefined,
|
||||
conversionRate: '' as unknown as undefined,
|
||||
category: 1, // category with Id 1 is Payment
|
||||
paidBy: searchParams.get('from') ?? undefined,
|
||||
paidFor: [
|
||||
@@ -234,6 +249,9 @@ export function ExpenseForm({
|
||||
? new Date(searchParams.get('date') as string)
|
||||
: new Date(),
|
||||
amount: Number(searchParams.get('amount')) || 0,
|
||||
originalCurrency: group.currencyCode ?? ('' as unknown as undefined),
|
||||
originalAmount: '' as unknown as undefined,
|
||||
conversionRate: '' as unknown as undefined,
|
||||
category: searchParams.get('categoryId')
|
||||
? Number(searchParams.get('categoryId'))
|
||||
: 0, // category with Id 0 is General
|
||||
@@ -272,6 +290,12 @@ export function ExpenseForm({
|
||||
? amountAsMinorUnits(shares, groupCurrency)
|
||||
: shares,
|
||||
}))
|
||||
|
||||
// Currency should be blank if same as group currency
|
||||
if (!conversionRequired) {
|
||||
delete values.originalAmount
|
||||
delete values.originalCurrency
|
||||
}
|
||||
return onSubmit(values, activeUserId ?? undefined)
|
||||
}
|
||||
|
||||
@@ -282,6 +306,23 @@ export function ExpenseForm({
|
||||
|
||||
const sExpense = isIncome ? 'Income' : 'Expense'
|
||||
|
||||
const originalCurrency = getCurrency(
|
||||
form.getValues('originalCurrency'),
|
||||
locale,
|
||||
'Custom',
|
||||
)
|
||||
const exchangeRate = useCurrencyRate(
|
||||
form.watch('expenseDate'),
|
||||
form.watch('originalCurrency') ?? '',
|
||||
groupCurrency.code,
|
||||
)
|
||||
|
||||
const conversionRequired =
|
||||
group.currencyCode &&
|
||||
group.currencyCode.length &&
|
||||
originalCurrency.code.length &&
|
||||
originalCurrency.code !== group.currencyCode
|
||||
|
||||
useEffect(() => {
|
||||
setManuallyEditedParticipants(new Set())
|
||||
}, [form.watch('splitMode'), form.watch('amount')])
|
||||
@@ -340,6 +381,71 @@ export function ExpenseForm({
|
||||
form.watch('splitMode'),
|
||||
])
|
||||
|
||||
const [usingCustomConversionRate, setUsingCustomConversionRate] = useState(
|
||||
!!form.formState.defaultValues?.conversionRate,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!usingCustomConversionRate && exchangeRate.data) {
|
||||
form.setValue('conversionRate', exchangeRate.data)
|
||||
}
|
||||
}, [exchangeRate.data, usingCustomConversionRate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.getFieldState('originalAmount').isTouched) return
|
||||
const originalAmount = form.getValues('originalAmount') ?? 0
|
||||
const conversionRate = form.getValues('conversionRate')
|
||||
|
||||
if (conversionRate && originalAmount) {
|
||||
const rate = Number(conversionRate)
|
||||
const convertedAmount = originalAmount * rate
|
||||
if (!Number.isNaN(convertedAmount)) {
|
||||
const v = enforceCurrencyPattern(
|
||||
convertedAmount.toFixed(groupCurrency.decimal_digits),
|
||||
)
|
||||
const income = Number(v) < 0
|
||||
setIsIncome(income)
|
||||
if (income) form.setValue('isReimbursement', false)
|
||||
form.setValue('amount', Number(v))
|
||||
}
|
||||
}
|
||||
}, [
|
||||
form.watch('originalAmount'),
|
||||
form.watch('conversionRate'),
|
||||
form.getFieldState('originalAmount').isTouched,
|
||||
])
|
||||
|
||||
let conversionRateMessage = ''
|
||||
if (exchangeRate.isLoading) {
|
||||
conversionRateMessage = t('conversionRateState.loading')
|
||||
} else {
|
||||
let ratesDisplay = ''
|
||||
if (exchangeRate.data) {
|
||||
// non breaking spaces so the rate text is not split with line feeds
|
||||
ratesDisplay = `${form.getValues('originalCurrency')}\xa01\xa0=\xa0${
|
||||
group.currencyCode
|
||||
}\xa0${exchangeRate.data}`
|
||||
}
|
||||
if (exchangeRate.error) {
|
||||
if (exchangeRate.error instanceof RangeError && exchangeRate.data)
|
||||
conversionRateMessage = t('conversionRateState.dateMismatch', {
|
||||
date: exchangeRate.error.message,
|
||||
})
|
||||
else {
|
||||
conversionRateMessage = t('conversionRateState.error')
|
||||
}
|
||||
conversionRateMessage +=
|
||||
' ' +
|
||||
(ratesDisplay.length
|
||||
? `${t('conversionRateState.staleRate')} ${ratesDisplay}`
|
||||
: t('conversionRateState.noRate'))
|
||||
} else {
|
||||
conversionRateMessage = ratesDisplay.length
|
||||
? `${t('conversionRateState.success')} ${ratesDisplay}`
|
||||
: t('conversionRateState.currencyNotFound')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(submit)}>
|
||||
@@ -406,11 +512,175 @@ export function ExpenseForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="originalCurrency"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormItem className="sm:order-3">
|
||||
<FormLabel>{t(`${sExpense}.currencyField.label`)}</FormLabel>
|
||||
<FormControl>
|
||||
{group.currencyCode ? (
|
||||
<CurrencySelector
|
||||
currencies={defaultCurrencyList(locale, '')}
|
||||
defaultValue={form.watch(field.name) ?? ''}
|
||||
isLoading={false}
|
||||
onValueChange={(v) => onChange(v)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className="text-base"
|
||||
disabled={true}
|
||||
{...field}
|
||||
placeholder={group.currency}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(`${sExpense}.currencyField.description`)}{' '}
|
||||
{!group.currencyCode && t('conversionUnavailable')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`sm:order-4 ${
|
||||
!conversionRequired ? 'max-sm:hidden sm:invisible' : ''
|
||||
} col-span-2 md:col-span-1 space-y-2`}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="originalAmount"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('originalAmountField.label')}</FormLabel>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span>{originalCurrency.symbol}</span>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-base max-w-[120px]"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
onChange={(event) => {
|
||||
const v = enforceCurrencyPattern(event.target.value)
|
||||
onChange(v)
|
||||
}}
|
||||
{...field}
|
||||
onFocus={(e) => {
|
||||
const target = e.currentTarget
|
||||
setTimeout(() => target.select(), 1)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{isNaN(form.getValues('expenseDate').getTime()) ? (
|
||||
t('conversionRateState.noDate')
|
||||
) : form.getValues('expenseDate') &&
|
||||
!usingCustomConversionRate ? (
|
||||
<>
|
||||
{conversionRateMessage}
|
||||
{!exchangeRate.isLoading && (
|
||||
<Button
|
||||
className="h-auto py-0"
|
||||
variant="link"
|
||||
onClick={() => exchangeRate.refresh()}
|
||||
>
|
||||
{t('conversionRateState.refresh')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
t('conversionRateState.customRate')
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Collapsible
|
||||
open={usingCustomConversionRate}
|
||||
onOpenChange={setUsingCustomConversionRate}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="link" className="-mx-4">
|
||||
{usingCustomConversionRate
|
||||
? t('conversionRateField.useApi')
|
||||
: t('conversionRateField.useCustom')}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="conversionRate"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormItem
|
||||
className={`sm:order-4 ${
|
||||
!conversionRequired
|
||||
? 'max-sm:hidden sm:invisible'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<FormLabel>{t('conversionRateField.label')}</FormLabel>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span>
|
||||
{originalCurrency.symbol} 1 = {group.currency}
|
||||
</span>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-base max-w-[120px]"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
onChange={(event) => {
|
||||
const v = enforceCurrencyPattern(
|
||||
event.target.value,
|
||||
)
|
||||
onChange(v)
|
||||
}}
|
||||
{...field}
|
||||
onFocus={(e) => {
|
||||
const target = e.currentTarget
|
||||
setTimeout(() => target.select(), 1)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem className="order-3 sm:order-2">
|
||||
<FormLabel>{t('categoryField.label')}</FormLabel>
|
||||
<CategorySelector
|
||||
categories={categories}
|
||||
defaultValue={
|
||||
form.watch(field.name) // may be overwritten externally
|
||||
}
|
||||
onValueChange={field.onChange}
|
||||
isLoading={isCategoryLoading}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t(`${sExpense}.categoryFieldDescription`)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormItem className="sm:order-3">
|
||||
<FormItem className="sm:order-5">
|
||||
<FormLabel>{t('amountField.label')}</FormLabel>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span>{group.currency}</span>
|
||||
@@ -463,28 +733,6 @@ export function ExpenseForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem className="order-3 sm:order-2">
|
||||
<FormLabel>{t('categoryField.label')}</FormLabel>
|
||||
<CategorySelector
|
||||
categories={categories}
|
||||
defaultValue={
|
||||
form.watch(field.name) // may be overwritten externally
|
||||
}
|
||||
onValueChange={field.onChange}
|
||||
isLoading={isCategoryLoading}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t(`${sExpense}.categoryFieldDescription`)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="paidBy"
|
||||
@@ -623,7 +871,7 @@ export function ExpenseForm({
|
||||
data-id={`${id}/${form.getValues().splitMode}/${
|
||||
group.currency
|
||||
}`}
|
||||
className="flex items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
|
||||
className="flex flex-wrap gap-y-4 items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
|
||||
>
|
||||
<FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
@@ -707,106 +955,209 @@ export function ExpenseForm({
|
||||
)}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
{form.getValues().splitMode !== 'EVENLY' && (
|
||||
<FormField
|
||||
name={`paidFor[${field.value.findIndex(
|
||||
({ participant }) => participant === id,
|
||||
)}].shares`}
|
||||
render={() => {
|
||||
const sharesLabel = (
|
||||
<span
|
||||
className={cn('text-sm', {
|
||||
'text-muted': !field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
})}
|
||||
>
|
||||
{match(form.getValues().splitMode)
|
||||
.with('BY_SHARES', () => (
|
||||
<>{t('shares')}</>
|
||||
))
|
||||
.with('BY_PERCENTAGE', () => <>%</>)
|
||||
.with('BY_AMOUNT', () => (
|
||||
<>{group.currency}</>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<></>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-1 items-center">
|
||||
{form.getValues().splitMode ===
|
||||
'BY_AMOUNT' && sharesLabel}
|
||||
<FormControl>
|
||||
<Input
|
||||
key={String(
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
)}
|
||||
className="text-base w-[80px] -my-2"
|
||||
type="text"
|
||||
disabled={
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
)
|
||||
}
|
||||
value={
|
||||
field.value?.find(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
)?.shares
|
||||
}
|
||||
onChange={(event) => {
|
||||
field.onChange(
|
||||
field.value.map((p) =>
|
||||
p.participant === id
|
||||
? {
|
||||
participant: id,
|
||||
shares:
|
||||
enforceCurrencyPattern(
|
||||
event.target.value,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
<div className="flex">
|
||||
{form.getValues().splitMode === 'BY_AMOUNT' &&
|
||||
!!conversionRequired && (
|
||||
<FormField
|
||||
name={`paidFor[${field.value.findIndex(
|
||||
({ participant }) => participant === id,
|
||||
)}].originalAmount`}
|
||||
render={() => {
|
||||
const sharesLabel = (
|
||||
<span
|
||||
className={cn('text-sm', {
|
||||
'text-muted': !field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
})}
|
||||
>
|
||||
{originalCurrency.symbol}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-1 items-center">
|
||||
{sharesLabel}
|
||||
<FormControl>
|
||||
<Input
|
||||
key={String(
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
)}
|
||||
className="text-base w-[80px] -my-2"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
disabled={
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
)
|
||||
}
|
||||
value={
|
||||
field.value.find(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
)?.originalAmount ?? ''
|
||||
}
|
||||
onChange={(event) => {
|
||||
const originalAmount = Number(
|
||||
event.target.value,
|
||||
)
|
||||
let convertedAmount = ''
|
||||
if (
|
||||
!Number.isNaN(
|
||||
originalAmount,
|
||||
) &&
|
||||
exchangeRate.data
|
||||
) {
|
||||
convertedAmount = (
|
||||
originalAmount *
|
||||
exchangeRate.data
|
||||
).toFixed(
|
||||
groupCurrency.decimal_digits,
|
||||
)
|
||||
}
|
||||
field.onChange(
|
||||
field.value.map((p) =>
|
||||
p.participant === id
|
||||
? {
|
||||
participant: id,
|
||||
originalAmount:
|
||||
event.target
|
||||
.value,
|
||||
shares:
|
||||
enforceCurrencyPattern(
|
||||
convertedAmount,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
)
|
||||
setManuallyEditedParticipants(
|
||||
(prev) =>
|
||||
new Set(prev).add(id),
|
||||
)
|
||||
}}
|
||||
step={
|
||||
10 **
|
||||
-originalCurrency.decimal_digits
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<ChevronRight className="h-4 w-4 mx-1 opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{form.getValues().splitMode !== 'EVENLY' && (
|
||||
<FormField
|
||||
name={`paidFor[${field.value.findIndex(
|
||||
({ participant }) => participant === id,
|
||||
)}].shares`}
|
||||
render={() => {
|
||||
const sharesLabel = (
|
||||
<span
|
||||
className={cn('text-sm', {
|
||||
'text-muted': !field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
})}
|
||||
>
|
||||
{match(form.getValues().splitMode)
|
||||
.with('BY_SHARES', () => (
|
||||
<>{t('shares')}</>
|
||||
))
|
||||
.with('BY_PERCENTAGE', () => <>%</>)
|
||||
.with('BY_AMOUNT', () => (
|
||||
<>{group.currency}</>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<></>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-1 items-center">
|
||||
{form.getValues().splitMode ===
|
||||
'BY_AMOUNT' && sharesLabel}
|
||||
<FormControl>
|
||||
<Input
|
||||
key={String(
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
)
|
||||
setManuallyEditedParticipants(
|
||||
(prev) => new Set(prev).add(id),
|
||||
)
|
||||
}}
|
||||
inputMode={
|
||||
form.getValues().splitMode ===
|
||||
'BY_AMOUNT'
|
||||
? 'decimal'
|
||||
: 'numeric'
|
||||
}
|
||||
step={
|
||||
form.getValues().splitMode ===
|
||||
'BY_AMOUNT'
|
||||
? 0.01
|
||||
: 1
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
{[
|
||||
'BY_SHARES',
|
||||
'BY_PERCENTAGE',
|
||||
].includes(
|
||||
form.getValues().splitMode,
|
||||
) && sharesLabel}
|
||||
)}
|
||||
className="text-base w-[80px] -my-2"
|
||||
type="text"
|
||||
disabled={
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
)
|
||||
}
|
||||
value={
|
||||
field.value?.find(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
)?.shares
|
||||
}
|
||||
onChange={(event) => {
|
||||
field.onChange(
|
||||
field.value.map((p) =>
|
||||
p.participant === id
|
||||
? {
|
||||
participant: id,
|
||||
shares:
|
||||
enforceCurrencyPattern(
|
||||
event.target
|
||||
.value,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
)
|
||||
setManuallyEditedParticipants(
|
||||
(prev) =>
|
||||
new Set(prev).add(id),
|
||||
)
|
||||
}}
|
||||
inputMode={
|
||||
form.getValues().splitMode ===
|
||||
'BY_AMOUNT'
|
||||
? 'decimal'
|
||||
: 'numeric'
|
||||
}
|
||||
step={
|
||||
form.getValues().splitMode ===
|
||||
'BY_AMOUNT'
|
||||
? 10 **
|
||||
-groupCurrency.decimal_digits
|
||||
: 1
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
{[
|
||||
'BY_SHARES',
|
||||
'BY_PERCENTAGE',
|
||||
].includes(
|
||||
form.getValues().splitMode,
|
||||
) && sharesLabel}
|
||||
</div>
|
||||
<FormMessage className="float-right" />
|
||||
</div>
|
||||
<FormMessage className="float-right" />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCurrency } from '@/lib/currency'
|
||||
import { formatAmountAsDecimal, getCurrencyFromGroup } from '@/lib/utils'
|
||||
import { Parser } from '@json2csv/plainjs'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
@@ -38,6 +39,9 @@ export async function GET(
|
||||
title: true,
|
||||
category: { select: { name: true } },
|
||||
amount: true,
|
||||
originalAmount: true,
|
||||
originalCurrency: true,
|
||||
conversionRate: true,
|
||||
paidById: true,
|
||||
paidFor: { select: { participantId: true, shares: true } },
|
||||
isReimbursement: true,
|
||||
@@ -54,30 +58,29 @@ export async function GET(
|
||||
|
||||
/*
|
||||
|
||||
CSV Structure:
|
||||
|
||||
--------------------------------------------------------------
|
||||
| Date | Description | Category | Currency | Cost
|
||||
--------------------------------------------------------------
|
||||
| Is Reimbursement | Split mode | UserA | UserB
|
||||
--------------------------------------------------------------
|
||||
|
||||
Columns:
|
||||
CSV Columns:
|
||||
- Date: The date of the expense.
|
||||
- Description: A brief description of the expense.
|
||||
- Category: The category of the expense (e.g., Food, Travel, etc.).
|
||||
- Currency: The currency in which the expense is recorded.
|
||||
- Cost: The amount spent.
|
||||
- Original cost: The amount spent in the original currency.
|
||||
- Original currency: The currency the amount was originally spent in.
|
||||
- Conversion rate: The rate used to convert the amount.
|
||||
- Is Reimbursement: Whether the expense is a reimbursement or not.
|
||||
- Split mode: The method used to split the expense (e.g., Evenly, By shares, By percentage, By amount).
|
||||
- UserA, UserB: User-specific data or balances (e.g., amount owed or contributed by each user).
|
||||
|
||||
Example Row:
|
||||
------------------------------------------------------------------------------------------
|
||||
| 2025-01-06 | Dinner with team | Food | ₹ | 5000 | No | Evenly | John | Jane
|
||||
------------------------------------------------------------------------------------------
|
||||
Example Table:
|
||||
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
|
||||
| Date | Description | Category | Currency | Cost | Original cost | Original currency | Conversion rate | Is reinbursement | Split mode | User A | User B |
|
||||
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
|
||||
| 2025-01-06 | Dinner with team | Food | INR | 5000 | | | | No | Evenly | 2500 | -2500 |
|
||||
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
|
||||
| 2025-02-07 | Plane tickets | Travel | INR | 97264.09 | 1000 | EUR | 97.2641 | No | Unevenly - By amount | -80000 | -17264.09 |
|
||||
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
|
||||
|
||||
*/
|
||||
*/
|
||||
|
||||
const fields = [
|
||||
{ label: 'Date', value: 'date' },
|
||||
@@ -85,6 +88,9 @@ export async function GET(
|
||||
{ label: 'Category', value: 'categoryName' },
|
||||
{ label: 'Currency', value: 'currency' },
|
||||
{ label: 'Cost', value: 'amount' },
|
||||
{ label: 'Original cost', value: 'originalAmount' },
|
||||
{ label: 'Original currency', value: 'originalCurrency' },
|
||||
{ label: 'Conversion rate', value: 'conversionRate' },
|
||||
{ label: 'Is Reimbursement', value: 'isReimbursement' },
|
||||
{ label: 'Split mode', value: 'splitMode' },
|
||||
...group.participants.map((participant) => ({
|
||||
@@ -101,6 +107,16 @@ export async function GET(
|
||||
categoryName: expense.category?.name || '',
|
||||
currency: group.currencyCode ?? group.currency,
|
||||
amount: formatAmountAsDecimal(expense.amount, currency),
|
||||
originalAmount: expense.originalAmount
|
||||
? formatAmountAsDecimal(
|
||||
expense.originalAmount,
|
||||
getCurrency(expense.originalCurrency),
|
||||
)
|
||||
: null,
|
||||
originalCurrency: expense.originalCurrency,
|
||||
conversionRate: expense.conversionRate
|
||||
? expense.conversionRate.toString()
|
||||
: null,
|
||||
isReimbursement: expense.isReimbursement ? 'Yes' : 'No',
|
||||
splitMode: splitModeLabel[expense.splitMode],
|
||||
...Object.fromEntries(
|
||||
|
||||
@@ -20,6 +20,9 @@ export async function GET(
|
||||
title: true,
|
||||
category: { select: { grouping: true, name: true } },
|
||||
amount: true,
|
||||
originalAmount: true,
|
||||
originalCurrency: true,
|
||||
conversionRate: true,
|
||||
paidById: true,
|
||||
paidFor: { select: { participantId: true, shares: true } },
|
||||
isReimbursement: true,
|
||||
|
||||
@@ -71,6 +71,9 @@ export async function createExpense(
|
||||
expenseDate: expenseFormValues.expenseDate,
|
||||
categoryId: expenseFormValues.category,
|
||||
amount: expenseFormValues.amount,
|
||||
originalAmount: expenseFormValues.originalAmount,
|
||||
originalCurrency: expenseFormValues.originalCurrency,
|
||||
conversionRate: expenseFormValues.conversionRate,
|
||||
title: expenseFormValues.title,
|
||||
paidById: expenseFormValues.paidBy,
|
||||
splitMode: expenseFormValues.splitMode,
|
||||
@@ -206,6 +209,9 @@ export async function updateExpense(
|
||||
data: {
|
||||
expenseDate: expenseFormValues.expenseDate,
|
||||
amount: expenseFormValues.amount,
|
||||
originalAmount: expenseFormValues.originalAmount,
|
||||
originalCurrency: expenseFormValues.originalCurrency,
|
||||
conversionRate: expenseFormValues.conversionRate,
|
||||
title: expenseFormValues.title,
|
||||
categoryId: expenseFormValues.category,
|
||||
paidById: expenseFormValues.paidBy,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import useSWR, { Fetcher } from 'swr'
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const getMatches = (query: string): boolean => {
|
||||
@@ -64,3 +66,62 @@ export function useActiveUser(groupId?: string) {
|
||||
|
||||
return activeUser
|
||||
}
|
||||
|
||||
interface FrankfurterAPIResponse {
|
||||
base: string
|
||||
date: string
|
||||
rates: Record<string, number>
|
||||
}
|
||||
|
||||
const fetcher: Fetcher<FrankfurterAPIResponse> = (url: string) =>
|
||||
fetch(url).then(async (res) => {
|
||||
if (!res.ok)
|
||||
throw new TypeError('Unsuccessful response from API', { cause: res })
|
||||
return res.json() as Promise<FrankfurterAPIResponse>
|
||||
})
|
||||
|
||||
export function useCurrencyRate(
|
||||
date: Date,
|
||||
baseCurrency: string,
|
||||
targetCurrency: string,
|
||||
) {
|
||||
const dateString = dayjs(date).format('YYYY-MM-DD')
|
||||
|
||||
// Only send request if both currency codes are given and not the same
|
||||
const url =
|
||||
!isNaN(date.getTime()) &&
|
||||
!!baseCurrency.length &&
|
||||
!!targetCurrency.length &&
|
||||
baseCurrency !== targetCurrency &&
|
||||
`https://api.frankfurter.app/${dateString}?base=${baseCurrency}`
|
||||
const { data, error, isLoading, mutate } = useSWR<FrankfurterAPIResponse>(
|
||||
url,
|
||||
fetcher,
|
||||
{ shouldRetryOnError: false, revalidateOnFocus: false },
|
||||
)
|
||||
|
||||
if (data) {
|
||||
let exchangeRate = undefined
|
||||
let sentError = error
|
||||
if (!error && data.date !== dateString) {
|
||||
// this happens if for example, the requested date is in the future.
|
||||
sentError = new RangeError(data.date)
|
||||
}
|
||||
if (data.rates[targetCurrency]) {
|
||||
exchangeRate = data.rates[targetCurrency]
|
||||
}
|
||||
return {
|
||||
data: exchangeRate,
|
||||
error: sentError,
|
||||
isLoading,
|
||||
refresh: mutate,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
refresh: mutate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,19 @@ export const groupFormSchema = z
|
||||
|
||||
export type GroupFormValues = z.infer<typeof groupFormSchema>
|
||||
|
||||
const inputCoercedToNumber = z.union([
|
||||
z.number(),
|
||||
z.string().transform((value, ctx) => {
|
||||
const valueAsNumber = Number(value)
|
||||
if (Number.isNaN(valueAsNumber))
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'invalidNumber',
|
||||
})
|
||||
return valueAsNumber
|
||||
}),
|
||||
])
|
||||
|
||||
export const expenseFormSchema = z
|
||||
.object({
|
||||
expenseDate: z.coerce.date(),
|
||||
@@ -55,11 +68,27 @@ export const expenseFormSchema = z
|
||||
)
|
||||
.refine((amount) => amount != 0, 'amountNotZero')
|
||||
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
|
||||
originalAmount: z
|
||||
.union([
|
||||
z.literal('').transform(() => undefined),
|
||||
inputCoercedToNumber
|
||||
.refine((amount) => amount != 0, 'amountNotZero')
|
||||
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
|
||||
])
|
||||
.optional(),
|
||||
originalCurrency: z.union([z.string().length(3).nullish(), z.literal('')]),
|
||||
conversionRate: z
|
||||
.union([
|
||||
z.literal('').transform(() => undefined),
|
||||
inputCoercedToNumber.refine((amount) => amount > 0, 'ratePositive'),
|
||||
])
|
||||
.optional(),
|
||||
paidBy: z.string({ required_error: 'paidByRequired' }),
|
||||
paidFor: z
|
||||
.array(
|
||||
z.object({
|
||||
participant: z.string(),
|
||||
originalAmount: z.string().optional(), // For converting shares by amounts in original currency, not saved.
|
||||
shares: z.union([
|
||||
z.number(),
|
||||
z.string().transform((value, ctx) => {
|
||||
@@ -163,17 +192,18 @@ export const expenseFormSchema = z
|
||||
// Format the share split as a number (if from form submission)
|
||||
return {
|
||||
...expense,
|
||||
paidFor: expense.paidFor.map(({ participant, shares }) => {
|
||||
paidFor: expense.paidFor.map((paidFor) => {
|
||||
const shares = paidFor.shares
|
||||
if (typeof shares === 'string' && expense.splitMode !== 'BY_AMOUNT') {
|
||||
// For splitting not by amount, preserve the previous behaviour of multiplying the share by 100
|
||||
return {
|
||||
participant,
|
||||
...paidFor,
|
||||
shares: Math.round(Number(shares) * 100),
|
||||
}
|
||||
}
|
||||
// Otherwise, no need as the number will have been formatted according to currency.
|
||||
return {
|
||||
participant,
|
||||
...paidFor,
|
||||
shares: Number(shares),
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client' // <-- to make sure we can mount the Provider from a server component
|
||||
import { Prisma } from '@prisma/client'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { httpBatchLink } from '@trpc/client'
|
||||
@@ -8,6 +9,15 @@ import superjson from 'superjson'
|
||||
import { makeQueryClient } from './query-client'
|
||||
import type { AppRouter } from './routers/_app'
|
||||
|
||||
superjson.registerCustom<Prisma.Decimal, string>(
|
||||
{
|
||||
isApplicable: (v): v is Prisma.Decimal => Prisma.Decimal.isDecimal(v),
|
||||
serialize: (v) => v.toJSON(),
|
||||
deserialize: (v) => new Prisma.Decimal(v),
|
||||
},
|
||||
'decimal.js',
|
||||
)
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>()
|
||||
|
||||
let clientQueryClientSingleton: QueryClient
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { initTRPC } from '@trpc/server'
|
||||
import { cache } from 'react'
|
||||
import superjson from 'superjson'
|
||||
|
||||
superjson.registerCustom<Prisma.Decimal, string>(
|
||||
{
|
||||
isApplicable: (v): v is Prisma.Decimal => Prisma.Decimal.isDecimal(v),
|
||||
serialize: (v) => v.toJSON(),
|
||||
deserialize: (v) => new Prisma.Decimal(v),
|
||||
},
|
||||
'decimal.js',
|
||||
)
|
||||
|
||||
export const createTRPCContext = cache(async () => {
|
||||
/**
|
||||
* @see: https://trpc.io/docs/server/context
|
||||
|
||||
Reference in New Issue
Block a user