Add support for group income (= negative expenses) (#158)

* Allow negative amount for expenses to be entered

- an expense becomes an income
- this does not affect calculations, i.e. an income can be split just like an expense

* Incomes should not be reimbursements

when entering a negative number
- deselect 'isReimbursement'
- hide reimbursement checkbox

* Change captions when entering a negative number

- "expense" becomes "income"
- "paid" becomes "received"

* Format incomes on expense list

- replace "paid by" with "received by"

* Format incomes on "Stats" tab

- a group's or participants balance might be negative
- in this case "spendings" will be "earnings" (display accordingly)
- always display positive numbers
- for active user: highlight spendings/earnings in red/green

* Fix typo

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
This commit is contained in:
Stefan Hynst
2024-05-30 04:20:04 +02:00
committed by GitHub
parent 3887efd9ee
commit 0c05499107
6 changed files with 75 additions and 55 deletions

View File

@@ -38,7 +38,8 @@ export function ExpenseCard({ expense, currency, groupId }: Props) {
{expense.title} {expense.title}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Paid by <strong>{expense.paidBy.name}</strong> for{' '} {expense.amount > 0 ? 'Paid by ' : 'Received by '}
<strong>{expense.paidBy.name}</strong> for{' '}
{expense.paidFor.map((paidFor, index) => ( {expense.paidFor.map((paidFor, index) => (
<Fragment key={index}> <Fragment key={index}>
{index !== 0 && <>, </>} {index !== 0 && <>, </>}

View File

@@ -6,11 +6,12 @@ type Props = {
} }
export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) { export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {
const balance = totalGroupSpendings < 0 ? 'earnings' : 'spendings'
return ( return (
<div> <div>
<div className="text-muted-foreground">Total group spendings</div> <div className="text-muted-foreground">Total group {balance}</div>
<div className="text-lg"> <div className="text-lg">
{formatCurrency(currency, totalGroupSpendings)} {formatCurrency(currency, Math.abs(totalGroupSpendings))}
</div> </div>
</div> </div>
) )

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { getGroup, getGroupExpenses } from '@/lib/api' import { getGroup, getGroupExpenses } from '@/lib/api'
import { getTotalActiveUserShare } from '@/lib/totals' import { getTotalActiveUserShare } from '@/lib/totals'
import { formatCurrency } from '@/lib/utils' import { cn, formatCurrency } from '@/lib/utils'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
type Props = { type Props = {
@@ -26,8 +26,13 @@ export function TotalsYourShare({ group, expenses }: Props) {
return ( return (
<div> <div>
<div className="text-muted-foreground">Your total share</div> <div className="text-muted-foreground">Your total share</div>
<div className="text-lg"> <div
{formatCurrency(currency, totalActiveUserShare)} className={cn(
'text-lg',
totalActiveUserShare < 0 ? 'text-green-600' : 'text-red-600',
)}
>
{formatCurrency(currency, Math.abs(totalActiveUserShare))}
</div> </div>
</div> </div>
) )

View File

@@ -2,7 +2,7 @@
import { getGroup, getGroupExpenses } from '@/lib/api' import { getGroup, getGroupExpenses } from '@/lib/api'
import { useActiveUser } from '@/lib/hooks' import { useActiveUser } from '@/lib/hooks'
import { getTotalActiveUserPaidFor } from '@/lib/totals' import { getTotalActiveUserPaidFor } from '@/lib/totals'
import { formatCurrency } from '@/lib/utils' import { cn, formatCurrency } from '@/lib/utils'
type Props = { type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>> group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
@@ -17,13 +17,19 @@ export function TotalsYourSpendings({ group, expenses }: Props) {
? 0 ? 0
: getTotalActiveUserPaidFor(activeUser, expenses) : getTotalActiveUserPaidFor(activeUser, expenses)
const currency = group.currency const currency = group.currency
const balance = totalYourSpendings < 0 ? 'earnings' : 'spendings'
return ( return (
<div> <div>
<div className="text-muted-foreground">Total you paid for</div> <div className="text-muted-foreground">Your total {balance}</div>
<div className="text-lg"> <div
{formatCurrency(currency, totalYourSpendings)} className={cn(
'text-lg',
totalYourSpendings < 0 ? 'text-green-600' : 'text-red-600',
)}
>
{formatCurrency(currency, Math.abs(totalYourSpendings))}
</div> </div>
</div> </div>
) )

View File

@@ -64,14 +64,15 @@ export type Props = {
const enforceCurrencyPattern = (value: string) => const enforceCurrencyPattern = (value: string) =>
value value
// replace first comma with # .replace(/^\s*-/, '_') // replace leading minus with _
.replace(/[.,]/, '#') .replace(/[.,]/, '#') // replace first comma with #
// remove all other commas .replace(/[-.,]/g, '') // remove other minus and commas characters
.replace(/[.,]/g, '') .replace(/_/, '-') // change back _ to minus
// change back # to dot .replace(/#/, '.') // change back # to dot
.replace(/#/, '.') .replace(/[^-\d.]/g, '') // remove all non-numeric characters
// remove all non-numeric and non-dot characters
.replace(/[^\d.]/g, '') const capitalize = (value: string) =>
value.charAt(0).toUpperCase() + value.slice(1)
const getDefaultSplittingOptions = (group: Props['group']) => { const getDefaultSplittingOptions = (group: Props['group']) => {
const defaultValue = { const defaultValue = {
@@ -243,14 +244,16 @@ export function ExpenseForm({
return onSubmit(values, activeUserId ?? undefined) return onSubmit(values, activeUserId ?? undefined)
} }
const [isIncome, setIsIncome] = useState(Number(form.getValues().amount) < 0)
const sExpense = isIncome ? 'income' : 'expense'
const sPaid = isIncome ? 'received' : 'paid'
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(submit)}> <form onSubmit={form.handleSubmit(submit)}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>{(isCreate ? 'Create ' : 'Edit ') + sExpense}</CardTitle>
{isCreate ? <>Create expense</> : <>Edit expense</>}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid sm:grid-cols-2 gap-6"> <CardContent className="grid sm:grid-cols-2 gap-6">
<FormField <FormField
@@ -258,7 +261,7 @@ export function ExpenseForm({
name="title" name="title"
render={({ field }) => ( render={({ field }) => (
<FormItem className=""> <FormItem className="">
<FormLabel>Expense title</FormLabel> <FormLabel>{capitalize(sExpense)} title</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="Monday evening restaurant" placeholder="Monday evening restaurant"
@@ -278,7 +281,7 @@ export function ExpenseForm({
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Enter a description for the expense. Enter a description for the {sExpense}.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -290,7 +293,7 @@ export function ExpenseForm({
name="expenseDate" name="expenseDate"
render={({ field }) => ( render={({ field }) => (
<FormItem className="sm:order-1"> <FormItem className="sm:order-1">
<FormLabel>Expense date</FormLabel> <FormLabel>{capitalize(sExpense)} date</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="date-base" className="date-base"
@@ -302,7 +305,7 @@ export function ExpenseForm({
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Enter the date the expense was made. Enter the date the {sExpense} was {sPaid}.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -319,15 +322,17 @@ export function ExpenseForm({
<span>{group.currency}</span> <span>{group.currency}</span>
<FormControl> <FormControl>
<Input <Input
{...field}
className="text-base max-w-[120px]" className="text-base max-w-[120px]"
type="text" type="text"
inputMode="decimal" inputMode="decimal"
step={0.01}
placeholder="0.00" placeholder="0.00"
onChange={(event) => onChange={(event) => {
onChange(enforceCurrencyPattern(event.target.value)) const v = enforceCurrencyPattern(event.target.value)
} const income = Number(v) < 0
setIsIncome(income)
if (income) form.setValue('isReimbursement', false)
onChange(v)
}}
onFocus={(e) => { onFocus={(e) => {
// we're adding a small delay to get around safaris issue with onMouseUp deselecting things again // we're adding a small delay to get around safaris issue with onMouseUp deselecting things again
const target = e.currentTarget const target = e.currentTarget
@@ -339,23 +344,25 @@ export function ExpenseForm({
</div> </div>
<FormMessage /> <FormMessage />
<FormField {!isIncome && (
control={form.control} <FormField
name="isReimbursement" control={form.control}
render={({ field }) => ( name="isReimbursement"
<FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2"> render={({ field }) => (
<FormControl> <FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
<Checkbox <FormControl>
checked={field.value} <Checkbox
onCheckedChange={field.onChange} checked={field.value}
/> onCheckedChange={field.onChange}
</FormControl> />
<div> </FormControl>
<FormLabel>This is a reimbursement</FormLabel> <div>
</div> <FormLabel>This is a reimbursement</FormLabel>
</FormItem> </div>
)} </FormItem>
/> )}
/>
)}
</FormItem> </FormItem>
)} )}
/> />
@@ -375,7 +382,7 @@ export function ExpenseForm({
isLoading={isCategoryLoading} isLoading={isCategoryLoading}
/> />
<FormDescription> <FormDescription>
Select the expense category. Select the {sExpense} category.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -387,7 +394,7 @@ export function ExpenseForm({
name="paidBy" name="paidBy"
render={({ field }) => ( render={({ field }) => (
<FormItem className="sm:order-5"> <FormItem className="sm:order-5">
<FormLabel>Paid by</FormLabel> <FormLabel>{capitalize(sPaid)} by</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={getSelectedPayer(field)} defaultValue={getSelectedPayer(field)}
@@ -404,7 +411,7 @@ export function ExpenseForm({
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <FormDescription>
Select the participant who paid the expense. Select the participant who {sPaid} the {sExpense}.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -428,7 +435,7 @@ export function ExpenseForm({
<Card className="mt-4"> <Card className="mt-4">
<CardHeader> <CardHeader>
<CardTitle className="flex justify-between"> <CardTitle className="flex justify-between">
<span>Paid for</span> <span>{capitalize(sPaid)} for</span>
<Button <Button
variant="link" variant="link"
type="button" type="button"
@@ -461,7 +468,7 @@ export function ExpenseForm({
</Button> </Button>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Select who the expense was paid for. Select who the {sExpense} was {sPaid} for.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -661,7 +668,7 @@ export function ExpenseForm({
</Select> </Select>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Select how to split the expense. Select how to split the {sExpense}.
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
@@ -698,7 +705,7 @@ export function ExpenseForm({
<span>Attach documents</span> <span>Attach documents</span>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
See and attach receipts to the expense. See and attach receipts to the {sExpense}.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@@ -62,7 +62,7 @@ export const expenseFormSchema = z
], ],
{ required_error: 'You must enter an amount.' }, { required_error: 'You must enter an amount.' },
) )
.refine((amount) => amount >= 1, 'The amount must be higher than 0.01.') .refine((amount) => amount != 1, 'The amount must not be zero.')
.refine( .refine(
(amount) => amount <= 10_000_000_00, (amount) => amount <= 10_000_000_00,
'The amount must be lower than 10,000,000.', 'The amount must be lower than 10,000,000.',