diff options
| author | Sebastien Castiel <sebastien@castiel.me> | 2024-01-30 16:36:29 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-01-30 16:36:29 -0500 |
| commit | 4a9bf575bd24b2e24348c65d9fbbf9c81a73cb35 (patch) | |
| tree | 364cb36ea89d8023676140ed2fdd166c3b1fd602 | |
| parent | 9e300e0ff0c9d93cbaeaa86c1e2a560523107382 (diff) | |
Create expense from receipt (#69)
* Create expense from receipt
* Add modal
* Update README
| -rw-r--r-- | README.md | 16 | ||||
| -rw-r--r-- | package-lock.json | 767 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts | 48 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx | 278 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/expense-list.tsx | 11 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/page.tsx | 13 | ||||
| -rw-r--r-- | src/components/expense-form.tsx | 25 | ||||
| -rw-r--r-- | src/lib/env.ts | 9 | ||||
| -rw-r--r-- | src/lib/utils.ts | 7 |
10 files changed, 657 insertions, 518 deletions
@@ -18,6 +18,7 @@ Spliit is a free and open source alternative to Splitwise. You can either use th - [x] Assign a category to expenses [(#35)](https://github.com/spliit-app/spliit/issues/35) - [x] Search for expenses in a group [(#51)](https://github.com/spliit-app/spliit/issues/51) - [x] Upload and attach images to expenses [(#63)](https://github.com/spliit-app/spliit/issues/63) +- [x] Create expense by scanning a receipt [(#23)](https://github.com/spliit-app/spliit/issues/23) ### Possible incoming features @@ -73,6 +74,21 @@ S3_UPLOAD_BUCKET=name-of-s3-bucket S3_UPLOAD_REGION=us-east-1 ``` +### Create expense from receipt + +You can offer users to create expense by uploading a receipt. This feature relies on [OpenAI GPT-4 with Vision](https://platform.openai.com/docs/guides/vision). + +To enable the feature: + +- You must enable expense documents feature as well (see section above). That might change in the future, but for now we need to store images to make receipt scanning work. +- Subscribe to OpenAI API and get access to GPT 4 with Vision (you might need to buy credits in advance). +- Update your environment variables with appropriate values: + +```.env +NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT=true +OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX +``` + ## License MIT, see [LICENSE](./LICENSE). diff --git a/package-lock.json b/package-lock.json index f137671..b28fb4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "next-s3-upload": "^0.3.4", "next-themes": "^0.2.1", "next13-progressbar": "^1.1.1", + "openai": "^4.25.0", "pg": "^8.11.3", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -910,15 +911,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/runtime": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.45.0.tgz", - "integrity": "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -1058,31 +1050,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.2.tgz", - "integrity": "sha512-itHBs1rPmsmGF9p4qRe++CzCgd+kFYktnsoR1sbIAfsRMrJZau0Tt1AH9KVnufc2/tU02Gf6Ibujx+15qRE03w==", - "cpu": [ - "arm64" - ], - "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-arm64": "1.0.1" - } - }, "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", @@ -1108,27 +1075,6 @@ "@img/sharp-libvips-darwin-x64": "1.0.1" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-kQyrSNd6lmBV7O0BUiyu/OEw9yeNGFbQhbxswS1i6rMDwBBSX+e+rPzu3S+MwAiGU3HdLze3PanQ4Xkfemgzcw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "macos": ">=11", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "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", @@ -1150,345 +1096,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.1.tgz", - "integrity": "sha512-FtdMvR4R99FTsD53IA3LxYGghQ82t3yt0ZQ93WMZ2xV3dqrb0E8zq4VHaTOuLEAuA83oDawHV3fd+BsAPadHIQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.28", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.1.tgz", - "integrity": "sha512-bnGG+MJjdX70mAQcSLxgeJco11G+MxTz+ebxlz8Y3dxyeb3Nkl7LgLI0mXupoO+u1wRNx/iRj5yHtzA4sde1yA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.26", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.1.tgz", - "integrity": "sha512-3+rzfAR1YpMOeA2zZNp+aYEzGNWK4zF3+sdMxuCS3ey9HhDbJ66w6hDSHDMoap32DueFwhhs3vwooAB2MaK4XQ==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.28", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.1.tgz", - "integrity": "sha512-3NR1mxFsaSgMMzz1bAnnKbSAI+lHXVTqAHgc1bgzjHuXjo4hlscpUxc0vFSAPKI3yuzdzcZOkq7nDPrP2F8Jgw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.26", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.1.tgz", - "integrity": "sha512-5aBRcjHDG/T6jwC3Edl3lP8nl9U2Yo8+oTl5drd1dh9Z1EBfzUKAJFUDTDisDjUwc7N4AjnPGfCA3jl3hY8uDg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "musl": ">=1.2.2", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.1.tgz", - "integrity": "sha512-dcT7inI9DBFK6ovfeWRe3hG30h51cBAP5JXlZfx6pzc/Mnf9HFCQDLtYf4MCBjxaaTfjCCjkBxcy3XzOAo5txw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "musl": ">=1.2.2", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.2.tgz", - "integrity": "sha512-Fndk/4Zq3vAc4G/qyfXASbS3HBZbKrlnKZLEJzPLrXoJuipFNNwTes71+Ki1hwYW5lch26niRYoZFAtZVf3EGA==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.28", - "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-linux-arm": "1.0.1" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.2.tgz", - "integrity": "sha512-pz0NNo882vVfqJ0yNInuG9YH71smP4gRSdeL09ukC2YLE6ZyZePAlWKEHgAzJGTiOh8Qkaov6mMIMlEhmLdKew==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "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-linux-arm64": "1.0.1" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.2.tgz", - "integrity": "sha512-MBoInDXDppMfhSzbMmOQtGfloVAflS2rP1qPcUIiITMi36Mm5YR7r0ASND99razjQUpHTzjrU1flO76hKvP5RA==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.28", - "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-linux-s390x": "1.0.1" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.2.tgz", - "integrity": "sha512-xUT82H5IbXewKkeF5aiooajoO1tQV4PnKfS/OZtb5DDdxS/FCI/uXTVZ35GQ97RZXsycojz/AJ0asoz6p2/H/A==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "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-linux-x64": "1.0.1" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.2.tgz", - "integrity": "sha512-F+0z8JCu/UnMzg8IYW1TMeiViIWBVg7IWP6nE0p5S5EPQxlLd76c8jYemG21X99UzFwgkRo5yz2DS+zbrnxZeA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "musl": ">=1.2.2", - "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-linuxmusl-arm64": "1.0.1" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.2.tgz", - "integrity": "sha512-+ZLE3SQmSL+Fn1gmSaM8uFusW5Y3J9VOf+wMGNnTtJUMUxFhv+P4UPaYEYT8tqnyYVaOVGgMN/zsOxn9pSsO2A==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "musl": ">=1.2.2", - "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-linuxmusl-x64": "1.0.1" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.2.tgz", - "integrity": "sha512-fLbTaESVKuQcpm8ffgBD7jLb/CQLcATju/jxtTXR1XCLwbOQt+OL5zPHSDMmp2JZIeq82e18yE0Vv7zh6+6BfQ==", - "cpu": [ - "wasm32" - ], - "optional": true, - "dependencies": { - "@emnapi/runtime": "^0.45.0" - }, - "engines": { - "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" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.2.tgz", - "integrity": "sha512-okBpql96hIGuZ4lN3+nsAjGeggxKm7hIRu9zyec0lnfB8E7Z6p95BuRZzDDXZOl2e8UmR4RhYt631i7mfmKU8g==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "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" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.2.tgz", - "integrity": "sha512-E4magOks77DK47FwHUIGH0RYWSgRBfGdK56kIHSVeB9uIS4pPFr4N2kIVsXdQQo4LzOsENKV5KAhRlRL7eMAdg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "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" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1671,111 +1278,6 @@ "node": ">= 10" } }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.0.tgz", - "integrity": "sha512-RHo7Tcj+jllXUbK7xk2NyIDod3YcCPDZxj1WLIYxd709BQ7WuRYl3OWUNG+WUfqeQBds6kvZYlc42NJJTNi4tQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.0.tgz", - "integrity": "sha512-v6kP8sHYxjO8RwHmWMJSq7VZP2nYCkRVQ0qolh2l6xroe9QjbgV8siTbduED4u0hlk0+tjS6/Tuy4n5XCp+l6g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.0.tgz", - "integrity": "sha512-zJ2pnoFYB1F4vmEVlb/eSe+VH679zT1VdXlZKX+pE66grOgjmKJHKacf82g/sWE4MQ4Rk2FMBCRnX+l6/TVYzQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.0.tgz", - "integrity": "sha512-rbaIYFt2X9YZBSbH/CwGAjbBG2/MrACCVu2X0+kSykHzHnYH5FjHxwXLkcoJ10cX0aWCEynpu+rP76x0914atg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.0.tgz", - "integrity": "sha512-o1N5TsYc8f/HpGt39OUQpQ9AKIGApd3QLueu7hXk//2xq5Z9OxmV6sQfNp8C7qYmiOlHYODOGqNNa0e9jvchGQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.0.tgz", - "integrity": "sha512-XXIuB1DBRCFwNO6EEzCTMHT5pauwaSj4SWs7CYnME57eaReAKBXCnkUE80p/pAZcewm7hs+vGvNqDPacEXHVkw==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.0.tgz", - "integrity": "sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3521,12 +3023,20 @@ "version": "20.8.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz", "integrity": "sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/nprogress": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@types/nprogress/-/nprogress-0.2.3.tgz", @@ -3771,6 +3281,17 @@ "dev": true, "license": "ISC" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", @@ -3794,6 +3315,17 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4045,6 +3577,11 @@ "has-symbols": "^1.0.3" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/autoprefixer": { "version": "10.4.16", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", @@ -4122,6 +3659,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4308,6 +3850,14 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -4647,6 +4197,17 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/command-score": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/command-score/-/command-score-0.1.2.tgz", @@ -4693,6 +4254,14 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "engines": { + "node": "*" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4782,6 +4351,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -4811,6 +4388,15 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0" }, + "node_modules/digest-fetch": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-1.3.0.tgz", + "integrity": "sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==", + "dependencies": { + "base-64": "^0.1.0", + "md5": "^2.3.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -5515,6 +5101,14 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -5680,6 +5274,44 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-node/node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "engines": { + "node": ">= 14" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -6013,6 +5645,14 @@ "node": ">= 0.4" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -6186,6 +5826,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -6713,6 +6358,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6735,6 +6390,25 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -6769,7 +6443,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -6904,6 +6577,43 @@ "react": ">= 18.0.0" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", @@ -7085,6 +6795,33 @@ "wrappy": "1" } }, + "node_modules/openai": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.26.0.tgz", + "integrity": "sha512-HPC7tgYdeP38F3uHA5WgnoXZyGbAp9jgcIo23p6It+q/07u4C+NZ8xHKlMShsPbDDmFRpPsa3vdbXYpbhJH3eg==", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "digest-fetch": "^1.3.0", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7", + "web-streams-polyfill": "^3.2.1" + }, + "bin": { + "openai": "bin/cli" + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.10.tgz", + "integrity": "sha512-IZD8kAM02AW1HRDTPOlz3npFava678pr8Ie9Vp8uRhBROXAv8MXT2pCnGZZAKYdromsNQLHQcfWQ6EOatVLtqA==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -8584,6 +8321,11 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/ts-api-utils": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", @@ -8756,7 +8498,6 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -8871,6 +8612,28 @@ "react-dom": "^16.8 || ^17.0 || ^18.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.2.tgz", + "integrity": "sha512-3pRGuxRF5gpuZc0W+EpwQRmCD7gRqcDOMt688KmdlDAgAyaB1XlN0zq2njfDNm44XVdIouE7pZ6GzbdyH47uIQ==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 819f815..1451f5a 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "next-s3-upload": "^0.3.4", "next-themes": "^0.2.1", "next13-progressbar": "^1.1.1", + "openai": "^4.25.0", "pg": "^8.11.3", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts b/src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts new file mode 100644 index 0000000..1714f82 --- /dev/null +++ b/src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts @@ -0,0 +1,48 @@ +'use server' +import { getCategories } from '@/lib/api' +import { env } from '@/lib/env' +import OpenAI from 'openai' + +const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY }) + +export async function extractExpenseInformationFromImage(imageUrl: string) { + 'use server' + const categories = await getCategories() + + const body = { + model: 'gpt-4-vision-preview', + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: ` + This image contains a receipt. + Read the total amount and store it as a non-formatted number without any other text or currency. + Then guess the category for this receipt amoung the following categories and store its ID: ${categories.map( + ({ id, grouping, name }) => `"${grouping}/${name}" (ID: ${id})`, + )}. + Guess the expense’s date and store it as yyyy-mm-dd. + Guess a title for the expense. + Return the amount, the category, the date and the title with just a comma between them, without anything else.`, + }, + ], + }, + { + role: 'user', + content: [{ type: 'image_url', image_url: { url: imageUrl } }], + }, + ], + } + const completion = await openai.chat.completions.create(body as any) + + const [amountString, categoryId, date, title] = completion.choices + .at(0) + ?.message.content?.split(',') ?? [null, null, null, null] + return { amount: Number(amountString), categoryId, date, title } +} + +export type ReceiptExtractedInfo = Awaited< + ReturnType<typeof extractExpenseInformationFromImage> +> diff --git a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx new file mode 100644 index 0000000..5feb931 --- /dev/null +++ b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx @@ -0,0 +1,278 @@ +'use client' + +import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon' +import { + ReceiptExtractedInfo, + extractExpenseInformationFromImage, +} from '@/app/groups/[groupId]/expenses/create-from-receipt-button-actions' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from '@/components/ui/drawer' +import { ToastAction } from '@/components/ui/toast' +import { useToast } from '@/components/ui/use-toast' +import { useMediaQuery } from '@/lib/hooks' +import { formatExpenseDate } from '@/lib/utils' +import { Category } from '@prisma/client' +import { ChevronRight, Loader2, Receipt } from 'lucide-react' +import { getImageData, useS3Upload } from 'next-s3-upload' +import Image from 'next/image' +import { useRouter } from 'next/navigation' +import { PropsWithChildren, ReactNode, useState } from 'react' + +type Props = { + groupId: string + groupCurrency: string + categories: Category[] +} + +export function CreateFromReceiptButton({ + groupId, + groupCurrency, + categories, +}: Props) { + const [pending, setPending] = useState(false) + const { uploadToS3, FileInput, openFileDialog } = useS3Upload() + const { toast } = useToast() + const router = useRouter() + const [receiptInfo, setReceiptInfo] = useState< + | null + | (ReceiptExtractedInfo & { url: string; width?: number; height?: number }) + >(null) + const isDesktop = useMediaQuery('(min-width: 640px)') + + const handleFileChange = async (file: File) => { + const upload = async () => { + try { + setPending(true) + console.log('Uploading image…') + let { url } = await uploadToS3(file) + console.log('Extracting information from receipt…') + const { amount, categoryId, date, title } = + await extractExpenseInformationFromImage(url) + const { width, height } = await getImageData(file) + setReceiptInfo({ amount, categoryId, date, title, url, width, height }) + } catch (err) { + console.error(err) + toast({ + title: 'Error while uploading document', + description: + 'Something wrong happened when uploading the document. Please retry later or select a different file.', + variant: 'destructive', + action: ( + <ToastAction altText="Retry" onClick={() => upload()}> + Retry + </ToastAction> + ), + }) + } finally { + setPending(false) + } + } + upload() + } + + const receiptInfoCategory = + (receiptInfo?.categoryId && + categories.find((c) => String(c.id) === receiptInfo.categoryId)) || + null + + const DialogOrDrawer = isDesktop + ? CreateFromReceiptDialog + : CreateFromReceiptDrawer + + return ( + <DialogOrDrawer + trigger={ + <Button + size="icon" + variant="secondary" + title="Create expense from receipt" + > + <Receipt className="w-4 h-4" /> + </Button> + } + title={ + <> + <span>Create from receipt</span> + <Badge className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600"> + Beta + </Badge> + </> + } + description={<>Extract the expense information from a receipt photo.</>} + > + <div className="prose prose-sm dark:prose-invert"> + <p> + Upload the photo of a receipt, and we’ll scan it to extract the + expense information if we can. + </p> + <div> + <FileInput + onChange={handleFileChange} + accept="image/jpeg,image/png" + /> + <div className="grid gap-x-4 gap-y-2 grid-cols-3"> + <Button + variant="secondary" + className="row-span-3 w-full h-full relative" + title="Create expense from receipt" + onClick={openFileDialog} + disabled={pending} + > + {pending ? ( + <Loader2 className="w-8 h-8 animate-spin" /> + ) : receiptInfo ? ( + <div className="absolute top-2 left-2 bottom-2 right-2"> + <Image + src={receiptInfo.url} + width={receiptInfo.width} + height={receiptInfo.height} + className="w-full h-full m-0 object-contain drop-shadow-lg" + alt="Scanned receipt" + /> + </div> + ) : ( + <span className="text-xs sm:text-sm text-muted-foreground"> + Select image… + </span> + )} + </Button> + <div className="col-span-2"> + <strong>Title:</strong> + <div>{receiptInfo?.title ?? '…'}</div> + </div> + <div className="col-span-2"> + <strong>Category:</strong> + <div> + {receiptInfoCategory ? ( + <div className="flex items-center"> + <CategoryIcon + category={receiptInfoCategory} + className="inline w-4 h-4 mr-2" + /> + <span className="mr-1">{receiptInfoCategory.grouping}</span> + <ChevronRight className="inline w-3 h-3 mr-1" /> + <span>{receiptInfoCategory.name}</span> + </div> + ) : ( + '' || '…' + )} + </div> + </div> + <div> + <strong>Amount:</strong> + <div> + {receiptInfo?.amount ? ( + <> + {groupCurrency} {receiptInfo.amount.toFixed(2)} + </> + ) : ( + '…' + )} + </div> + </div> + <div> + <strong>Date:</strong> + <div> + {receiptInfo?.date + ? formatExpenseDate( + new Date(`${receiptInfo?.date}T12:00:00.000Z`), + ) + : '…'} + </div> + </div> + </div> + </div> + <p>You’ll be able to edit the expense information after creating it.</p> + <div className="text-center"> + <Button + disabled={pending || !receiptInfo} + onClick={() => { + if (!receiptInfo) return + router.push( + `/groups/${groupId}/expenses/create?amount=${ + receiptInfo.amount + }&categoryId=${receiptInfo.categoryId}&date=${ + receiptInfo.date + }&title=${encodeURIComponent( + receiptInfo.title ?? '', + )}&imageUrl=${encodeURIComponent(receiptInfo.url)}&imageWidth=${ + receiptInfo.width + }&imageHeight=${receiptInfo.height}`, + ) + }} + > + Create expense + </Button> + </div> + </div> + </DialogOrDrawer> + ) +} + +function CreateFromReceiptDialog({ + trigger, + title, + description, + children, +}: PropsWithChildren<{ + trigger: ReactNode + title: ReactNode + description: ReactNode +}>) { + return ( + <Dialog> + <DialogTrigger asChild>{trigger}</DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle className="flex items-center gap-2">{title}</DialogTitle> + <DialogDescription className="text-left"> + {description} + </DialogDescription> + </DialogHeader> + {children} + </DialogContent> + </Dialog> + ) +} + +function CreateFromReceiptDrawer({ + trigger, + title, + description, + children, +}: PropsWithChildren<{ + trigger: ReactNode + title: ReactNode + description: ReactNode +}>) { + return ( + <Drawer> + <DrawerTrigger asChild>{trigger}</DrawerTrigger> + <DrawerContent> + <DrawerHeader> + <DrawerTitle className="flex items-center gap-2">{title}</DrawerTitle> + <DrawerDescription className="text-left"> + {description} + </DrawerDescription> + </DrawerHeader> + <div className="px-4 pb-4">{children}</div> + </DrawerContent> + </Drawer> + ) +} diff --git a/src/app/groups/[groupId]/expenses/expense-list.tsx b/src/app/groups/[groupId]/expenses/expense-list.tsx index 0dc7a7e..a97c2bd 100644 --- a/src/app/groups/[groupId]/expenses/expense-list.tsx +++ b/src/app/groups/[groupId]/expenses/expense-list.tsx @@ -3,7 +3,7 @@ import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon' import { Button } from '@/components/ui/button' import { SearchBar } from '@/components/ui/search-bar' import { getGroupExpenses } from '@/lib/api' -import { cn } from '@/lib/utils' +import { cn, formatExpenseDate } from '@/lib/utils' import { Expense, Participant } from '@prisma/client' import dayjs, { type Dayjs } from 'dayjs' import { ChevronRight } from 'lucide-react' @@ -159,7 +159,7 @@ export function ExpenseList({ {currency} {(expense.amount / 100).toFixed(2)} </div> <div className="text-xs text-muted-foreground"> - {formatDate(expense.expenseDate)} + {formatExpenseDate(expense.expenseDate)} </div> </div> <Button @@ -189,10 +189,3 @@ export function ExpenseList({ </p> ) } - -function formatDate(date: Date) { - return date.toLocaleDateString('en-US', { - dateStyle: 'medium', - timeZone: 'UTC', - }) -} diff --git a/src/app/groups/[groupId]/expenses/page.tsx b/src/app/groups/[groupId]/expenses/page.tsx index 0c36166..9dc3ab5 100644 --- a/src/app/groups/[groupId]/expenses/page.tsx +++ b/src/app/groups/[groupId]/expenses/page.tsx @@ -1,5 +1,6 @@ import { cached } from '@/app/cached-functions' import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal' +import { CreateFromReceiptButton } from '@/app/groups/[groupId]/expenses/create-from-receipt-button' import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list' import { Button } from '@/components/ui/button' import { @@ -10,7 +11,8 @@ import { CardTitle, } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' -import { getGroupExpenses } from '@/lib/api' +import { getCategories, getGroupExpenses } from '@/lib/api' +import { env } from '@/lib/env' import { Download, Plus } from 'lucide-react' import { Metadata } from 'next' import Link from 'next/link' @@ -31,6 +33,8 @@ export default async function GroupExpensesPage({ const group = await cached.getGroup(groupId) if (!group) notFound() + const categories = await getCategories() + return ( <> <Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0"> @@ -51,6 +55,13 @@ export default async function GroupExpensesPage({ <Download className="w-4 h-4" /> </Link> </Button> + {env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT && ( + <CreateFromReceiptButton + groupId={groupId} + groupCurrency={group.currency} + categories={categories} + /> + )} <Button asChild size="icon"> <Link href={`/groups/${groupId}/expenses/create`}> <Plus className="w-4 h-4" /> diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx index a847949..3d6c2da 100644 --- a/src/components/expense-form.tsx +++ b/src/components/expense-form.tsx @@ -34,7 +34,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { getCategories, getExpense, getGroup } from '@/lib/api' +import { getCategories, getExpense, getGroup, randomId } from '@/lib/api' import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas' import { cn } from '@/lib/utils' import { zodResolver } from '@hookform/resolvers/zod' @@ -105,10 +105,14 @@ export function ExpenseForm({ documents: [], } : { - title: '', - expenseDate: new Date(), - amount: 0, - category: 0, // category with Id 0 is General + title: searchParams.get('title') ?? '', + expenseDate: searchParams.get('date') + ? new Date(searchParams.get('date') as string) + : new Date(), + amount: (searchParams.get('amount') || 0) as unknown as number, // hack, + category: searchParams.get('categoryId') + ? Number(searchParams.get('categoryId')) + : 0, // category with Id 0 is General // paid for all, split evenly paidFor: group.participants.map(({ id }) => ({ participant: id, @@ -117,7 +121,16 @@ export function ExpenseForm({ paidBy: getSelectedPayer(), isReimbursement: false, splitMode: 'EVENLY', - documents: [], + documents: searchParams.get('imageUrl') + ? [ + { + id: randomId(), + url: searchParams.get('imageUrl') as string, + width: Number(searchParams.get('imageWidth')), + height: Number(searchParams.get('imageHeight')), + }, + ] + : [], }, }) diff --git a/src/lib/env.ts b/src/lib/env.ts index 9912945..33b835d 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -17,6 +17,8 @@ const envSchema = z S3_UPLOAD_SECRET: z.string().optional(), S3_UPLOAD_BUCKET: z.string().optional(), S3_UPLOAD_REGION: z.string().optional(), + NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT: z.coerce.boolean().default(false), + OPENAI_API_KEY: z.string().optional(), }) .superRefine((env, ctx) => { if ( @@ -32,6 +34,13 @@ const envSchema = z 'If NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS is specified, then S3_* must be specified too', }) } + if (env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT && !env.OPENAI_API_KEY) { + ctx.addIssue({ + code: ZodIssueCode.custom, + message: + 'If NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT is specified, then OPENAI_API_KEY must be specified too', + }) + } }) export const env = envSchema.parse(process.env) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 502daf3..d0b32e2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -8,3 +8,10 @@ export function cn(...inputs: ClassValue[]) { export function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } + +export function formatExpenseDate(date: Date) { + return date.toLocaleDateString('en-US', { + dateStyle: 'medium', + timeZone: 'UTC', + }) +} |
