Skip to content

Commit

Permalink
Add support for group income (= negative expenses) (#158)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
shynst and scastiel committed May 30, 2024
1 parent 3887efd commit 0c05499
Show file tree
Hide file tree
Showing 6 changed files with 75 additions and 55 deletions.
3 changes: 2 additions & 1 deletion src/app/groups/[groupId]/expenses/expense-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export function ExpenseCard({ expense, currency, groupId }: Props) {
{expense.title}
</div>
<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) => (
<Fragment key={index}>
{index !== 0 && <>, </>}
Expand Down
5 changes: 3 additions & 2 deletions src/app/groups/[groupId]/stats/totals-group-spending.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ type Props = {
}

export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {
const balance = totalGroupSpendings < 0 ? 'earnings' : 'spendings'
return (
<div>
<div className="text-muted-foreground">Total group spendings</div>
<div className="text-muted-foreground">Total group {balance}</div>
<div className="text-lg">
{formatCurrency(currency, totalGroupSpendings)}
{formatCurrency(currency, Math.abs(totalGroupSpendings))}
</div>
</div>
)
Expand Down
11 changes: 8 additions & 3 deletions src/app/groups/[groupId]/stats/totals-your-share.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'
import { getGroup, getGroupExpenses } from '@/lib/api'
import { getTotalActiveUserShare } from '@/lib/totals'
import { formatCurrency } from '@/lib/utils'
import { cn, formatCurrency } from '@/lib/utils'
import { useEffect, useState } from 'react'

type Props = {
Expand All @@ -26,8 +26,13 @@ export function TotalsYourShare({ group, expenses }: Props) {
return (
<div>
<div className="text-muted-foreground">Your total share</div>
<div className="text-lg">
{formatCurrency(currency, totalActiveUserShare)}
<div
className={cn(
'text-lg',
totalActiveUserShare < 0 ? 'text-green-600' : 'text-red-600',
)}
>
{formatCurrency(currency, Math.abs(totalActiveUserShare))}
</div>
</div>
)
Expand Down
14 changes: 10 additions & 4 deletions src/app/groups/[groupId]/stats/totals-your-spending.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { getGroup, getGroupExpenses } from '@/lib/api'
import { useActiveUser } from '@/lib/hooks'
import { getTotalActiveUserPaidFor } from '@/lib/totals'
import { formatCurrency } from '@/lib/utils'
import { cn, formatCurrency } from '@/lib/utils'

type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
Expand All @@ -17,13 +17,19 @@ export function TotalsYourSpendings({ group, expenses }: Props) {
? 0
: getTotalActiveUserPaidFor(activeUser, expenses)
const currency = group.currency
const balance = totalYourSpendings < 0 ? 'earnings' : 'spendings'

return (
<div>
<div className="text-muted-foreground">Total you paid for</div>
<div className="text-muted-foreground">Your total {balance}</div>

<div className="text-lg">
{formatCurrency(currency, totalYourSpendings)}
<div
className={cn(
'text-lg',
totalYourSpendings < 0 ? 'text-green-600' : 'text-red-600',
)}
>
{formatCurrency(currency, Math.abs(totalYourSpendings))}
</div>
</div>
)
Expand Down
95 changes: 51 additions & 44 deletions src/components/expense-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,15 @@ export type Props = {

const enforceCurrencyPattern = (value: string) =>
value
// replace first comma with #
.replace(/[.,]/, '#')
// remove all other commas
.replace(/[.,]/g, '')
// change back # to dot
.replace(/#/, '.')
// remove all non-numeric and non-dot characters
.replace(/[^\d.]/g, '')
.replace(/^\s*-/, '_') // replace leading minus with _
.replace(/[.,]/, '#') // replace first comma with #
.replace(/[-.,]/g, '') // remove other minus and commas characters
.replace(/_/, '-') // change back _ to minus
.replace(/#/, '.') // change back # to dot
.replace(/[^-\d.]/g, '') // remove all non-numeric characters

const capitalize = (value: string) =>
value.charAt(0).toUpperCase() + value.slice(1)

const getDefaultSplittingOptions = (group: Props['group']) => {
const defaultValue = {
Expand Down Expand Up @@ -243,22 +244,24 @@ export function ExpenseForm({
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(submit)}>
<Card>
<CardHeader>
<CardTitle>
{isCreate ? <>Create expense</> : <>Edit expense</>}
</CardTitle>
<CardTitle>{(isCreate ? 'Create ' : 'Edit ') + sExpense}</CardTitle>
</CardHeader>
<CardContent className="grid sm:grid-cols-2 gap-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="">
<FormLabel>Expense title</FormLabel>
<FormLabel>{capitalize(sExpense)} title</FormLabel>
<FormControl>
<Input
placeholder="Monday evening restaurant"
Expand All @@ -278,7 +281,7 @@ export function ExpenseForm({
/>
</FormControl>
<FormDescription>
Enter a description for the expense.
Enter a description for the {sExpense}.
</FormDescription>
<FormMessage />
</FormItem>
Expand All @@ -290,7 +293,7 @@ export function ExpenseForm({
name="expenseDate"
render={({ field }) => (
<FormItem className="sm:order-1">
<FormLabel>Expense date</FormLabel>
<FormLabel>{capitalize(sExpense)} date</FormLabel>
<FormControl>
<Input
className="date-base"
Expand All @@ -302,7 +305,7 @@ export function ExpenseForm({
/>
</FormControl>
<FormDescription>
Enter the date the expense was made.
Enter the date the {sExpense} was {sPaid}.
</FormDescription>
<FormMessage />
</FormItem>
Expand All @@ -319,15 +322,17 @@ export function ExpenseForm({
<span>{group.currency}</span>
<FormControl>
<Input
{...field}
className="text-base max-w-[120px]"
type="text"
inputMode="decimal"
step={0.01}
placeholder="0.00"
onChange={(event) =>
onChange(enforceCurrencyPattern(event.target.value))
}
onChange={(event) => {
const v = enforceCurrencyPattern(event.target.value)
const income = Number(v) < 0
setIsIncome(income)
if (income) form.setValue('isReimbursement', false)
onChange(v)
}}
onFocus={(e) => {
// we're adding a small delay to get around safaris issue with onMouseUp deselecting things again
const target = e.currentTarget
Expand All @@ -339,23 +344,25 @@ export function ExpenseForm({
</div>
<FormMessage />

<FormField
control={form.control}
name="isReimbursement"
render={({ field }) => (
<FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div>
<FormLabel>This is a reimbursement</FormLabel>
</div>
</FormItem>
)}
/>
{!isIncome && (
<FormField
control={form.control}
name="isReimbursement"
render={({ field }) => (
<FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div>
<FormLabel>This is a reimbursement</FormLabel>
</div>
</FormItem>
)}
/>
)}
</FormItem>
)}
/>
Expand All @@ -375,7 +382,7 @@ export function ExpenseForm({
isLoading={isCategoryLoading}
/>
<FormDescription>
Select the expense category.
Select the {sExpense} category.
</FormDescription>
<FormMessage />
</FormItem>
Expand All @@ -387,7 +394,7 @@ export function ExpenseForm({
name="paidBy"
render={({ field }) => (
<FormItem className="sm:order-5">
<FormLabel>Paid by</FormLabel>
<FormLabel>{capitalize(sPaid)} by</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={getSelectedPayer(field)}
Expand All @@ -404,7 +411,7 @@ export function ExpenseForm({
</SelectContent>
</Select>
<FormDescription>
Select the participant who paid the expense.
Select the participant who {sPaid} the {sExpense}.
</FormDescription>
<FormMessage />
</FormItem>
Expand All @@ -428,7 +435,7 @@ export function ExpenseForm({
<Card className="mt-4">
<CardHeader>
<CardTitle className="flex justify-between">
<span>Paid for</span>
<span>{capitalize(sPaid)} for</span>
<Button
variant="link"
type="button"
Expand Down Expand Up @@ -461,7 +468,7 @@ export function ExpenseForm({
</Button>
</CardTitle>
<CardDescription>
Select who the expense was paid for.
Select who the {sExpense} was {sPaid} for.
</CardDescription>
</CardHeader>
<CardContent>
Expand Down Expand Up @@ -661,7 +668,7 @@ export function ExpenseForm({
</Select>
</FormControl>
<FormDescription>
Select how to split the expense.
Select how to split the {sExpense}.
</FormDescription>
</FormItem>
)}
Expand Down Expand Up @@ -698,7 +705,7 @@ export function ExpenseForm({
<span>Attach documents</span>
</CardTitle>
<CardDescription>
See and attach receipts to the expense.
See and attach receipts to the {sExpense}.
</CardDescription>
</CardHeader>
<CardContent>
Expand Down
2 changes: 1 addition & 1 deletion src/lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const expenseFormSchema = z
],
{ 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(
(amount) => amount <= 10_000_000_00,
'The amount must be lower than 10,000,000.',
Expand Down

0 comments on commit 0c05499

Please sign in to comment.