mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-28 02:16:12 +01:00
Add currency and exchange rate with Frankfurter per expense
This commit is contained in:
committed by
Peter Smit
parent
0e77a666f4
commit
7e7bb94d3b
@@ -141,6 +141,10 @@
|
|||||||
"label": "Income date",
|
"label": "Income date",
|
||||||
"description": "Enter the date the income was received."
|
"description": "Enter the date the income was received."
|
||||||
},
|
},
|
||||||
|
"currencyField": {
|
||||||
|
"label": "Currency of income",
|
||||||
|
"description": "The currency in which the income was received."
|
||||||
|
},
|
||||||
"categoryFieldDescription": "Select the income category.",
|
"categoryFieldDescription": "Select the income category.",
|
||||||
"paidByField": {
|
"paidByField": {
|
||||||
"label": "Received by",
|
"label": "Received by",
|
||||||
@@ -165,6 +169,10 @@
|
|||||||
"label": "Expense date",
|
"label": "Expense date",
|
||||||
"description": "Enter the date the expense was paid."
|
"description": "Enter the date the expense was paid."
|
||||||
},
|
},
|
||||||
|
"currencyField": {
|
||||||
|
"label": "Currency of expense",
|
||||||
|
"description": "The currency in which the expense was paid."
|
||||||
|
},
|
||||||
"categoryFieldDescription": "Select the expense category.",
|
"categoryFieldDescription": "Select the expense category.",
|
||||||
"paidByField": {
|
"paidByField": {
|
||||||
"label": "Paid by",
|
"label": "Paid by",
|
||||||
@@ -190,6 +198,27 @@
|
|||||||
"amountField": {
|
"amountField": {
|
||||||
"label": "Amount"
|
"label": "Amount"
|
||||||
},
|
},
|
||||||
|
"conversionUnavailable": "To set a different currency per expense and convert amounts, select a non-custom currency for the group.",
|
||||||
|
"originalAmountField": {
|
||||||
|
"label": "Amount to convert"
|
||||||
|
},
|
||||||
|
"conversionRateField": {
|
||||||
|
"useApi": "Use rates from Frankfurter",
|
||||||
|
"useCustom": "Use custom rate",
|
||||||
|
"label": "Exchange rate"
|
||||||
|
},
|
||||||
|
"conversionRateState": {
|
||||||
|
"loading": "Getting exchange rates…",
|
||||||
|
"success": "Obtained rates:",
|
||||||
|
"error": "Oops, we could not get the most recent rates.",
|
||||||
|
"staleRate": "Using rate:",
|
||||||
|
"noRate": "Enter a custom rate below.",
|
||||||
|
"currencyNotFound": "Oops, Frankfurter does not have the rate for this currency at this day.",
|
||||||
|
"noDate": "Enter the expense date to get a conversion rate.",
|
||||||
|
"dateMismatch": "Rates from date: {date}",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"customRate": "Using custom rate"
|
||||||
|
},
|
||||||
"isReimbursementField": {
|
"isReimbursementField": {
|
||||||
"label": "This is a reimbursement"
|
"label": "This is a reimbursement"
|
||||||
},
|
},
|
||||||
@@ -331,6 +360,7 @@
|
|||||||
"amountRequired": "You must enter an amount.",
|
"amountRequired": "You must enter an amount.",
|
||||||
"amountNotZero": "The amount must not be zero.",
|
"amountNotZero": "The amount must not be zero.",
|
||||||
"amountTenMillion": "The amount must be lower than 10,000,000.",
|
"amountTenMillion": "The amount must be lower than 10,000,000.",
|
||||||
|
"ratePositive": "The rate must be strictly greater than zero.",
|
||||||
"paidByRequired": "You must select a participant.",
|
"paidByRequired": "You must select a participant.",
|
||||||
"paidForMin1": "The expense must be paid for at least one participant.",
|
"paidForMin1": "The expense must be paid for at least one participant.",
|
||||||
"noZeroShares": "All shares must be higher than 0.",
|
"noZeroShares": "All shares must be higher than 0.",
|
||||||
|
|||||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -56,6 +56,7 @@
|
|||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
|
"swr": "^2.3.3",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ts-pattern": "^5.0.6",
|
"ts-pattern": "^5.0.6",
|
||||||
@@ -11040,7 +11041,6 @@
|
|||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -16850,6 +16850,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swr": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dequal": "^2.0.3",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/symbol-tree": {
|
"node_modules/symbol-tree": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
@@ -17369,6 +17382,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
|
"swr": "^2.3.3",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ts-pattern": "^5.0.6",
|
"ts-pattern": "^5.0.6",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Expense" ADD COLUMN "conversionRate" DECIMAL(65,30),
|
||||||
|
ADD COLUMN "originalAmount" INTEGER,
|
||||||
|
ADD COLUMN "originalCurrency" TEXT;
|
||||||
@@ -40,25 +40,28 @@ model Category {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Expense {
|
model Expense {
|
||||||
id String @id
|
id String @id
|
||||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
||||||
title String
|
title String
|
||||||
category Category? @relation(fields: [categoryId], references: [id])
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
categoryId Int @default(0)
|
categoryId Int @default(0)
|
||||||
amount Int
|
amount Int
|
||||||
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
originalAmount Int?
|
||||||
paidById String
|
originalCurrency String?
|
||||||
paidFor ExpensePaidFor[]
|
conversionRate Decimal?
|
||||||
groupId String
|
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
||||||
isReimbursement Boolean @default(false)
|
paidById String
|
||||||
splitMode SplitMode @default(EVENLY)
|
paidFor ExpensePaidFor[]
|
||||||
createdAt DateTime @default(now())
|
groupId String
|
||||||
documents ExpenseDocument[]
|
isReimbursement Boolean @default(false)
|
||||||
notes String?
|
splitMode SplitMode @default(EVENLY)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
documents ExpenseDocument[]
|
||||||
|
notes String?
|
||||||
|
|
||||||
recurrenceRule RecurrenceRule? @default(NONE)
|
recurrenceRule RecurrenceRule? @default(NONE)
|
||||||
recurringExpenseLink RecurringExpenseLink?
|
recurringExpenseLink RecurringExpenseLink?
|
||||||
recurringExpenseLinkId String?
|
recurringExpenseLinkId String?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,16 +82,16 @@ enum SplitMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model RecurringExpenseLink {
|
model RecurringExpenseLink {
|
||||||
id String @id
|
id String @id
|
||||||
groupId String
|
groupId String
|
||||||
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
|
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
|
||||||
currentFrameExpenseId String @unique
|
currentFrameExpenseId String @unique
|
||||||
|
|
||||||
// Note: We do not want to link to the next expense because once it is created, it should be
|
// Note: We do not want to link to the next expense because once it is created, it should be
|
||||||
// treated as it's own independent entity. This means that if a user wants to delete an Expense
|
// treated as it's own independent entity. This means that if a user wants to delete an Expense
|
||||||
// and any prior related recurring expenses, they'll need to delete them one by one.
|
// and any prior related recurring expenses, they'll need to delete them one by one.
|
||||||
nextExpenseCreatedAt DateTime?
|
nextExpenseCreatedAt DateTime?
|
||||||
nextExpenseDate DateTime
|
nextExpenseDate DateTime
|
||||||
|
|
||||||
@@index([groupId])
|
@@index([groupId])
|
||||||
@@index([groupId, nextExpenseCreatedAt, nextExpenseDate(sort: Desc)])
|
@@index([groupId, nextExpenseCreatedAt, nextExpenseDate(sort: Desc)])
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CategorySelector } from '@/components/category-selector'
|
import { CategorySelector } from '@/components/category-selector'
|
||||||
|
import { CurrencySelector } from '@/components/currency-selector'
|
||||||
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
|
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
|
||||||
import { SubmitButton } from '@/components/submit-button'
|
import { SubmitButton } from '@/components/submit-button'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -32,9 +33,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import { Locale } from '@/i18n'
|
||||||
import { randomId } from '@/lib/api'
|
import { randomId } from '@/lib/api'
|
||||||
|
import { defaultCurrencyList, getCurrency } from '@/lib/currency'
|
||||||
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
|
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
|
||||||
import { useActiveUser } from '@/lib/hooks'
|
import { useActiveUser, useCurrencyRate } from '@/lib/hooks'
|
||||||
import {
|
import {
|
||||||
ExpenseFormValues,
|
ExpenseFormValues,
|
||||||
SplittingOptions,
|
SplittingOptions,
|
||||||
@@ -51,7 +54,7 @@ import {
|
|||||||
import { AppRouterOutput } from '@/trpc/routers/_app'
|
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { RecurrenceRule } from '@prisma/client'
|
import { RecurrenceRule } from '@prisma/client'
|
||||||
import { Save } from 'lucide-react'
|
import { ChevronRight, Save } from 'lucide-react'
|
||||||
import { useLocale, useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
@@ -161,7 +164,7 @@ export function ExpenseForm({
|
|||||||
runtimeFeatureFlags: RuntimeFeatureFlags
|
runtimeFeatureFlags: RuntimeFeatureFlags
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('ExpenseForm')
|
const t = useTranslations('ExpenseForm')
|
||||||
const locale = useLocale()
|
const locale = useLocale() as Locale
|
||||||
const isCreate = expense === undefined
|
const isCreate = expense === undefined
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
@@ -187,6 +190,15 @@ export function ExpenseForm({
|
|||||||
title: expense.title,
|
title: expense.title,
|
||||||
expenseDate: expense.expenseDate ?? new Date(),
|
expenseDate: expense.expenseDate ?? new Date(),
|
||||||
amount: amountAsDecimal(expense.amount, groupCurrency),
|
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,
|
category: expense.categoryId,
|
||||||
paidBy: expense.paidById,
|
paidBy: expense.paidById,
|
||||||
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
|
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
|
||||||
@@ -208,9 +220,12 @@ export function ExpenseForm({
|
|||||||
title: t('reimbursement'),
|
title: t('reimbursement'),
|
||||||
expenseDate: new Date(),
|
expenseDate: new Date(),
|
||||||
amount: amountAsDecimal(
|
amount: amountAsDecimal(
|
||||||
Number(searchParams.get('amount')) || 0,
|
Number(searchParams.get('amount')) || 0,
|
||||||
groupCurrency,
|
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
|
category: 1, // category with Id 1 is Payment
|
||||||
paidBy: searchParams.get('from') ?? undefined,
|
paidBy: searchParams.get('from') ?? undefined,
|
||||||
paidFor: [
|
paidFor: [
|
||||||
@@ -234,6 +249,9 @@ export function ExpenseForm({
|
|||||||
? new Date(searchParams.get('date') as string)
|
? new Date(searchParams.get('date') as string)
|
||||||
: new Date(),
|
: new Date(),
|
||||||
amount: Number(searchParams.get('amount')) || 0,
|
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')
|
category: searchParams.get('categoryId')
|
||||||
? Number(searchParams.get('categoryId'))
|
? Number(searchParams.get('categoryId'))
|
||||||
: 0, // category with Id 0 is General
|
: 0, // category with Id 0 is General
|
||||||
@@ -272,6 +290,12 @@ export function ExpenseForm({
|
|||||||
? amountAsMinorUnits(shares, groupCurrency)
|
? amountAsMinorUnits(shares, groupCurrency)
|
||||||
: shares,
|
: shares,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Currency should be blank if same as group currency
|
||||||
|
if (!conversionRequired) {
|
||||||
|
delete values.originalAmount
|
||||||
|
delete values.originalCurrency
|
||||||
|
}
|
||||||
return onSubmit(values, activeUserId ?? undefined)
|
return onSubmit(values, activeUserId ?? undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +306,23 @@ export function ExpenseForm({
|
|||||||
|
|
||||||
const sExpense = isIncome ? 'Income' : 'Expense'
|
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(() => {
|
useEffect(() => {
|
||||||
setManuallyEditedParticipants(new Set())
|
setManuallyEditedParticipants(new Set())
|
||||||
}, [form.watch('splitMode'), form.watch('amount')])
|
}, [form.watch('splitMode'), form.watch('amount')])
|
||||||
@@ -340,6 +381,71 @@ export function ExpenseForm({
|
|||||||
form.watch('splitMode'),
|
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 (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(submit)}>
|
<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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="amount"
|
name="amount"
|
||||||
render={({ field: { onChange, ...field } }) => (
|
render={({ field: { onChange, ...field } }) => (
|
||||||
<FormItem className="sm:order-3">
|
<FormItem className="sm:order-5">
|
||||||
<FormLabel>{t('amountField.label')}</FormLabel>
|
<FormLabel>{t('amountField.label')}</FormLabel>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span>{group.currency}</span>
|
<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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="paidBy"
|
name="paidBy"
|
||||||
@@ -623,7 +871,7 @@ export function ExpenseForm({
|
|||||||
data-id={`${id}/${form.getValues().splitMode}/${
|
data-id={`${id}/${form.getValues().splitMode}/${
|
||||||
group.currency
|
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">
|
<FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -707,106 +955,209 @@ export function ExpenseForm({
|
|||||||
)}
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
{form.getValues().splitMode !== 'EVENLY' && (
|
<div className="flex">
|
||||||
<FormField
|
{form.getValues().splitMode === 'BY_AMOUNT' &&
|
||||||
name={`paidFor[${field.value.findIndex(
|
!!conversionRequired && (
|
||||||
({ participant }) => participant === id,
|
<FormField
|
||||||
)}].shares`}
|
name={`paidFor[${field.value.findIndex(
|
||||||
render={() => {
|
({ participant }) => participant === id,
|
||||||
const sharesLabel = (
|
)}].originalAmount`}
|
||||||
<span
|
render={() => {
|
||||||
className={cn('text-sm', {
|
const sharesLabel = (
|
||||||
'text-muted': !field.value?.some(
|
<span
|
||||||
({ participant }) =>
|
className={cn('text-sm', {
|
||||||
participant === id,
|
'text-muted': !field.value?.some(
|
||||||
),
|
({ participant }) =>
|
||||||
})}
|
participant === id,
|
||||||
>
|
),
|
||||||
{match(form.getValues().splitMode)
|
})}
|
||||||
.with('BY_SHARES', () => (
|
>
|
||||||
<>{t('shares')}</>
|
{originalCurrency.symbol}
|
||||||
))
|
</span>
|
||||||
.with('BY_PERCENTAGE', () => <>%</>)
|
)
|
||||||
.with('BY_AMOUNT', () => (
|
return (
|
||||||
<>{group.currency}</>
|
<div>
|
||||||
))
|
<div className="flex gap-1 items-center">
|
||||||
.otherwise(() => (
|
{sharesLabel}
|
||||||
<></>
|
<FormControl>
|
||||||
))}
|
<Input
|
||||||
</span>
|
key={String(
|
||||||
)
|
!field.value?.some(
|
||||||
return (
|
({ participant }) =>
|
||||||
<div>
|
participant === id,
|
||||||
<div className="flex gap-1 items-center">
|
),
|
||||||
{form.getValues().splitMode ===
|
)}
|
||||||
'BY_AMOUNT' && sharesLabel}
|
className="text-base w-[80px] -my-2"
|
||||||
<FormControl>
|
type="text"
|
||||||
<Input
|
inputMode="decimal"
|
||||||
key={String(
|
disabled={
|
||||||
!field.value?.some(
|
!field.value?.some(
|
||||||
({ participant }) =>
|
({ participant }) =>
|
||||||
participant === id,
|
participant === id,
|
||||||
),
|
)
|
||||||
)}
|
}
|
||||||
className="text-base w-[80px] -my-2"
|
value={
|
||||||
type="text"
|
field.value.find(
|
||||||
disabled={
|
({ participant }) =>
|
||||||
!field.value?.some(
|
participant === id,
|
||||||
({ participant }) =>
|
)?.originalAmount ?? ''
|
||||||
participant === id,
|
}
|
||||||
)
|
onChange={(event) => {
|
||||||
}
|
const originalAmount = Number(
|
||||||
value={
|
event.target.value,
|
||||||
field.value?.find(
|
)
|
||||||
({ participant }) =>
|
let convertedAmount = ''
|
||||||
participant === id,
|
if (
|
||||||
)?.shares
|
!Number.isNaN(
|
||||||
}
|
originalAmount,
|
||||||
onChange={(event) => {
|
) &&
|
||||||
field.onChange(
|
exchangeRate.data
|
||||||
field.value.map((p) =>
|
) {
|
||||||
p.participant === id
|
convertedAmount = (
|
||||||
? {
|
originalAmount *
|
||||||
participant: id,
|
exchangeRate.data
|
||||||
shares:
|
).toFixed(
|
||||||
enforceCurrencyPattern(
|
groupCurrency.decimal_digits,
|
||||||
event.target.value,
|
)
|
||||||
),
|
}
|
||||||
}
|
field.onChange(
|
||||||
: p,
|
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(
|
className="text-base w-[80px] -my-2"
|
||||||
(prev) => new Set(prev).add(id),
|
type="text"
|
||||||
)
|
disabled={
|
||||||
}}
|
!field.value?.some(
|
||||||
inputMode={
|
({ participant }) =>
|
||||||
form.getValues().splitMode ===
|
participant === id,
|
||||||
'BY_AMOUNT'
|
)
|
||||||
? 'decimal'
|
}
|
||||||
: 'numeric'
|
value={
|
||||||
}
|
field.value?.find(
|
||||||
step={
|
({ participant }) =>
|
||||||
form.getValues().splitMode ===
|
participant === id,
|
||||||
'BY_AMOUNT'
|
)?.shares
|
||||||
? 0.01
|
}
|
||||||
: 1
|
onChange={(event) => {
|
||||||
}
|
field.onChange(
|
||||||
/>
|
field.value.map((p) =>
|
||||||
</FormControl>
|
p.participant === id
|
||||||
{[
|
? {
|
||||||
'BY_SHARES',
|
participant: id,
|
||||||
'BY_PERCENTAGE',
|
shares:
|
||||||
].includes(
|
enforceCurrencyPattern(
|
||||||
form.getValues().splitMode,
|
event.target
|
||||||
) && sharesLabel}
|
.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>
|
</div>
|
||||||
<FormMessage className="float-right" />
|
)
|
||||||
</div>
|
}}
|
||||||
)
|
/>
|
||||||
}}
|
)}
|
||||||
/>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getCurrency } from '@/lib/currency'
|
||||||
import { formatAmountAsDecimal, getCurrencyFromGroup } from '@/lib/utils'
|
import { formatAmountAsDecimal, getCurrencyFromGroup } from '@/lib/utils'
|
||||||
import { Parser } from '@json2csv/plainjs'
|
import { Parser } from '@json2csv/plainjs'
|
||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
@@ -38,6 +39,9 @@ export async function GET(
|
|||||||
title: true,
|
title: true,
|
||||||
category: { select: { name: true } },
|
category: { select: { name: true } },
|
||||||
amount: true,
|
amount: true,
|
||||||
|
originalAmount: true,
|
||||||
|
originalCurrency: true,
|
||||||
|
conversionRate: true,
|
||||||
paidById: true,
|
paidById: true,
|
||||||
paidFor: { select: { participantId: true, shares: true } },
|
paidFor: { select: { participantId: true, shares: true } },
|
||||||
isReimbursement: true,
|
isReimbursement: true,
|
||||||
@@ -54,30 +58,29 @@ export async function GET(
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
||||||
CSV Structure:
|
CSV Columns:
|
||||||
|
|
||||||
--------------------------------------------------------------
|
|
||||||
| Date | Description | Category | Currency | Cost
|
|
||||||
--------------------------------------------------------------
|
|
||||||
| Is Reimbursement | Split mode | UserA | UserB
|
|
||||||
--------------------------------------------------------------
|
|
||||||
|
|
||||||
Columns:
|
|
||||||
- Date: The date of the expense.
|
- Date: The date of the expense.
|
||||||
- Description: A brief description of the expense.
|
- Description: A brief description of the expense.
|
||||||
- Category: The category of the expense (e.g., Food, Travel, etc.).
|
- Category: The category of the expense (e.g., Food, Travel, etc.).
|
||||||
- Currency: The currency in which the expense is recorded.
|
- Currency: The currency in which the expense is recorded.
|
||||||
- Cost: The amount spent.
|
- 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.
|
- 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).
|
- 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).
|
- UserA, UserB: User-specific data or balances (e.g., amount owed or contributed by each user).
|
||||||
|
|
||||||
Example Row:
|
Example Table:
|
||||||
------------------------------------------------------------------------------------------
|
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
|
||||||
| 2025-01-06 | Dinner with team | Food | ₹ | 5000 | No | Evenly | John | Jane
|
| 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 = [
|
const fields = [
|
||||||
{ label: 'Date', value: 'date' },
|
{ label: 'Date', value: 'date' },
|
||||||
@@ -85,6 +88,9 @@ export async function GET(
|
|||||||
{ label: 'Category', value: 'categoryName' },
|
{ label: 'Category', value: 'categoryName' },
|
||||||
{ label: 'Currency', value: 'currency' },
|
{ label: 'Currency', value: 'currency' },
|
||||||
{ label: 'Cost', value: 'amount' },
|
{ 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: 'Is Reimbursement', value: 'isReimbursement' },
|
||||||
{ label: 'Split mode', value: 'splitMode' },
|
{ label: 'Split mode', value: 'splitMode' },
|
||||||
...group.participants.map((participant) => ({
|
...group.participants.map((participant) => ({
|
||||||
@@ -101,6 +107,16 @@ export async function GET(
|
|||||||
categoryName: expense.category?.name || '',
|
categoryName: expense.category?.name || '',
|
||||||
currency: group.currencyCode ?? group.currency,
|
currency: group.currencyCode ?? group.currency,
|
||||||
amount: formatAmountAsDecimal(expense.amount, 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',
|
isReimbursement: expense.isReimbursement ? 'Yes' : 'No',
|
||||||
splitMode: splitModeLabel[expense.splitMode],
|
splitMode: splitModeLabel[expense.splitMode],
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export async function GET(
|
|||||||
title: true,
|
title: true,
|
||||||
category: { select: { grouping: true, name: true } },
|
category: { select: { grouping: true, name: true } },
|
||||||
amount: true,
|
amount: true,
|
||||||
|
originalAmount: true,
|
||||||
|
originalCurrency: true,
|
||||||
|
conversionRate: true,
|
||||||
paidById: true,
|
paidById: true,
|
||||||
paidFor: { select: { participantId: true, shares: true } },
|
paidFor: { select: { participantId: true, shares: true } },
|
||||||
isReimbursement: true,
|
isReimbursement: true,
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export async function createExpense(
|
|||||||
expenseDate: expenseFormValues.expenseDate,
|
expenseDate: expenseFormValues.expenseDate,
|
||||||
categoryId: expenseFormValues.category,
|
categoryId: expenseFormValues.category,
|
||||||
amount: expenseFormValues.amount,
|
amount: expenseFormValues.amount,
|
||||||
|
originalAmount: expenseFormValues.originalAmount,
|
||||||
|
originalCurrency: expenseFormValues.originalCurrency,
|
||||||
|
conversionRate: expenseFormValues.conversionRate,
|
||||||
title: expenseFormValues.title,
|
title: expenseFormValues.title,
|
||||||
paidById: expenseFormValues.paidBy,
|
paidById: expenseFormValues.paidBy,
|
||||||
splitMode: expenseFormValues.splitMode,
|
splitMode: expenseFormValues.splitMode,
|
||||||
@@ -206,6 +209,9 @@ export async function updateExpense(
|
|||||||
data: {
|
data: {
|
||||||
expenseDate: expenseFormValues.expenseDate,
|
expenseDate: expenseFormValues.expenseDate,
|
||||||
amount: expenseFormValues.amount,
|
amount: expenseFormValues.amount,
|
||||||
|
originalAmount: expenseFormValues.originalAmount,
|
||||||
|
originalCurrency: expenseFormValues.originalCurrency,
|
||||||
|
conversionRate: expenseFormValues.conversionRate,
|
||||||
title: expenseFormValues.title,
|
title: expenseFormValues.title,
|
||||||
categoryId: expenseFormValues.category,
|
categoryId: expenseFormValues.category,
|
||||||
paidById: expenseFormValues.paidBy,
|
paidById: expenseFormValues.paidBy,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import dayjs from 'dayjs'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import useSWR, { Fetcher } from 'swr'
|
||||||
|
|
||||||
export function useMediaQuery(query: string): boolean {
|
export function useMediaQuery(query: string): boolean {
|
||||||
const getMatches = (query: string): boolean => {
|
const getMatches = (query: string): boolean => {
|
||||||
@@ -64,3 +66,62 @@ export function useActiveUser(groupId?: string) {
|
|||||||
|
|
||||||
return activeUser
|
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>
|
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
|
export const expenseFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
expenseDate: z.coerce.date(),
|
expenseDate: z.coerce.date(),
|
||||||
@@ -55,11 +68,27 @@ export const expenseFormSchema = z
|
|||||||
)
|
)
|
||||||
.refine((amount) => amount != 0, 'amountNotZero')
|
.refine((amount) => amount != 0, 'amountNotZero')
|
||||||
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
|
.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' }),
|
paidBy: z.string({ required_error: 'paidByRequired' }),
|
||||||
paidFor: z
|
paidFor: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
participant: z.string(),
|
participant: z.string(),
|
||||||
|
originalAmount: z.string().optional(), // For converting shares by amounts in original currency, not saved.
|
||||||
shares: z.union([
|
shares: z.union([
|
||||||
z.number(),
|
z.number(),
|
||||||
z.string().transform((value, ctx) => {
|
z.string().transform((value, ctx) => {
|
||||||
@@ -163,17 +192,18 @@ export const expenseFormSchema = z
|
|||||||
// Format the share split as a number (if from form submission)
|
// Format the share split as a number (if from form submission)
|
||||||
return {
|
return {
|
||||||
...expense,
|
...expense,
|
||||||
paidFor: expense.paidFor.map(({ participant, shares }) => {
|
paidFor: expense.paidFor.map((paidFor) => {
|
||||||
|
const shares = paidFor.shares
|
||||||
if (typeof shares === 'string' && expense.splitMode !== 'BY_AMOUNT') {
|
if (typeof shares === 'string' && expense.splitMode !== 'BY_AMOUNT') {
|
||||||
// For splitting not by amount, preserve the previous behaviour of multiplying the share by 100
|
// For splitting not by amount, preserve the previous behaviour of multiplying the share by 100
|
||||||
return {
|
return {
|
||||||
participant,
|
...paidFor,
|
||||||
shares: Math.round(Number(shares) * 100),
|
shares: Math.round(Number(shares) * 100),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Otherwise, no need as the number will have been formatted according to currency.
|
// Otherwise, no need as the number will have been formatted according to currency.
|
||||||
return {
|
return {
|
||||||
participant,
|
...paidFor,
|
||||||
shares: Number(shares),
|
shares: Number(shares),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
'use client' // <-- to make sure we can mount the Provider from a server component
|
'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 type { QueryClient } from '@tanstack/react-query'
|
||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { httpBatchLink } from '@trpc/client'
|
import { httpBatchLink } from '@trpc/client'
|
||||||
@@ -8,6 +9,15 @@ import superjson from 'superjson'
|
|||||||
import { makeQueryClient } from './query-client'
|
import { makeQueryClient } from './query-client'
|
||||||
import type { AppRouter } from './routers/_app'
|
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>()
|
export const trpc = createTRPCReact<AppRouter>()
|
||||||
|
|
||||||
let clientQueryClientSingleton: QueryClient
|
let clientQueryClientSingleton: QueryClient
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
|
import { Prisma } from '@prisma/client'
|
||||||
import { initTRPC } from '@trpc/server'
|
import { initTRPC } from '@trpc/server'
|
||||||
import { cache } from 'react'
|
import { cache } from 'react'
|
||||||
import superjson from 'superjson'
|
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 () => {
|
export const createTRPCContext = cache(async () => {
|
||||||
/**
|
/**
|
||||||
* @see: https://trpc.io/docs/server/context
|
* @see: https://trpc.io/docs/server/context
|
||||||
|
|||||||
Reference in New Issue
Block a user