5 Commits

Author SHA1 Message Date
Peter Smit
763c8c42e5 Remove ghrc pull example from README since it's not available yet
All checks were successful
CI / checks (push) Successful in 55s
2025-09-05 16:55:57 +02:00
Peter Smit
5fee0440c2 Add main currency code for groups feedback (#329)
Clarify group currency field description

Use default currency code instead of symbol

Hide currency symbol field when using a non-custom Currency

Run prettier

Update currency data

Update package-lock.json
2025-09-05 13:11:56 +02:00
Peter Smit
da8473406e Merge branch 'group-currency-code' of github.com:whimcomp/spliit into whimcomp-group-currency-code 2025-09-05 10:46:04 +02:00
Steven Sengchanh
2814811aea Script to recreate currencies data 2025-04-21 00:49:43 +02:00
Steven Sengchanh
af4bfe3780 Add non-custom currencies per group 2025-04-21 00:49:43 +02:00
33 changed files with 5534 additions and 138 deletions

View File

@@ -1,4 +1,4 @@
POSTGRES_PRISMA_URL=postgresql://postgres:1234@localhost
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL=""
NEXT_PUBLIC_DEFAULT_CURRENCY_CODE=""

View File

@@ -68,9 +68,6 @@ Here is the current state of translation:
3. Run `npm run start-container` to start the postgres and the spliit2 containers
4. You can access the app by browsing to http://localhost:3000
You could also pull it from the container registry:
```docker pull ghcr.io/spliit-app/spliit:latest```
## Health check
The application has a health check endpoint that can be used to check if the application is running and if the database is accessible.

View File

@@ -96,6 +96,12 @@
"placeholder": "$, €, £…",
"description": "Well use it to display amounts."
},
"CurrencyCodeField": {
"label": "Main currency",
"createDescription": "All amounts and balances will be in this currency.",
"editDescription": "All amounts and balances will be in this currency. Changing this will NOT convert expenses already entered, except when the currency has different \"minor units\" than the current one (e.g. changing from US Dollar to Japanese Yen)",
"customOption": "Custom"
},
"Participants": {
"title": "Participants",
"description": "Enter the name for each participant.",
@@ -399,5 +405,18 @@
"TV/Phone/Internet": "TV/Phone/Internet",
"Water": "Water"
}
},
"Currencies": {
"search": "Search currency...",
"noCurrency": "No currencies found.",
"custom": {
"heading": "Custom"
},
"common": {
"heading": "Most common"
},
"other": {
"heading": "Other currencies"
}
}
}

75
package-lock.json generated
View File

@@ -78,6 +78,7 @@
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.6",
"autoprefixer": "^10",
"currency-list": "^1.0.8",
"dotenv": "^16.3.1",
"eslint": "^8",
"eslint-config-next": "^14.1.0",
@@ -4689,52 +4690,6 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.2.tgz",
"integrity": "sha512-/rK/69Rrp9x5kaWBjVN07KixZanRr+W1OiyKdXcbjQD6KbW+obaTeBBtLUAtbBsnlTTmWthw99xqoOS7SsySDg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"glibc": ">=2.26",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.1"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.1.tgz",
"integrity": "sha512-eVU/JYLPVjhhrd8Tk6gosl5pVlvsqiFlt50wotCvdkFGf+mDNBJxMh+bvav+Wt3EBnNZWq8Sp2I7XfSjm8siog==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"macos": ">=10.13",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -5326,7 +5281,6 @@
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -5342,7 +5296,6 @@
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -5358,7 +5311,6 @@
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -5374,7 +5326,6 @@
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -5390,7 +5341,6 @@
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -5406,7 +5356,6 @@
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -5422,7 +5371,6 @@
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -5438,7 +5386,6 @@
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -10927,6 +10874,13 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/currency-list": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/currency-list/-/currency-list-1.0.8.tgz",
"integrity": "sha512-KBUtf8AzoP2WYeAKUYFhhNdHRx8Xw2UoOUnBNVir43RxL96T+iSHMtThtT1DFNtYbbVVyD08ETe3aamn+JX7RA==",
"dev": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -12243,19 +12197,6 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",

View File

@@ -13,7 +13,8 @@
"postinstall": "prisma migrate deploy && prisma generate",
"build-image": "./scripts/build-image.sh",
"start-container": "docker compose --env-file container.env up",
"test": "jest"
"test": "jest",
"generate-currency-data": "ts-node -T ./src/scripts/generateCurrencyData.ts"
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
@@ -85,6 +86,7 @@
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.6",
"autoprefixer": "^10",
"currency-list": "^1.0.8",
"dotenv": "^16.3.1",
"eslint": "^8",
"eslint-config-next": "^14.1.0",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Group" ADD COLUMN "currencyCode" TEXT;

View File

@@ -16,6 +16,7 @@ model Group {
name String
information String? @db.Text
currency String @default("$")
currencyCode String?
participants Participant[]
expenses Expense[]
activities Activity[]

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,12 @@ 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 +207,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}
/>
</>
)}

View File

@@ -0,0 +1,198 @@
import { ChevronDown, Loader2 } from 'lucide-react'
import { Button, ButtonProps } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@/components/ui/command'
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Currency } from '@/lib/currency'
import { useMediaQuery } from '@/lib/hooks'
import { useTranslations } from 'next-intl'
import { forwardRef, useEffect, useState } from 'react'
type Props = {
currencies: Currency[]
onValueChange: (currencyCode: Currency['code']) => void
/** Currency code to be selected by default. Overwriting this value will update current selection, too. */
defaultValue: Currency['code']
isLoading: boolean
}
export function CurrencySelector({
currencies,
onValueChange,
defaultValue,
isLoading,
}: Props) {
const [open, setOpen] = useState(false)
const [value, setValue] = useState<string>(defaultValue)
const isDesktop = useMediaQuery('(min-width: 768px)')
// allow overwriting currently selected currency from outside
useEffect(() => {
setValue(defaultValue)
onValueChange(defaultValue)
}, [defaultValue])
const selectedCurrency =
currencies.find((currency) => (currency.code ?? '') === value) ??
currencies[0]
if (isDesktop) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<CurrencyButton
currency={selectedCurrency}
open={open}
isLoading={isLoading}
/>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<CurrencyCommand
currencies={currencies}
onValueChange={(code) => {
setValue(code)
onValueChange(code)
setOpen(false)
}}
/>
</PopoverContent>
</Popover>
)
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<CurrencyButton
currency={selectedCurrency}
open={open}
isLoading={isLoading}
/>
</DrawerTrigger>
<DrawerContent className="p-0">
<CurrencyCommand
currencies={currencies}
onValueChange={(id) => {
setValue(id)
onValueChange(id)
setOpen(false)
}}
/>
</DrawerContent>
</Drawer>
)
}
function CurrencyCommand({
currencies,
onValueChange,
}: {
currencies: Currency[]
onValueChange: (currencyId: Currency['code']) => void
}) {
const currencyGroup = (currency: Currency) => {
switch (currency.code) {
case 'USD':
case 'EUR':
case 'JPY':
case 'GBP':
case 'CNY':
return 'common'
default:
if (currency.code === '') return 'custom'
return 'other'
}
}
const t = useTranslations('Currencies')
const currenciesByGroup = currencies.reduce<Record<string, Currency[]>>(
(acc, currency) => ({
...acc,
[currencyGroup(currency)]: (acc[currencyGroup(currency)] ?? []).concat([
currency,
]),
}),
{},
)
return (
<Command>
<CommandInput placeholder={t('search')} className="text-base" />
<CommandEmpty>{t('noCurrency')}</CommandEmpty>
<div className="w-full max-h-[300px] overflow-y-auto">
{Object.entries(currenciesByGroup).map(
([group, groupCurrencies], index) => (
<CommandGroup key={index} heading={t(`${group}.heading`)}>
{groupCurrencies.map((currency) => (
<CommandItem
key={currency.code}
value={`${currency.code} ${currency.name} ${currency.symbol}`}
onSelect={(currentValue) => {
onValueChange(currency.code)
}}
>
<CurrencyLabel currency={currency} />
</CommandItem>
))}
</CommandGroup>
),
)}
</div>
</Command>
)
}
type CurrencyButtonProps = {
currency: Currency
open: boolean
isLoading: boolean
}
const CurrencyButton = forwardRef<HTMLButtonElement, CurrencyButtonProps>(
(
{ currency, open, isLoading, ...props }: ButtonProps & CurrencyButtonProps,
ref,
) => {
const iconClassName = 'ml-2 h-4 w-4 shrink-0 opacity-50'
return (
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="flex w-full justify-between"
ref={ref}
{...props}
>
<CurrencyLabel currency={currency} />
{isLoading ? (
<Loader2 className={`animate-spin ${iconClassName}`} />
) : (
<ChevronDown className={iconClassName} />
)}
</Button>
)
},
)
CurrencyButton.displayName = 'CurrencyButton'
function CurrencyLabel({ currency }: { currency: Currency }) {
const flagUrl = `https://flagcdn.com/h24/${
currency?.code.length ? currency.code.slice(0, 2).toLowerCase() : 'un'
}.png`
return (
<div className="flex items-center gap-3">
<img src={flagUrl} className="w-4" alt="" />
{currency.name}
{currency.code ? ` (${currency.code})` : ''}
</div>
)
}

View File

@@ -30,14 +30,17 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Locale } from '@/i18n'
import { getGroup } from '@/lib/api'
import { defaultCurrencyList, getCurrency } from '@/lib/currency'
import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
import { zodResolver } from '@hookform/resolvers/zod'
import { Save, Trash2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import { CurrencySelector } from './currency-selector'
import { Textarea } from './ui/textarea'
export type Props = {
@@ -54,6 +57,7 @@ export function GroupForm({
onSubmit,
protectedParticipantIds = [],
}: Props) {
const locale = useLocale()
const t = useTranslations('GroupForm')
const form = useForm<GroupFormValues>({
resolver: zodResolver(groupFormSchema),
@@ -62,12 +66,13 @@ export function GroupForm({
name: group.name,
information: group.information ?? '',
currency: group.currency,
currencyCode: group.currencyCode,
participants: group.participants,
}
: {
name: '',
information: '',
currency: process.env.NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL || '',
currencyCode: process.env.NEXT_PUBLIC_DEFAULT_CURRENCY_CODE || 'USD', // TODO: If NEXT_PUBLIC_DEFAULT_CURRENCY_CODE, is not set, determine the default currency code based on locale
participants: [
{ name: t('Participants.John') },
{ name: t('Participants.Jane') },
@@ -145,9 +150,48 @@ export function GroupForm({
<FormField
control={form.control}
name="currency"
name="currencyCode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('CurrencyCodeField.label')}</FormLabel>
<CurrencySelector
currencies={defaultCurrencyList(
locale as Locale,
t('CurrencyCodeField.customOption'),
)}
defaultValue={form.watch(field.name) ?? ''}
onValueChange={(newCurrency) => {
field.onChange(newCurrency)
const currency = getCurrency(newCurrency)
if (
currency.code.length ||
form.getFieldState('currency').isTouched
)
form.setValue('currency', currency.symbol, {
shouldValidate: true,
shouldTouch: true,
shouldDirty: true,
})
}}
isLoading={false}
/>
<FormDescription>
{t(
group
? 'CurrencyCodeField.editDescription'
: 'CurrencyCodeField.createDescription',
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency"
render={({ field }) => (
<FormItem hidden={!!form.watch('currencyCode')?.length}>
<FormLabel>{t('CurrencyField.label')}</FormLabel>
<FormControl>
<Input

View File

@@ -1,9 +1,10 @@
'use client'
import { Currency } from '@/lib/currency'
import { cn, formatCurrency } from '@/lib/utils'
import { useLocale } from 'next-intl'
type Props = {
currency: string
currency: Currency
amount: number
bold?: boolean
colored?: boolean

View File

@@ -19,6 +19,7 @@ export async function createGroup(groupFormValues: GroupFormValues) {
name: groupFormValues.name,
information: groupFormValues.information,
currency: groupFormValues.currency,
currencyCode: groupFormValues.currencyCode,
participants: {
createMany: {
data: groupFormValues.participants.map(({ name }) => ({
@@ -293,6 +294,7 @@ export async function updateGroup(
name: groupFormValues.name,
information: groupFormValues.information,
currency: groupFormValues.currency,
currencyCode: groupFormValues.currencyCode,
participants: {
deleteMany: existingGroup.participants.filter(
(p) => !groupFormValues.participants.some((p2) => p2.id === p.id),

4898
src/lib/currency-data.json Normal file

File diff suppressed because it is too large Load Diff

89
src/lib/currency.ts Normal file
View File

@@ -0,0 +1,89 @@
import { Locale } from '@/i18n'
import currencyList from './currency-data.json'
export type Currency = {
name: string
symbol_native: string
symbol: string
code: string
name_plural: string
rounding: number
decimal_digits: number
}
export const supportedCurrencyCodes = [
'USD',
'EUR',
'JPY',
'BGN',
'CZK',
'DKK',
'GBP',
'HUF',
'PLN',
'RON',
'SEK',
'CHF',
'ISK',
'NOK',
'TRY',
'AUD',
'BRL',
'CAD',
'CNY',
'HKD',
'IDR',
'ILS',
'INR',
'KRW',
'MXN',
'NZD',
'PHP',
'SGD',
'THB',
'ZAR',
] as const
export type supportedCurrencyCodeType = (typeof supportedCurrencyCodes)[number]
export function defaultCurrencyList(
locale: Locale = 'en-US',
customChoice: string | null = null,
) {
const currencies = customChoice
? [
{
name: customChoice,
symbol_native: '',
symbol: '',
code: '',
name_plural: customChoice,
rounding: 0,
decimal_digits: 2,
},
]
: []
const allCurrencies = currencyList[locale]
return currencies.concat(Object.values(allCurrencies))
}
export function getCurrency(
currencyCode: string | undefined | null,
locale: Locale = 'en-US',
customChoice = 'Custom',
): Currency {
const defaultCurrency = {
name: customChoice,
symbol_native: '',
symbol: '',
code: '',
name_plural: customChoice,
rounding: 0,
decimal_digits: 2,
}
if (!currencyCode || currencyCode === '') return defaultCurrency
const currencyListInLocale = currencyList[locale] ?? currencyList['en-US']
return (
currencyListInLocale[currencyCode as supportedCurrencyCodeType] ??
defaultCurrency
)
}

View File

@@ -21,7 +21,7 @@ const envSchema = z
interpretEnvVarAsBool,
z.boolean().default(false),
),
NEXT_PUBLIC_DEFAULT_CURRENCY_SYMBOL: z.string().optional(),
NEXT_PUBLIC_DEFAULT_CURRENCY_CODE: z.string().optional(),
S3_UPLOAD_KEY: z.string().optional(),
S3_UPLOAD_SECRET: z.string().optional(),
S3_UPLOAD_BUCKET: z.string().optional(),

View File

@@ -6,6 +6,7 @@ export const groupFormSchema = z
name: z.string().min(2, 'min2').max(50, 'max50'),
information: z.string().optional(),
currency: z.string().min(1, 'min1').max(5, 'max5'),
currencyCode: z.union([z.string().length(3).nullish(), z.literal('')]), // ISO-4217 currency code
participants: z
.array(
z.object({
@@ -47,7 +48,7 @@ export const expenseFormSchema = z
code: z.ZodIssueCode.custom,
message: 'invalidNumber',
})
return Math.round(valueAsNumber * 100)
return valueAsNumber
}),
],
{ required_error: 'amountRequired' },
@@ -69,17 +70,16 @@ export const expenseFormSchema = z
code: z.ZodIssueCode.custom,
message: 'invalidNumber',
})
return Math.round(valueAsNumber * 100)
return value
}),
]),
}),
)
.min(1, 'paidForMin1')
.superRefine((paidFor, ctx) => {
let sum = 0
for (const { shares } of paidFor) {
sum += shares
if (shares < 1) {
const shareNumber = Number(shares)
if (shareNumber <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'noZeroShares',
@@ -112,17 +112,16 @@ export const expenseFormSchema = z
.default('NONE'),
})
.superRefine((expense, ctx) => {
let sum = 0
for (const { shares } of expense.paidFor) {
sum +=
typeof shares === 'number' ? shares : Math.round(Number(shares) * 100)
}
switch (expense.splitMode) {
case 'EVENLY':
break // noop
case 'BY_SHARES':
break // noop
case 'BY_AMOUNT': {
const sum = expense.paidFor.reduce(
(sum, { shares }) => sum + Number(shares),
0,
)
if (sum !== expense.amount) {
const detail =
sum < expense.amount
@@ -137,6 +136,14 @@ export const expenseFormSchema = z
break
}
case 'BY_PERCENTAGE': {
const sum = expense.paidFor.reduce(
(sum, { shares }) =>
sum +
(typeof shares === 'string'
? Math.round(Number(shares) * 100)
: Number(shares)),
0,
)
if (sum !== 10000) {
const detail =
sum < 10000
@@ -152,6 +159,26 @@ export const expenseFormSchema = z
}
}
})
.transform((expense) => {
// Format the share split as a number (if from form submission)
return {
...expense,
paidFor: expense.paidFor.map(({ participant, 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,
shares: Math.round(Number(shares) * 100),
}
}
// Otherwise, no need as the number will have been formatted according to currency.
return {
participant,
shares: Number(shares),
}
}),
}
})
export type ExpenseFormValues = z.infer<typeof expenseFormSchema>

View File

@@ -1,7 +1,16 @@
import { Currency } from './currency'
import { formatCurrency } from './utils'
describe('formatCurrency', () => {
const currency = 'CUR'
const currency: Currency = {
name: 'Test',
symbol_native: '',
symbol: 'CUR',
code: '',
name_plural: '',
rounding: 0,
decimal_digits: 2,
}
/** For testing decimals */
const partialAmount = 1.23
/** For testing small full amounts */
@@ -27,32 +36,32 @@ describe('formatCurrency', () => {
{
amount: partialAmount,
locale: `en-US`,
result: `${currency}1.23`,
result: `${currency.symbol}1.23`,
},
{
amount: smallAmount,
locale: `en-US`,
result: `${currency}1.00`,
result: `${currency.symbol}1.00`,
},
{
amount: largeAmount,
locale: `en-US`,
result: `${currency}10,000.00`,
result: `${currency.symbol}10,000.00`,
},
{
amount: partialAmount,
locale: `de-DE`,
result: `1,23${nbsp}${currency}`,
result: `1,23${nbsp}${currency.symbol}`,
},
{
amount: smallAmount,
locale: `de-DE`,
result: `1,00${nbsp}${currency}`,
result: `1,00${nbsp}${currency.symbol}`,
},
{
amount: largeAmount,
locale: `de-DE`,
result: `10.000,00${nbsp}${currency}`,
result: `10.000,00${nbsp}${currency.symbol}`,
},
]

View File

@@ -1,6 +1,7 @@
import { Category } from '@prisma/client'
import { Category, Group } from '@prisma/client'
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { Currency, getCurrency } from './currency'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -33,20 +34,85 @@ export function formatCategoryForAIPrompt(category: Category) {
* Set this to `true` if you need to pass a value with decimal fractions instead (e.g. 1.00 for USD 1.00).
*/
export function formatCurrency(
currency: string,
currency: Currency,
amount: number,
locale: string,
fractions?: boolean,
) {
const format = new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
minimumFractionDigits: currency.decimal_digits,
maximumFractionDigits: currency.decimal_digits,
style: 'currency',
// '€' will be placed in correct position
currency: 'EUR',
currency: currency.code.length ? currency.code : 'EUR',
})
const formattedAmount = format.format(fractions ? amount : amount / 100)
return formattedAmount.replace('€', currency)
const formatted = format.format(
fractions ? amount : amountAsDecimal(amount, currency),
)
if (currency.code.length) {
return formatted
}
return formatted.replace('€', currency.symbol)
}
export function getCurrencyFromGroup(
group: Pick<Group, 'currency' | 'currencyCode'>,
): Currency {
if (!group.currencyCode) {
return {
name: 'Custom',
symbol_native: group.currency,
symbol: group.currency,
code: '',
name_plural: '',
rounding: 0,
decimal_digits: 2,
}
}
return getCurrency(group.currencyCode)
}
/**
* Converts monetary amounts in minor units to the corresponding amount in major units in the given currency.
* e.g.
* - 150 "minor units" of euros = 1.5
* - 1000 "minor units" of yen = 1000 (the yen does not have minor units in practice)
*
* @param amount The amount, as the number of minor units of currency (cents for most currencies)
* @param round Whether to round the amount to the nearest minor unit (e.g.: 1.5612 € => 1.56 €)
*/
export function amountAsDecimal(
amount: number,
currency: Currency,
round = false,
) {
const decimal = amount / 10 ** currency.decimal_digits
if (round) {
return Number(decimal.toFixed(currency.decimal_digits))
}
return decimal
}
/**
* Converts decimal monetary amounts in major units to the amount in minor units in the given currency.
* e.g.
* - €1.5 = 150 "minor units" of euros (cents)
* - JPY 1000 = 1000 "minor units" of yen (the yen does not have minor units in practice)
*
* @param amount The amount in decimal major units
*/
export function amountAsMinorUnits(amount: number, currency: Currency) {
return amount * 10 ** currency.decimal_digits
}
/**
* Formats monetary amounts in minor units to the corresponding amount in major units in the given currency,
* as a string, with correct rounding.
*
* @param amount The amount, as the number of minor units of currency (cents for most currencies)
*/
export function formatAmountAsDecimal(amount: number, currency: Currency) {
return amountAsDecimal(amount, currency).toFixed(currency.decimal_digits)
}
export function formatFileSize(size: number, locale: string) {

View File

@@ -0,0 +1,40 @@
// @ts-nocheck
import { Locale, locales } from '@/i18n'
import {
Currency,
supportedCurrencyCodeType,
supportedCurrencyCodes,
} from '@/lib/currency'
import CurrencyList from 'currency-list'
import fs from 'node:fs'
const currencyList = locales.reduce((curList, locale) => {
const currencyData = supportedCurrencyCodes.reduce(
(curData, currencyCode) => {
try {
return {
...curData,
[currencyCode]: CurrencyList.get(
currencyCode,
locale.replaceAll('-', '_'),
),
}
} catch {
// For currency translations which are not found in the library (e.g. ua), use English.
return {
...curData,
[currencyCode]: CurrencyList.get(currencyCode, 'en_US'),
}
}
},
{},
)
return { ...curList, [locale]: currencyData }
}, {}) as {
[K in Locale]: {
[K in supportedCurrencyCodeType]: Currency
}
}
fs.writeFileSync('src/lib/currency-data.json', JSON.stringify(currencyList))

View File

@@ -28,7 +28,8 @@
"require": ["tsconfig-paths/register", "dotenv/config"],
"compilerOptions": {
"isolatedModules": false,
"module": "CommonJS"
"moduleResolution": "nodenext",
"module": "nodenext"
}
}
}