mirror of
https://github.com/spliit-app/spliit.git
synced 2025-12-06 01:19:29 +01:00
Compare commits
5 Commits
39d55d908a
...
763c8c42e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
763c8c42e5 | ||
|
|
5fee0440c2 | ||
|
|
da8473406e | ||
|
|
2814811aea | ||
|
|
af4bfe3780 |
@@ -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=""
|
||||
@@ -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.
|
||||
|
||||
@@ -96,6 +96,12 @@
|
||||
"placeholder": "$, €, £…",
|
||||
"description": "We’ll 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
75
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",
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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),
|
||||
|
||||
4898
src/lib/currency-data.json
Normal file
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
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
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
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