mirror of
https://github.com/spliit-app/spliit.git
synced 2026-03-03 11:36:12 +01:00
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:
@@ -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 && <>, </>}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
Reference in New Issue
Block a user