15 Commits
1.0.0 ... 1.6.0

Author SHA1 Message Date
Deep Golani
2fd38aadd9 Add notes in expense (#126)
* Feature: Added notes in expense

* Add missing notes in form values

* Prettier

---------

Co-authored-by: deep.golani <deep.golani@bfhl.in>
Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-04-05 08:38:38 -04:00
magomzr
b61d1836ea Add titles for a better user experience (#137)
Co-authored-by: Mario Gómez <60667991+mgomezarr@users.noreply.github.com>
2024-04-05 08:29:08 -04:00
Sahil Mehra
c3903849ec Bug: Fixed wrong paid by Name in Reimbursement (#134) 2024-04-02 08:20:56 -04:00
Jan T
b67a0be0dd Add "save as default splitting options" feature (#120)
* Add "save as default splitting options" feature

* Fix type issue

* Run autoformatter
2024-03-09 11:45:53 -05:00
Guhan
e07d237218 Ask for confirmation to delete an expense (#124)
* feat: added a popup asking for confirmation to delete an expense

* fix: changed cancel option as a button and formatting issues

* fix: removed unnecessary tags and replaced generic tags with proper components

* Small fix to avoid warning in console

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-03-09 11:38:30 -05:00
annalouisep
cc37083389 Expense list: add section for planned purchases (#122)
* add planned purchases

* Updating verbiage to reflect possible future entry types
2024-03-09 11:30:24 -05:00
Sebastien Castiel
552953151a Don’t count reimbursements in stats (fixes #118) (#119) 2024-02-29 10:21:23 -05:00
Jan T
b227401dd6 Minor: reorder Dockerfile layers for better cache use (#116) 2024-02-28 10:59:19 -05:00
sashkent3
6a5efc5f3f Fix the default value for the expense shares field (#113)
* fix default shares value

* fix default shares value for reimbursements

* prettier
2024-02-28 10:58:49 -05:00
Jan T
4c5f8a6aa5 Fix decimal separator issue in numeric form fields (#115)
* Revert 5b65b8f, fix comma issue with type="text" and onChange

* Fix comma issue in "paid for" input

* Run prettier autoformat

* Allow only digits and dots in currency inputs

* Fix behaviour in paidFor field

* Fix duplicated onChange prop

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-02-28 10:57:55 -05:00
Lauri Vuorela
c2b591349b add a prettier script for ease of use (#105) 2024-02-28 10:45:02 -05:00
Lauri Vuorela
56c1865264 Add onClick-event to select all to amount input (#104)
* add onfocus-event to select all to amount input

* use onClick instead of onFocus
2024-02-28 10:44:27 -05:00
Anurag
2f991e680b feat: initialise a new totals tab with basic UI (#94)
* feat: initialise a new totals tab with basic UI

* fix: update group tabs and add stats page

* fix: styling within the new elements

* Prettier

* Display active user expenses only if active user is set

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-02-28 10:43:25 -05:00
Jan T
2af0660383 Optimize docker image size (#91)
* Move prisma to runtime dependencies

* Optimize Dockerfile and build script

* Fix: remove mention of generated next-env.d.ts in Dockerfile

* Add missing reset.d.ts file to Dockerfile

* Remove compression steps from Dockerfile and entrypoint script

* Add an env file with mocked env vars added for Docker production builds

* Use server actions to get runtime env vars

* Refactor types and names

* Rollback serverActions, use parsed Zod object for runtime env

* Reintroduce featureFlags object to avoid passing secret envs to the frontend

* Improve string to boolean coercion

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>

* Run prettier autoformat

* Fix type issue, rename function to match behaviour better

---------

Co-authored-by: Lauri Vuorela <lauri.vuorela@gmail.com>
Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-02-14 10:18:30 -05:00
Sebastien Castiel
50525ad881 Add cancel button on expense form (fixes #92) 2024-02-14 09:49:06 -05:00
29 changed files with 646 additions and 116 deletions

1
.gitignore vendored
View File

@@ -28,6 +28,7 @@ yarn-error.log*
# local env files # local env files
.env*.local .env*.local
*.env *.env
!scripts/build.env
# vercel # vercel
.vercel .vercel

View File

@@ -1,22 +1,48 @@
FROM node:21-slim as base FROM node:21-alpine as base
WORKDIR /usr/app
COPY ./package.json \
./package-lock.json \
./next.config.js \
./tsconfig.json \
./reset.d.ts \
./tailwind.config.js \
./postcss.config.js ./
COPY ./scripts ./scripts
COPY ./prisma ./prisma
RUN apk add --no-cache openssl && \
npm ci --ignore-scripts && \
npx prisma generate
COPY ./src ./src
ENV NEXT_TELEMETRY_DISABLED=1
COPY scripts/build.env .env
RUN npm run build
RUN rm -r .next/cache
FROM node:21-alpine as runtime-deps
WORKDIR /usr/app
COPY --from=base /usr/app/package.json /usr/app/package-lock.json ./
COPY --from=base /usr/app/prisma ./prisma
RUN npm ci --omit=dev --omit=optional --ignore-scripts && \
npx prisma generate
FROM node:21-alpine as runner
EXPOSE 3000/tcp EXPOSE 3000/tcp
WORKDIR /usr/app WORKDIR /usr/app
COPY ./ ./
RUN apt update && \ COPY --from=base /usr/app/package.json /usr/app/package-lock.json ./
apt install openssl -y && \ COPY --from=runtime-deps /usr/app/node_modules ./node_modules
apt clean && \ COPY ./public ./public
apt autoclean && \ COPY ./scripts ./scripts
apt autoremove && \ COPY --from=base /usr/app/prisma ./prisma
npm ci --ignore-scripts && \ COPY --from=base /usr/app/.next ./.next
npm install -g prisma && \
prisma generate
# env vars needed for build not to fail ENTRYPOINT ["/bin/sh", "/usr/app/scripts/container-entrypoint.sh"]
ARG POSTGRES_PRISMA_URL
ARG POSTGRES_URL_NON_POOLING
RUN npm run build
ENTRYPOINT ["/usr/app/scripts/container-entrypoint.sh"]

83
package-lock.json generated
View File

@@ -39,6 +39,7 @@
"next13-progressbar": "^1.1.1", "next13-progressbar": "^1.1.1",
"openai": "^4.25.0", "openai": "^4.25.0",
"pg": "^8.11.3", "pg": "^8.11.3",
"prisma": "^5.7.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.47.0", "react-hook-form": "^7.47.0",
@@ -65,7 +66,6 @@
"postcss": "^8", "postcss": "^8",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"prettier-plugin-organize-imports": "^3.2.3", "prettier-plugin-organize-imports": "^3.2.3",
"prisma": "^5.7.0",
"tailwindcss": "^3", "tailwindcss": "^3",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"
@@ -1429,13 +1429,10 @@
} }
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "5.6.0", "version": "5.9.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.6.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.9.1.tgz",
"integrity": "sha512-mUDefQFa1wWqk4+JhKPYq8BdVoFk9NFMBXUI8jAkBfQTtgx8WPx02U2HB/XbAz3GSUJpeJOKJQtNvaAIDs6sug==", "integrity": "sha512-caSOnG4kxcSkhqC/2ShV7rEoWwd3XrftokxJqOCMVvia4NYV/TPtJlS9C2os3Igxw/Qyxumj9GBQzcStzECvtQ==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": {
"@prisma/engines-version": "5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee"
},
"engines": { "engines": {
"node": ">=16.13" "node": ">=16.13"
}, },
@@ -1449,59 +1446,48 @@
} }
}, },
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "5.7.0", "version": "5.9.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.7.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.9.1.tgz",
"integrity": "sha512-tZ+MOjWlVvz1kOEhNYMa4QUGURY+kgOUBqLHYIV8jmCsMuvA1tWcn7qtIMLzYWCbDcQT4ZS8xDgK0R2gl6/0wA==", "integrity": "sha512-yAHFSFCg8KVoL0oRUno3m60GAjsUKYUDkQ+9BA2X2JfVR3kRVSJFc/GpQ2fSORi4pSHZR9orfM4UC9OVXIFFTA=="
"devOptional": true
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "5.7.0", "version": "5.9.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.7.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.9.1.tgz",
"integrity": "sha512-TkOMgMm60n5YgEKPn9erIvFX2/QuWnl3GBo6yTRyZKk5O5KQertXiNnrYgSLy0SpsKmhovEPQb+D4l0SzyE7XA==", "integrity": "sha512-gkdXmjxQ5jktxWNdDA5aZZ6R8rH74JkoKq6LD5mACSvxd2vbqWeWIOV0Py5wFC8vofOYShbt6XUeCIUmrOzOnQ==",
"devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/debug": "5.7.0", "@prisma/debug": "5.9.1",
"@prisma/engines-version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9", "@prisma/engines-version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64",
"@prisma/fetch-engine": "5.7.0", "@prisma/fetch-engine": "5.9.1",
"@prisma/get-platform": "5.7.0" "@prisma/get-platform": "5.9.1"
} }
}, },
"node_modules/@prisma/engines-version": {
"version": "5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee.tgz",
"integrity": "sha512-UoFgbV1awGL/3wXuUK3GDaX2SolqczeeJ5b4FVec9tzeGbSWJboPSbT0psSrmgYAKiKnkOPFSLlH6+b+IyOwAw=="
},
"node_modules/@prisma/engines/node_modules/@prisma/engines-version": { "node_modules/@prisma/engines/node_modules/@prisma/engines-version": {
"version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9", "version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64.tgz",
"integrity": "sha512-V6tgRVi62jRwTm0Hglky3Scwjr/AKFBFtS+MdbsBr7UOuiu1TKLPc6xfPiyEN1+bYqjEtjxwGsHgahcJsd1rNg==", "integrity": "sha512-HFl7275yF0FWbdcNvcSRbbu9JCBSLMcurYwvWc8WGDnpu7APxQo2ONtZrUggU3WxLxUJ2uBX+0GOFIcJeVeOOQ=="
"devOptional": true
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "5.7.0", "version": "5.9.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.7.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.9.1.tgz",
"integrity": "sha512-zIn/qmO+N/3FYe7/L9o+yZseIU8ivh4NdPKSkQRIHfg2QVTVMnbhGoTcecbxfVubeTp+DjcbjS0H9fCuM4W04w==", "integrity": "sha512-l0goQOMcNVOJs1kAcwqpKq3ylvkD9F04Ioe1oJoCqmz05mw22bNAKKGWuDd3zTUoUZr97va0c/UfLNru+PDmNA==",
"devOptional": true,
"dependencies": { "dependencies": {
"@prisma/debug": "5.7.0", "@prisma/debug": "5.9.1",
"@prisma/engines-version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9", "@prisma/engines-version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64",
"@prisma/get-platform": "5.7.0" "@prisma/get-platform": "5.9.1"
} }
}, },
"node_modules/@prisma/fetch-engine/node_modules/@prisma/engines-version": { "node_modules/@prisma/fetch-engine/node_modules/@prisma/engines-version": {
"version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9", "version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64.tgz",
"integrity": "sha512-V6tgRVi62jRwTm0Hglky3Scwjr/AKFBFtS+MdbsBr7UOuiu1TKLPc6xfPiyEN1+bYqjEtjxwGsHgahcJsd1rNg==", "integrity": "sha512-HFl7275yF0FWbdcNvcSRbbu9JCBSLMcurYwvWc8WGDnpu7APxQo2ONtZrUggU3WxLxUJ2uBX+0GOFIcJeVeOOQ=="
"devOptional": true
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
"version": "5.7.0", "version": "5.9.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.7.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.9.1.tgz",
"integrity": "sha512-ZeV/Op4bZsWXuw5Tg05WwRI8BlKiRFhsixPcAM+5BKYSiUZiMKIi713tfT3drBq8+T0E1arNZgYSA9QYcglWNA==", "integrity": "sha512-6OQsNxTyhvG+T2Ksr8FPFpuPeL4r9u0JF0OZHUBI/Uy9SS43sPyAIutt4ZEAyqWQt104ERh70EZedkHZKsnNbg==",
"devOptional": true,
"dependencies": { "dependencies": {
"@prisma/debug": "5.7.0" "@prisma/debug": "5.9.1"
} }
}, },
"node_modules/@radix-ui/number": { "node_modules/@radix-ui/number": {
@@ -7430,13 +7416,12 @@
} }
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "5.7.0", "version": "5.9.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.7.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.9.1.tgz",
"integrity": "sha512-0rcfXO2ErmGAtxnuTNHQT9ztL0zZheQjOI/VNJzdq87C3TlGPQtMqtM+KCwU6XtmkoEr7vbCQqA7HF9IY0ST+Q==", "integrity": "sha512-Hy/8KJZz0ELtkw4FnG9MS9rNWlXcJhf98Z2QMqi0QiVMoS8PzsBkpla0/Y5hTlob8F3HeECYphBjqmBxrluUrQ==",
"devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/engines": "5.7.0" "@prisma/engines": "5.9.1"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"

View File

@@ -9,6 +9,7 @@
"lint": "next lint", "lint": "next lint",
"check-types": "tsc --noEmit", "check-types": "tsc --noEmit",
"check-formatting": "prettier -c src", "check-formatting": "prettier -c src",
"prettier": "prettier -w src",
"postinstall": "prisma migrate deploy && prisma generate", "postinstall": "prisma migrate deploy && prisma generate",
"build-image": "./scripts/build-image.sh", "build-image": "./scripts/build-image.sh",
"start-container": "docker compose --env-file container.env up" "start-container": "docker compose --env-file container.env up"
@@ -53,7 +54,8 @@
"ts-pattern": "^5.0.6", "ts-pattern": "^5.0.6",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"vaul": "^0.8.0", "vaul": "^0.8.0",
"zod": "^3.22.4" "zod": "^3.22.4",
"prisma": "^5.7.0"
}, },
"devDependencies": { "devDependencies": {
"@total-typescript/ts-reset": "^0.5.1", "@total-typescript/ts-reset": "^0.5.1",
@@ -70,7 +72,6 @@
"postcss": "^8", "postcss": "^8",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"prettier-plugin-organize-imports": "^3.2.3", "prettier-plugin-organize-imports": "^3.2.3",
"prisma": "^5.7.0",
"tailwindcss": "^3", "tailwindcss": "^3",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "notes" TEXT;

View File

@@ -52,6 +52,7 @@ model Expense {
splitMode SplitMode @default(EVENLY) splitMode SplitMode @default(EVENLY)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
documents ExpenseDocument[] documents ExpenseDocument[]
notes String?
} }
model ExpenseDocument { model ExpenseDocument {

View File

@@ -5,9 +5,6 @@ SPLIIT_VERSION=$(node -p -e "require('./package.json').version")
# we need to set dummy data for POSTGRES env vars in order for build not to fail # we need to set dummy data for POSTGRES env vars in order for build not to fail
docker buildx build \ docker buildx build \
--no-cache \
--build-arg POSTGRES_PRISMA_URL=postgresql://build:@db \
--build-arg POSTGRES_URL_NON_POOLING=postgresql://build:@db \
-t ${SPLIIT_APP_NAME}:${SPLIIT_VERSION} \ -t ${SPLIIT_APP_NAME}:${SPLIIT_VERSION} \
-t ${SPLIIT_APP_NAME}:latest \ -t ${SPLIIT_APP_NAME}:latest \
. .

22
scripts/build.env Normal file
View File

@@ -0,0 +1,22 @@
# build file that contains all possible env vars with mocked values
# as most of them are used at build time in order to have the production build to work properly
# db
POSTGRES_PASSWORD=1234
# app
POSTGRES_PRISMA_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db
POSTGRES_URL_NON_POOLING=postgresql://postgres:${POSTGRES_PASSWORD}@db
# app-minio
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS=false
S3_UPLOAD_KEY=AAAAAAAAAAAAAAAAAAAA
S3_UPLOAD_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
S3_UPLOAD_BUCKET=spliit
S3_UPLOAD_REGION=eu-north-1
S3_UPLOAD_ENDPOINT=s3://minio.example.com
# app-openai
NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT=false
OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT=false

View File

@@ -1,3 +1,6 @@
#!/bin/bash #!/bin/bash
prisma migrate deploy
set -euxo pipefail
npx prisma migrate deploy
npm run start npm run start

View File

@@ -6,6 +6,7 @@ import {
getExpense, getExpense,
updateExpense, updateExpense,
} from '@/lib/api' } from '@/lib/api'
import { getRuntimeFeatureFlags } from '@/lib/featureFlags'
import { expenseFormSchema } from '@/lib/schemas' import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next' import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
@@ -47,6 +48,7 @@ export default async function EditExpensePage({
categories={categories} categories={categories}
onSubmit={updateExpenseAction} onSubmit={updateExpenseAction}
onDelete={deleteExpenseAction} onDelete={deleteExpenseAction}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/> />
</Suspense> </Suspense>
) )

View File

@@ -1,6 +1,7 @@
import { cached } from '@/app/cached-functions' import { cached } from '@/app/cached-functions'
import { ExpenseForm } from '@/components/expense-form' import { ExpenseForm } from '@/components/expense-form'
import { createExpense, getCategories } from '@/lib/api' import { createExpense, getCategories } from '@/lib/api'
import { getRuntimeFeatureFlags } from '@/lib/featureFlags'
import { expenseFormSchema } from '@/lib/schemas' import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next' import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
@@ -32,6 +33,7 @@ export default async function ExpensePage({
group={group} group={group}
categories={categories} categories={categories}
onSubmit={createExpenseAction} onSubmit={createExpenseAction}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/> />
</Suspense> </Suspense>
) )

View File

@@ -19,6 +19,7 @@ type Props = {
} }
const EXPENSE_GROUPS = { const EXPENSE_GROUPS = {
UPCOMING: 'Upcoming',
THIS_WEEK: 'This week', THIS_WEEK: 'This week',
EARLIER_THIS_MONTH: 'Earlier this month', EARLIER_THIS_MONTH: 'Earlier this month',
LAST_MONTH: 'Last month', LAST_MONTH: 'Last month',
@@ -28,7 +29,9 @@ const EXPENSE_GROUPS = {
} }
function getExpenseGroup(date: Dayjs, today: Dayjs) { function getExpenseGroup(date: Dayjs, today: Dayjs) {
if (today.isSame(date, 'week')) { if (today.isBefore(date)) {
return EXPENSE_GROUPS.UPCOMING
} else if (today.isSame(date, 'week')) {
return EXPENSE_GROUPS.THIS_WEEK return EXPENSE_GROUPS.THIS_WEEK
} else if (today.isSame(date, 'month')) { } else if (today.isSame(date, 'month')) {
return EXPENSE_GROUPS.EARLIER_THIS_MONTH return EXPENSE_GROUPS.EARLIER_THIS_MONTH

View File

@@ -51,6 +51,7 @@ export default async function GroupExpensesPage({
prefetch={false} prefetch={false}
href={`/groups/${groupId}/expenses/export/json`} href={`/groups/${groupId}/expenses/export/json`}
target="_blank" target="_blank"
title="Export to JSON"
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Link> </Link>
@@ -63,7 +64,10 @@ export default async function GroupExpensesPage({
/> />
)} )}
<Button asChild size="icon"> <Button asChild size="icon">
<Link href={`/groups/${groupId}/expenses/create`}> <Link
href={`/groups/${groupId}/expenses/create`}
title="Create expense"
>
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</Link> </Link>
</Button> </Button>

View File

@@ -23,6 +23,7 @@ export function GroupTabs({ groupId }: Props) {
<TabsList> <TabsList>
<TabsTrigger value="expenses">Expenses</TabsTrigger> <TabsTrigger value="expenses">Expenses</TabsTrigger>
<TabsTrigger value="balances">Balances</TabsTrigger> <TabsTrigger value="balances">Balances</TabsTrigger>
<TabsTrigger value="stats">Stats</TabsTrigger>
<TabsTrigger value="edit">Settings</TabsTrigger> <TabsTrigger value="edit">Settings</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>

View File

@@ -23,7 +23,7 @@ export function ShareButton({ group }: Props) {
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button size="icon"> <Button title="Share" size="icon">
<Share className="w-4 h-4" /> <Share className="w-4 h-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>

View File

@@ -0,0 +1,49 @@
import { cached } from '@/app/cached-functions'
import { Totals } from '@/app/groups/[groupId]/stats/totals'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { getGroupExpenses } from '@/lib/api'
import { getTotalGroupSpending } from '@/lib/totals'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
title: 'Totals',
}
export default async function TotalsPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
const group = await cached.getGroup(groupId)
if (!group) notFound()
const expenses = await getGroupExpenses(groupId)
const totalGroupSpendings = getTotalGroupSpending(expenses)
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>Totals</CardTitle>
<CardDescription>
Spending summary of the entire group.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<Totals
group={group}
expenses={expenses}
totalGroupSpendings={totalGroupSpendings}
/>
</CardContent>
</Card>
</>
)
}

View File

@@ -0,0 +1,17 @@
import { formatCurrency } from '@/lib/utils'
type Props = {
totalGroupSpendings: number
currency: string
}
export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {
return (
<div>
<div className="text-muted-foreground">Total group spendings</div>
<div className="text-lg">
{formatCurrency(currency, totalGroupSpendings)}
</div>
</div>
)
}

View File

@@ -0,0 +1,34 @@
'use client'
import { getGroup, getGroupExpenses } from '@/lib/api'
import { getTotalActiveUserShare } from '@/lib/totals'
import { formatCurrency } from '@/lib/utils'
import { useEffect, useState } from 'react'
type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
}
export function TotalsYourShare({ group, expenses }: Props) {
const [activeUser, setActiveUser] = useState('')
useEffect(() => {
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
if (activeUser) setActiveUser(activeUser)
}, [group, expenses])
const totalActiveUserShare =
activeUser === '' || activeUser === 'None'
? 0
: getTotalActiveUserShare(activeUser, expenses)
const currency = group.currency
return (
<div>
<div className="text-muted-foreground">Your total share</div>
<div className="text-lg">
{formatCurrency(currency, totalActiveUserShare)}
</div>
</div>
)
}

View File

@@ -0,0 +1,30 @@
'use client'
import { getGroup, getGroupExpenses } from '@/lib/api'
import { useActiveUser } from '@/lib/hooks'
import { getTotalActiveUserPaidFor } from '@/lib/totals'
import { formatCurrency } from '@/lib/utils'
type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
}
export function TotalsYourSpendings({ group, expenses }: Props) {
const activeUser = useActiveUser(group.id)
const totalYourSpendings =
activeUser === '' || activeUser === 'None'
? 0
: getTotalActiveUserPaidFor(activeUser, expenses)
const currency = group.currency
return (
<div>
<div className="text-muted-foreground">Total you paid for</div>
<div className="text-lg">
{formatCurrency(currency, totalYourSpendings)}
</div>
</div>
)
}

View File

@@ -0,0 +1,34 @@
'use client'
import { TotalsGroupSpending } from '@/app/groups/[groupId]/stats/totals-group-spending'
import { TotalsYourShare } from '@/app/groups/[groupId]/stats/totals-your-share'
import { TotalsYourSpendings } from '@/app/groups/[groupId]/stats/totals-your-spending'
import { getGroup, getGroupExpenses } from '@/lib/api'
import { useActiveUser } from '@/lib/hooks'
export function Totals({
group,
expenses,
totalGroupSpendings,
}: {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
totalGroupSpendings: number
}) {
const activeUser = useActiveUser(group.id)
console.log('activeUser', activeUser)
return (
<>
<TotalsGroupSpending
totalGroupSpendings={totalGroupSpendings}
currency={group.currency}
/>
{activeUser && activeUser !== 'None' && (
<>
<TotalsYourSpendings group={group} expenses={expenses} />
<TotalsYourShare group={group} expenses={expenses} />
</>
)}
</>
)
}

View File

@@ -0,0 +1,47 @@
'use client'
import { Trash2 } from 'lucide-react'
import { AsyncButton } from './async-button'
import { Button } from './ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
DialogTrigger,
} from './ui/dialog'
export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Delete this expense?</DialogTitle>
<DialogDescription>
Do you really want to delete this expense? This action is
irreversible.
</DialogDescription>
<DialogFooter className="flex flex-col gap-2">
<AsyncButton
type="button"
variant="destructive"
loadingContent="Deleting…"
action={onDelete}
>
Yes
</AsyncButton>
<DialogClose asChild>
<Button variant={'secondary'}>Cancel</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,5 +1,4 @@
'use client' 'use client'
import { AsyncButton } from '@/components/async-button'
import { CategorySelector } from '@/components/category-selector' import { CategorySelector } from '@/components/category-selector'
import { ExpenseDocumentsInput } from '@/components/expense-documents-input' import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
import { SubmitButton } from '@/components/submit-button' import { SubmitButton } from '@/components/submit-button'
@@ -35,15 +34,23 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { getCategories, getExpense, getGroup, randomId } from '@/lib/api' import { getCategories, getExpense, getGroup, randomId } from '@/lib/api'
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas' import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import {
ExpenseFormValues,
SplittingOptions,
expenseFormSchema,
} from '@/lib/schemas'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { Save, Trash2 } from 'lucide-react' import { Save } from 'lucide-react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { useState } from 'react' import { useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { match } from 'ts-pattern' import { match } from 'ts-pattern'
import { DeletePopup } from './delete-popup'
import { extractCategoryFromTitle } from './expense-form-actions' import { extractCategoryFromTitle } from './expense-form-actions'
import { Textarea } from './ui/textarea'
export type Props = { export type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>> group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
@@ -51,6 +58,90 @@ export type Props = {
categories: NonNullable<Awaited<ReturnType<typeof getCategories>>> categories: NonNullable<Awaited<ReturnType<typeof getCategories>>>
onSubmit: (values: ExpenseFormValues) => Promise<void> onSubmit: (values: ExpenseFormValues) => Promise<void>
onDelete?: () => Promise<void> onDelete?: () => Promise<void>
runtimeFeatureFlags: RuntimeFeatureFlags
}
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, '')
const getDefaultSplittingOptions = (group: Props['group']) => {
const defaultValue = {
splitMode: 'EVENLY' as const,
paidFor: group.participants.map(({ id }) => ({
participant: id,
shares: '1' as unknown as number,
})),
}
if (typeof localStorage === 'undefined') return defaultValue
const defaultSplitMode = localStorage.getItem(
`${group.id}-defaultSplittingOptions`,
)
if (defaultSplitMode === null) return defaultValue
const parsedDefaultSplitMode = JSON.parse(
defaultSplitMode,
) as SplittingOptions
if (parsedDefaultSplitMode.paidFor === null) {
parsedDefaultSplitMode.paidFor = defaultValue.paidFor
}
// if there is a participant in the default options that does not exist anymore,
// remove the stale default splitting options
for (const parsedPaidFor of parsedDefaultSplitMode.paidFor) {
if (
!group.participants.some(({ id }) => id === parsedPaidFor.participant)
) {
localStorage.removeItem(`${group.id}-defaultSplittingOptions`)
return defaultValue
}
}
return {
splitMode: parsedDefaultSplitMode.splitMode,
paidFor: parsedDefaultSplitMode.paidFor.map((paidFor) => ({
participant: paidFor.participant,
shares: String(paidFor.shares / 100) as unknown as number,
})),
}
}
async function persistDefaultSplittingOptions(
groupId: string,
expenseFormValues: ExpenseFormValues,
) {
if (localStorage && expenseFormValues.saveDefaultSplittingOptions) {
const computePaidFor = (): SplittingOptions['paidFor'] => {
if (expenseFormValues.splitMode === 'EVENLY') {
return expenseFormValues.paidFor.map(({ participant }) => ({
participant,
shares: '100' as unknown as number,
}))
} else if (expenseFormValues.splitMode === 'BY_AMOUNT') {
return null
} else {
return expenseFormValues.paidFor
}
}
const splittingOptions = {
splitMode: expenseFormValues.splitMode,
paidFor: computePaidFor(),
} satisfies SplittingOptions
localStorage.setItem(
`${groupId}-defaultSplittingOptions`,
JSON.stringify(splittingOptions),
)
}
} }
export function ExpenseForm({ export function ExpenseForm({
@@ -59,18 +150,20 @@ export function ExpenseForm({
categories, categories,
onSubmit, onSubmit,
onDelete, onDelete,
runtimeFeatureFlags,
}: Props) { }: Props) {
const isCreate = expense === undefined const isCreate = expense === undefined
const searchParams = useSearchParams() const searchParams = useSearchParams()
const getSelectedPayer = (field?: { value: string }) => { const getSelectedPayer = (field?: { value: string }) => {
if (isCreate && typeof window !== 'undefined') { if (isCreate && typeof window !== 'undefined') {
const activeUser = localStorage.getItem(`${group.id}-activeUser`) const activeUser = localStorage.getItem(`${group.id}-activeUser`)
if (activeUser && activeUser !== 'None') { if (activeUser && activeUser !== 'None' && field?.value === undefined) {
return activeUser return activeUser
} }
} }
return field?.value return field?.value
} }
const defaultSplittingOptions = getDefaultSplittingOptions(group)
const form = useForm<ExpenseFormValues>({ const form = useForm<ExpenseFormValues>({
resolver: zodResolver(expenseFormSchema), resolver: zodResolver(expenseFormSchema),
defaultValues: expense defaultValues: expense
@@ -85,8 +178,10 @@ export function ExpenseForm({
shares: String(shares / 100) as unknown as number, shares: String(shares / 100) as unknown as number,
})), })),
splitMode: expense.splitMode, splitMode: expense.splitMode,
saveDefaultSplittingOptions: false,
isReimbursement: expense.isReimbursement, isReimbursement: expense.isReimbursement,
documents: expense.documents, documents: expense.documents,
notes: expense.notes ?? '',
} }
: searchParams.get('reimbursement') : searchParams.get('reimbursement')
? { ? {
@@ -99,12 +194,17 @@ export function ExpenseForm({
paidBy: searchParams.get('from') ?? undefined, paidBy: searchParams.get('from') ?? undefined,
paidFor: [ paidFor: [
searchParams.get('to') searchParams.get('to')
? { participant: searchParams.get('to')!, shares: 1 } ? {
participant: searchParams.get('to')!,
shares: '1' as unknown as number,
}
: undefined, : undefined,
], ],
isReimbursement: true, isReimbursement: true,
splitMode: 'EVENLY', splitMode: defaultSplittingOptions.splitMode,
saveDefaultSplittingOptions: false,
documents: [], documents: [],
notes: '',
} }
: { : {
title: searchParams.get('title') ?? '', title: searchParams.get('title') ?? '',
@@ -116,13 +216,11 @@ export function ExpenseForm({
? Number(searchParams.get('categoryId')) ? Number(searchParams.get('categoryId'))
: 0, // category with Id 0 is General : 0, // category with Id 0 is General
// paid for all, split evenly // paid for all, split evenly
paidFor: group.participants.map(({ id }) => ({ paidFor: defaultSplittingOptions.paidFor,
participant: id,
shares: 1,
})),
paidBy: getSelectedPayer(), paidBy: getSelectedPayer(),
isReimbursement: false, isReimbursement: false,
splitMode: 'EVENLY', splitMode: defaultSplittingOptions.splitMode,
saveDefaultSplittingOptions: false,
documents: searchParams.get('imageUrl') documents: searchParams.get('imageUrl')
? [ ? [
{ {
@@ -133,13 +231,19 @@ export function ExpenseForm({
}, },
] ]
: [], : [],
notes: '',
}, },
}) })
const [isCategoryLoading, setCategoryLoading] = useState(false) const [isCategoryLoading, setCategoryLoading] = useState(false)
const submit = async (values: ExpenseFormValues) => {
await persistDefaultSplittingOptions(group.id, values)
return onSubmit(values)
}
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit((values) => onSubmit(values))}> <form onSubmit={form.handleSubmit(submit)}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
@@ -160,7 +264,7 @@ export function ExpenseForm({
{...field} {...field}
onBlur={async () => { onBlur={async () => {
field.onBlur() // avoid skipping other blur event listeners since we overwrite `field` field.onBlur() // avoid skipping other blur event listeners since we overwrite `field`
if (process.env.NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT) { if (runtimeFeatureFlags.enableCategoryExtract) {
setCategoryLoading(true) setCategoryLoading(true)
const { categoryId } = await extractCategoryFromTitle( const { categoryId } = await extractCategoryFromTitle(
field.value, field.value,
@@ -206,18 +310,23 @@ export function ExpenseForm({
<FormField <FormField
control={form.control} control={form.control}
name="amount" name="amount"
render={({ field }) => ( render={({ field: { onChange, ...field } }) => (
<FormItem className="sm:order-3"> <FormItem className="sm:order-3">
<FormLabel>Amount</FormLabel> <FormLabel>Amount</FormLabel>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<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="number" type="text"
inputMode="decimal" inputMode="decimal"
step={0.01} step={0.01}
placeholder="0.00" placeholder="0.00"
onChange={(event) =>
onChange(enforceCurrencyPattern(event.target.value))
}
onClick={(e) => e.currentTarget.select()}
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -295,6 +404,18 @@ export function ExpenseForm({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem className="sm:order-6">
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea className="text-base" {...field} />
</FormControl>
</FormItem>
)}
/>
</CardContent> </CardContent>
</Card> </Card>
@@ -423,7 +544,7 @@ export function ExpenseForm({
), ),
)} )}
className="text-base w-[80px] -my-2" className="text-base w-[80px] -my-2"
type="number" type="text"
disabled={ disabled={
!field.value?.some( !field.value?.some(
({ participant }) => ({ participant }) =>
@@ -443,7 +564,9 @@ export function ExpenseForm({
? { ? {
participant: id, participant: id,
shares: shares:
event.target.value, enforceCurrencyPattern(
event.target.value,
),
} }
: p, : p,
), ),
@@ -486,7 +609,10 @@ export function ExpenseForm({
)} )}
/> />
<Collapsible className="mt-5"> <Collapsible
className="mt-5"
defaultOpen={form.getValues().splitMode !== 'EVENLY'}
>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="link" className="-mx-4"> <Button variant="link" className="-mx-4">
Advanced splitting options Advanced splitting options
@@ -498,7 +624,7 @@ export function ExpenseForm({
control={form.control} control={form.control}
name="splitMode" name="splitMode"
render={({ field }) => ( render={({ field }) => (
<FormItem className="sm:order-2"> <FormItem>
<FormLabel>Split mode</FormLabel> <FormLabel>Split mode</FormLabel>
<FormControl> <FormControl>
<Select <Select
@@ -534,13 +660,32 @@ export function ExpenseForm({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="saveDefaultSplittingOptions"
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>
Save as default splitting options
</FormLabel>
</div>
</FormItem>
)}
/>
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</CardContent> </CardContent>
</Card> </Card>
{process.env.NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS && ( {runtimeFeatureFlags.enableExpenseDocuments && (
<Card className="mt-4"> <Card className="mt-4">
<CardHeader> <CardHeader>
<CardTitle className="flex justify-between"> <CardTitle className="flex justify-between">
@@ -573,16 +718,11 @@ export function ExpenseForm({
{isCreate ? <>Create</> : <>Save</>} {isCreate ? <>Create</> : <>Save</>}
</SubmitButton> </SubmitButton>
{!isCreate && onDelete && ( {!isCreate && onDelete && (
<AsyncButton <DeletePopup onDelete={onDelete}></DeletePopup>
type="button"
variant="destructive"
loadingContent="Deleting…"
action={onDelete}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</AsyncButton>
)} )}
<Button variant="ghost" asChild>
<Link href={`/groups/${group.id}`}>Cancel</Link>
</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -35,6 +35,7 @@ import { getGroup } from '@/lib/api'
import { GroupFormValues, groupFormSchema } from '@/lib/schemas' import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { Save, Trash2 } from 'lucide-react' import { Save, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form' import { useFieldArray, useForm } from 'react-hook-form'
@@ -272,13 +273,19 @@ export function GroupForm({
</CardContent> </CardContent>
</Card> </Card>
<SubmitButton <div className="flex mt-4 gap-2">
size="lg" <SubmitButton
loadingContent={group ? 'Saving…' : 'Creating…'} loadingContent={group ? 'Saving…' : 'Creating…'}
onClick={updateActiveUser} onClick={updateActiveUser}
> >
<Save className="w-4 h-4 mr-2" /> {group ? <>Save</> : <> Create</>} <Save className="w-4 h-4 mr-2" /> {group ? <>Save</> : <> Create</>}
</SubmitButton> </SubmitButton>
{!group && (
<Button variant="ghost" asChild>
<Link href="/groups">Cancel</Link>
</Button>
)}
</div>
</form> </form>
</Form> </Form>
) )

View File

@@ -72,6 +72,7 @@ export async function createExpense(
})), })),
}, },
}, },
notes: expenseFormValues.notes,
}, },
}) })
} }
@@ -185,6 +186,7 @@ export async function updateExpense(
id: doc.id, id: doc.id,
})), })),
}, },
notes: expenseFormValues.notes,
}, },
}) })
} }

View File

@@ -1,5 +1,10 @@
import { ZodIssueCode, z } from 'zod' import { ZodIssueCode, z } from 'zod'
const interpretEnvVarAsBool = (val: unknown): boolean => {
if (typeof val !== 'string') return false
return ['true', 'yes', '1', 'on'].includes(val.toLowerCase())
}
const envSchema = z const envSchema = z
.object({ .object({
POSTGRES_URL_NON_POOLING: z.string().url(), POSTGRES_URL_NON_POOLING: z.string().url(),
@@ -12,14 +17,23 @@ const envSchema = z
? `https://${process.env.VERCEL_URL}` ? `https://${process.env.VERCEL_URL}`
: 'http://localhost:3000', : 'http://localhost:3000',
), ),
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS: z.coerce.boolean().default(false), NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS: z.preprocess(
interpretEnvVarAsBool,
z.boolean().default(false),
),
S3_UPLOAD_KEY: z.string().optional(), S3_UPLOAD_KEY: z.string().optional(),
S3_UPLOAD_SECRET: z.string().optional(), S3_UPLOAD_SECRET: z.string().optional(),
S3_UPLOAD_BUCKET: z.string().optional(), S3_UPLOAD_BUCKET: z.string().optional(),
S3_UPLOAD_REGION: z.string().optional(), S3_UPLOAD_REGION: z.string().optional(),
S3_UPLOAD_ENDPOINT: z.string().optional(), S3_UPLOAD_ENDPOINT: z.string().optional(),
NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT: z.coerce.boolean().default(false), NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT: z.preprocess(
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT: z.coerce.boolean().default(false), interpretEnvVarAsBool,
z.boolean().default(false),
),
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT: z.preprocess(
interpretEnvVarAsBool,
z.boolean().default(false),
),
OPENAI_API_KEY: z.string().optional(), OPENAI_API_KEY: z.string().optional(),
}) })
.superRefine((env, ctx) => { .superRefine((env, ctx) => {

15
src/lib/featureFlags.ts Normal file
View File

@@ -0,0 +1,15 @@
'use server'
import { env } from './env'
export async function getRuntimeFeatureFlags() {
return {
enableExpenseDocuments: env.NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS,
enableReceiptExtract: env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT,
enableCategoryExtract: env.NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT,
}
}
export type RuntimeFeatureFlags = Awaited<
ReturnType<typeof getRuntimeFeatureFlags>
>

View File

@@ -48,3 +48,17 @@ export function useBaseUrl() {
}, []) }, [])
return baseUrl return baseUrl
} }
/**
* @returns The active user, or `null` until it is fetched from local storage
*/
export function useActiveUser(groupId: string) {
const [activeUser, setActiveUser] = useState<string | null>(null)
useEffect(() => {
const activeUser = localStorage.getItem(`${groupId}-activeUser`)
if (activeUser) setActiveUser(activeUser)
}, [groupId])
return activeUser
}

View File

@@ -51,8 +51,7 @@ export const expenseFormSchema = z
[ [
z.number(), z.number(),
z.string().transform((value, ctx) => { z.string().transform((value, ctx) => {
const normalizedValue = value.replace(/,/g, '.') const valueAsNumber = Number(value)
const valueAsNumber = Number(normalizedValue)
if (Number.isNaN(valueAsNumber)) if (Number.isNaN(valueAsNumber))
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
@@ -106,6 +105,7 @@ export const expenseFormSchema = z
Object.values(SplitMode) as any, Object.values(SplitMode) as any,
) )
.default('EVENLY'), .default('EVENLY'),
saveDefaultSplittingOptions: z.boolean(),
isReimbursement: z.boolean(), isReimbursement: z.boolean(),
documents: z documents: z
.array( .array(
@@ -117,6 +117,7 @@ export const expenseFormSchema = z
}), }),
) )
.default([]), .default([]),
notes: z.string().optional(),
}) })
.superRefine((expense, ctx) => { .superRefine((expense, ctx) => {
let sum = 0 let sum = 0
@@ -161,3 +162,9 @@ export const expenseFormSchema = z
}) })
export type ExpenseFormValues = z.infer<typeof expenseFormSchema> export type ExpenseFormValues = z.infer<typeof expenseFormSchema>
export type SplittingOptions = {
// Used for saving default splitting options in localStorage
splitMode: SplitMode
paidFor: ExpenseFormValues['paidFor'] | null
}

70
src/lib/totals.ts Normal file
View File

@@ -0,0 +1,70 @@
import { getGroupExpenses } from '@/lib/api'
export function getTotalGroupSpending(
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
): number {
return expenses.reduce(
(total, expense) =>
expense.isReimbursement ? total : total + expense.amount,
0,
)
}
export function getTotalActiveUserPaidFor(
activeUserId: string | null,
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
): number {
return expenses.reduce(
(total, expense) =>
expense.paidBy.id === activeUserId && !expense.isReimbursement
? total + expense.amount
: total,
0,
)
}
export function getTotalActiveUserShare(
activeUserId: string | null,
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
): number {
let total = 0
expenses.forEach((expense) => {
if (expense.isReimbursement) return
const paidFors = expense.paidFor
const userPaidFor = paidFors.find(
(paidFor) => paidFor.participantId === activeUserId,
)
if (!userPaidFor) {
// If the active user is not involved in the expense, skip it
return
}
switch (expense.splitMode) {
case 'EVENLY':
// Divide the total expense evenly among all participants
total += expense.amount / paidFors.length
break
case 'BY_AMOUNT':
// Directly add the user's share if the split mode is BY_AMOUNT
total += userPaidFor.shares
break
case 'BY_PERCENTAGE':
// Calculate the user's share based on their percentage of the total expense
total += (expense.amount * userPaidFor.shares) / 10000 // Assuming shares are out of 10000 for percentage
break
case 'BY_SHARES':
// Calculate the user's share based on their shares relative to the total shares
const totalShares = paidFors.reduce(
(sum, paidFor) => sum + paidFor.shares,
0,
)
total += (expense.amount * userPaidFor.shares) / totalShares
break
}
})
return parseFloat(total.toFixed(2))
}