mirror of
https://github.com/spliit-app/spliit.git
synced 2025-12-06 01:19:29 +01:00
Merge branch 'group-currency-code' of github.com:whimcomp/spliit into whimcomp-group-currency-code
This commit is contained in:
@@ -96,6 +96,11 @@
|
||||
"placeholder": "$, €, £…",
|
||||
"description": "We’ll use it to display amounts."
|
||||
},
|
||||
"CurrencyCodeField": {
|
||||
"label": "Main currency",
|
||||
"description": "All amounts and balances will be in this currency. Changing this will NOT convert expenses already entered.",
|
||||
"customOption": "Custom"
|
||||
},
|
||||
"Participants": {
|
||||
"title": "Participants",
|
||||
"description": "Enter the name for each participant.",
|
||||
@@ -399,5 +404,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
195
package-lock.json
generated
195
package-lock.json
generated
@@ -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",
|
||||
@@ -5319,134 +5274,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.28.tgz",
|
||||
"integrity": "sha512-kzGChl9setxYWpk3H6fTZXXPFFjg7urptLq5o5ZgYezCrqlemKttwMT5iFyx/p1e/JeglTwDFRtb923gTJ3R1w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.28.tgz",
|
||||
"integrity": "sha512-z6FXYHDJlFOzVEOiiJ/4NG8aLCeayZdcRSMjPDysW297Up6r22xw6Ea9AOwQqbNsth8JNgIK8EkWz2IDwaLQcw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.28.tgz",
|
||||
"integrity": "sha512-9ARHLEQXhAilNJ7rgQX8xs9aH3yJSj888ssSjJLeldiZKR4D7N08MfMqljk77fAwZsWwsrp8ohHsMvurvv9liQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.28.tgz",
|
||||
"integrity": "sha512-p6gvatI1nX41KCizEe6JkF0FS/cEEF0u23vKDpl+WhPe/fCTBeGkEBh7iW2cUM0rvquPVwPWdiUR6Ebr/kQWxQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.28.tgz",
|
||||
"integrity": "sha512-nsiSnz2wO6GwMAX2o0iucONlVL7dNgKUqt/mDTATGO2NY59EO/ZKnKEr80BJFhuA5UC1KZOMblJHWZoqIJddpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.28.tgz",
|
||||
"integrity": "sha512-+IuGQKoI3abrXFqx7GtlvNOpeExUH1mTIqCrh1LGFf8DnlUcTmOOCApEnPJUSLrSbzOdsF2ho2KhnQoO0I1RDw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.28.tgz",
|
||||
"integrity": "sha512-l61WZ3nevt4BAnGksUVFKy2uJP5DPz2E0Ma/Oklvo3sGj9sw3q7vBWONFRgz+ICiHpW5mV+mBrkB3XEubMrKaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.28.tgz",
|
||||
"integrity": "sha512-+Kcp1T3jHZnJ9v9VTJ/yf1t/xmtFAc/Sge4v7mVc1z+NYfYzisi8kJ9AsY8itbgq+WgEwMtOpiLLJsUy2qnXZw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "14.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.28.tgz",
|
||||
@@ -10927,6 +10754,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 +12077,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Group" ADD COLUMN "currencyCode" TEXT;
|
||||
@@ -16,6 +16,7 @@ model Group {
|
||||
name String
|
||||
information String? @db.Text
|
||||
currency String @default("$")
|
||||
currencyCode String?
|
||||
participants Participant[]
|
||||
expenses Expense[]
|
||||
activities Activity[]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useMediaQuery } from '@/lib/hooks'
|
||||
import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils'
|
||||
import { formatCurrency, formatDate, formatFileSize, getCurrencyFromGroup } from '@/lib/utils'
|
||||
import { trpc } from '@/trpc/client'
|
||||
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
@@ -202,7 +202,7 @@ function ReceiptDialogContent() {
|
||||
receiptInfo.amount ? (
|
||||
<>
|
||||
{formatCurrency(
|
||||
group.currency,
|
||||
getCurrencyFromGroup(group),
|
||||
receiptInfo.amount,
|
||||
locale,
|
||||
true,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function GET(
|
||||
id: true,
|
||||
name: true,
|
||||
currency: true,
|
||||
currencyCode: true,
|
||||
expenses: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
198
src/components/currency-selector.tsx
Normal file
198
src/components/currency-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,6 +66,7 @@ export function GroupForm({
|
||||
name: group.name,
|
||||
information: group.information ?? '',
|
||||
currency: group.currency,
|
||||
currencyCode: group.currencyCode,
|
||||
participants: group.participants,
|
||||
}
|
||||
: {
|
||||
@@ -143,6 +148,41 @@ export function GroupForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
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('CurrencyCodeField.description')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currency"
|
||||
@@ -153,6 +193,7 @@ export function GroupForm({
|
||||
<Input
|
||||
className="text-base"
|
||||
placeholder={t('CurrencyField.placeholder')}
|
||||
disabled={!!form.watch('currencyCode')?.length}
|
||||
max={5}
|
||||
{...field}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
4082
src/lib/currency-data.json
Normal file
4082
src/lib/currency-data.json
Normal file
File diff suppressed because it is too large
Load Diff
89
src/lib/currency.ts
Normal file
89
src/lib/currency.ts
Normal 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
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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,81 @@ 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) {
|
||||
|
||||
40
src/scripts/generateCurrencyData.ts
Normal file
40
src/scripts/generateCurrencyData.ts
Normal 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))
|
||||
@@ -28,7 +28,8 @@
|
||||
"require": ["tsconfig-paths/register", "dotenv/config"],
|
||||
"compilerOptions": {
|
||||
"isolatedModules": false,
|
||||
"module": "CommonJS"
|
||||
"moduleResolution": "nodenext",
|
||||
"module": "nodenext"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user