├── .example.env
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── components.json
├── evals
├── billsEvals.ts
└── scrapeBill.eval.ts
├── next.config.ts
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── public
├── add.svg
├── camera.png
├── camera.svg
├── file.svg
├── github.svg
├── globe.svg
├── logo.png
├── logo.svg
├── next.svg
├── og.png
├── together.svg
├── trash.svg
├── vercel.svg
└── window.svg
├── src
├── app
│ ├── api
│ │ ├── s3-upload
│ │ │ └── route.ts
│ │ └── vision
│ │ │ └── route.ts
│ ├── app
│ │ ├── InputPrice.tsx
│ │ ├── InputText.tsx
│ │ ├── layout.tsx
│ │ ├── loading.tsx
│ │ ├── page.tsx
│ │ ├── subpages
│ │ │ ├── PeopleAndSplit.tsx
│ │ │ ├── ReceiptItems.tsx
│ │ │ ├── SplitSummary.tsx
│ │ │ └── UploadOrManualBill.tsx
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── components
│ ├── ClearStorageLink.tsx
│ ├── DatePicker.tsx
│ ├── Footer.tsx
│ ├── Header.tsx
│ ├── SubPageHeader.tsx
│ └── ui
│ │ ├── button.tsx
│ │ ├── calendar.tsx
│ │ └── popover.tsx
└── lib
│ ├── clients.ts
│ ├── scrapeBill.ts
│ └── utils.ts
├── tsconfig.json
└── vite.config.ts
/.example.env:
--------------------------------------------------------------------------------
1 | S3_UPLOAD_KEY=
2 | S3_UPLOAD_SECRET=
3 | S3_UPLOAD_BUCKET=
4 | S3_UPLOAD_REGION=us-east-1
5 |
6 | HELICONE_API_KEY=
7 | TOGETHER_API_KEY=
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": false
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Riccardo Giorato
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
BillSplit
7 |
8 | A modern bill splitting app. Powered by Together.ai.
9 |
10 |
11 |
12 | ## Tech Stack
13 |
14 | - Next.js 15 with App Router for modern web development
15 | - Together.ai for advanced LLM capabilities
16 | - Helicone for LLM observability and monitoring
17 | - Amazon S3 for secure image storage
18 | - Vercel for seamless deployment and hosting
19 |
20 | ## How it works
21 |
22 | 1. User uploads a picture of the bill
23 | 2. The app processes the PDF using Together.ai with Vision models and Json mode
24 | 3. The app let the user choose how to split items and add people names
25 | 4. The app displays the final summary with the split of the bill
26 |
27 | ## Cloning & running
28 |
29 | 1. Fork or clone the repo
30 | 2. Create an account at https://togetherai.link for the LLM
31 | 3. Create an account at https://aws.amazon.com/ for the S3 bucket
32 | 4. Create a `.env` (use the `.example.env` for reference) and replace the API keys
33 | 5. Run `pnpm install` and `pnpm run dev` to install dependencies and run locally
34 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/evals/billsEvals.ts:
--------------------------------------------------------------------------------
1 | export const billEvals: {
2 | name: string;
3 | input: string;
4 | expected: {
5 | businessName: string | null;
6 | date: string | null;
7 | billItems: { name: string; price: number }[];
8 | tax: number | null;
9 | tip: number | null;
10 | };
11 | }[] = [
12 | {
13 | name: "US Walmart Receipt",
14 | input: "https://asprise.com/ocr/api/img/blog/rcpt/US-1.jpg",
15 | expected: {
16 | businessName: "Walmart",
17 | date: "2013-11-29",
18 | billItems: [
19 | { name: "3DSXL BUNDLE", price: 149.99 },
20 | { name: "3DSXL BUNDLE", price: 149.99 },
21 | ],
22 | tax: 24,
23 | tip: null,
24 | },
25 | },
26 | {
27 | name: "Nobu Caesars Palace Receipt",
28 | input:
29 | "https://i0.wp.com/escapearoundtheworld.com/wp-content/uploads/2020/03/IMG_6147-1.jpg?resize=610%2C1024&ssl=1",
30 | expected: {
31 | businessName: "NOBU - Restaurant",
32 | date: "2020-02-13",
33 | billItems: [
34 | { name: "Peruvian Caipiri", price: 18 },
35 | { name: "Hakka Nigori", price: 18 },
36 | { name: "PF 155 Oma", price: 155 },
37 | { name: "PF 155 Oma", price: 155 },
38 | ],
39 | tax: 28.98,
40 | tip: 62.28,
41 | },
42 | },
43 | {
44 | name: "Bubba Gump Shrimp Receipt",
45 | input:
46 | "https://media-cdn.tripadvisor.com/media/photo-s/0c/11/46/20/bubba-gump-shrimp-co.jpg",
47 | expected: {
48 | businessName: "Bubba Gump Shrimp Co",
49 | date: "2016-07-18",
50 | billItems: [
51 | { name: "Diet Pepsi", price: 2.79 },
52 | { name: "Lemonade", price: 2.79 },
53 | { name: "Dft 16 Bud Light", price: 3.5 },
54 | { name: "Shrimmer's Heaven", price: 20.99 },
55 | { name: "Steamed Shellfish", price: 24.49 },
56 | { name: "Scampi Linguini", price: 17.49 },
57 | { name: "Scampi Linguini", price: 17.49 },
58 | ],
59 | tax: 4.55,
60 | tip: null,
61 | },
62 | },
63 | {
64 | name: "Italian Restaurant Receipt",
65 | input:
66 | "https://upload.wikimedia.org/wikipedia/commons/e/ee/Italian_supermarket_receipt_showing_value-added_tax_%28IVA%29_categories.jpg",
67 | expected: {
68 | businessName: null,
69 | date: null,
70 | billItems: [
71 | { name: "ACQUA S.ANGELO NATUR", price: 1.32 },
72 | { name: "MILK PRO PORRIDGE AV", price: 1.65 },
73 | { name: "MILK PRO PORRIDGE AV", price: 1.65 },
74 | { name: "LINDT TAVOLETTA LATT", price: 2.85 },
75 | { name: "CIK CROK STILE FATT", price: 2.85 },
76 | { name: "MELE GRANNY SMITH", price: 1.53 },
77 | ],
78 | tax: 1.12,
79 | tip: null,
80 | },
81 | },
82 | {
83 | name: "Dubai Mall Receipt",
84 | input:
85 | "https://media-cdn.tripadvisor.com/media/photo-s/12/99/2b/3b/receipt.jpg",
86 | expected: {
87 | businessName: "Mertcan",
88 | date: "2018-03-28",
89 | billItems: [
90 | { name: "Fresh Lavash Wrap", price: 39 },
91 | { name: "Urfa Kebab", price: 54 },
92 | { name: "Lamb Chops", price: 67 },
93 | { name: "Chicken Skewers", price: 47 },
94 | { name: "OFM Styl Lamb", price: 67 },
95 | { name: "Peach Ice Tea", price: 17 },
96 | { name: "Lemon &Peach I.T", price: 36 },
97 | ],
98 | tax: 16.35,
99 | tip: null,
100 | },
101 | },
102 | {
103 | name: "Tatiana New York Receipt",
104 | input:
105 | "https://reportergourmet.com/upload/multimedia/Tatiana-scontrino.jpg",
106 | expected: {
107 | businessName: "Lincoln Center",
108 | date: "2024-06-18",
109 | billItems: [
110 | { name: "Spicy Marg", price: 18 },
111 | { name: "Tatiana Tonic", price: 18 },
112 | { name: "Egusi Dumpling", price: 22 },
113 | { name: "Crispy Okra", price: 16 },
114 | { name: "Curried Goat Patties", price: 27 },
115 | { name: "Braised Oxtails", price: 58 },
116 | { name: "Malbec, Solar del Alma, Natural, Mendoza", price: 59 },
117 | { name: "Black Bean Hummus", price: 26 },
118 | { name: "Rice & Peas", price: 12 },
119 | { name: "Rum Cake", price: 18 },
120 | ],
121 | tax: 24.33,
122 | tip: null,
123 | },
124 | },
125 | {
126 | name: "El Chalan Restaurant Receipt",
127 | input:
128 | "https://c8.alamy.com/comp/FWREE7/miami-floridael-chalan-restaurant-peruvian-foodcheck-receipt-bill-FWREE7.jpg",
129 | expected: {
130 | businessName: "El Chalan Restaurant",
131 | date: "2016-12-03",
132 | billItems: [
133 | { name: "CAUSA DE POLLO", price: 8.95 },
134 | { name: "CEVICHE DE CAMARONES", price: 16.95 },
135 | { name: "LIMONADA", price: 4 },
136 | { name: "PESCADO AL AJILLO", price: 15.95 },
137 | ],
138 | tax: 3.67,
139 | tip: 9.9,
140 | },
141 | },
142 | {
143 | name: "Blue India Atlanta Receipt",
144 | input:
145 | "https://media-cdn.tripadvisor.com/media/photo-s/1b/3c/ac/33/12-24-19-blue-india-receipt.jpg",
146 | expected: {
147 | businessName: "Blue India Atlanta",
148 | date: "2019-12-24",
149 | billItems: [
150 | { name: "Samosas", price: 6 },
151 | { name: "Karahi Dinner", price: 16 },
152 | { name: "Paneer", price: 1 },
153 | { name: "Vindaloo Dinner", price: 16 },
154 | { name: "- Paneer", price: 1 },
155 | { name: "Biryani", price: 16 },
156 | { name: "Cheddar Naan", price: 6 },
157 | ],
158 | tax: 5.5,
159 | tip: 11.16,
160 | },
161 | },
162 | {
163 | name: "Iranian Restaurant Receipt",
164 | input:
165 | "https://eatgosee.com/wp-content/uploads/2024/06/Iranish-Iranian-Restaurant-Receipt.webp",
166 | expected: {
167 | businessName: "Iranian Restaurant",
168 | date: "2024-5-17",
169 | billItems: [
170 | { name: "Noon", price: 8 },
171 | { name: "Mast-O-Khiar", price: 15 },
172 | { name: "Kashk E Badenjan", price: 36 },
173 | { name: "Soltani", price: 74 },
174 | { name: "Iranian Rice", price: 15 },
175 | { name: "Sparkling water LRG", price: 20 },
176 | ],
177 | tax: 8,
178 | tip: null,
179 | },
180 | },
181 | {
182 | name: "Nobu Los Angeles Receipt",
183 | input: "https://www.tangmeister.com/110416_nobu_los_angeles/Receipt.jpg",
184 | expected: {
185 | businessName: null,
186 | date: "2011-4-16",
187 | billItems: [
188 | { name: "Pina Martini", price: 14 },
189 | { name: "Japanese Calpitrina", price: 14 },
190 | { name: "Yardaskis150car", price: 14 },
191 | { name: "Mia Margarita", price: 4 },
192 | { name: "Diet Coke", price: 27 },
193 | { name: "Vodkared bull (2 @14.00)", price: 28 },
194 | { name: "Vodkared bull12 (4 @12.00)", price: 48 },
195 | { name: "Glass TazulefRiesl Ing", price: 12 },
196 | { name: "Glass TazulefRiesl Ing (2 @12.00)", price: 24 },
197 | { name: "Sangria ROM (6 @24.00)", price: 432 },
198 | { name: "YKS0", price: 225 },
199 | { name: "Green Tea (5 @0.00)", price: 0 },
200 | { name: "Tiradito", price: 75 },
201 | { name: "$25", price: 25 },
202 | { name: "Tiraditto", price: 20 },
203 | { name: "$20", price: 20 },
204 | { name: "New-F BOTAN (3 @30.00)", price: 90 },
205 | { name: "Diet Coke Reflll", price: 3 },
206 | { name: "babboo (3 @25.00)", price: 75 },
207 | { name: "Admin Fee", price: 300 },
208 | { name: "TESOLUR (15 @150.00)", price: 2250 },
209 | { name: "Sparkling Water large", price: 9 },
210 | { name: "King Crab Assu (3 @26.00)", price: 78 },
211 | { name: "Mexican white shrimp (15 @5.00)", price: 75 },
212 | { name: "NorkFish Pate Cav", price: 22 },
213 | ],
214 | tax: 447.72,
215 | tip: 766,
216 | },
217 | },
218 | ];
219 |
--------------------------------------------------------------------------------
/evals/scrapeBill.eval.ts:
--------------------------------------------------------------------------------
1 | import { evalite } from "evalite";
2 | import { Levenshtein } from "autoevals";
3 | import { scrapeBill } from "../src/lib/scrapeBill";
4 | import { billEvals } from "./billsEvals";
5 |
6 | const visionModels = [
7 | "meta-llama/Llama-4-Scout-17B-16E-Instruct",
8 | "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
9 | "Qwen/Qwen2-VL-72B-Instruct",
10 | ];
11 |
12 | visionModels.map((model) => {
13 | evalite(`Bill Scraping with: ${model}`, {
14 | data: async () =>
15 | billEvals.map((item) => ({
16 | input: item.input,
17 | expected: JSON.stringify(item.expected),
18 | })),
19 | task: async (input) =>
20 | JSON.stringify(
21 | await scrapeBill({
22 | billUrl: input,
23 | model: model,
24 | })
25 | ),
26 | scorers: [Levenshtein],
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | };
6 |
7 | export default nextConfig;
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "billsplit",
3 | "onlyBuiltDependencies": [
4 | "better-sqlite3",
5 | "esbuild"
6 | ],
7 | "version": "0.1.0",
8 | "private": true,
9 | "scripts": {
10 | "dev": "next dev --turbopack",
11 | "build": "next build",
12 | "start": "next start",
13 | "lint": "next lint",
14 | "test": "evalite watch"
15 | },
16 | "dependencies": {
17 | "@radix-ui/react-popover": "^1.1.13",
18 | "@radix-ui/react-slot": "^1.2.2",
19 | "class-variance-authority": "^0.7.1",
20 | "clsx": "^2.1.1",
21 | "date-fns": "^4.1.0",
22 | "decimal.js": "^10.5.0",
23 | "dedent": "^1.6.0",
24 | "dotenv": "^16.5.0",
25 | "lodash": "^4.17.21",
26 | "lucide-react": "^0.508.0",
27 | "nanoid": "^5.1.5",
28 | "next": "15.3.2",
29 | "next-plausible": "^3.12.4",
30 | "next-s3-upload": "^0.3.4",
31 | "nuqs": "^2.4.3",
32 | "react": "^19.0.0",
33 | "react-confetti-boom": "^1.1.2",
34 | "react-day-picker": "8.10.1",
35 | "react-dom": "^19.0.0",
36 | "react-dropzone": "^14.3.8",
37 | "react-hook-form": "^7.56.3",
38 | "tailwind-merge": "^3.2.0",
39 | "together-ai": "^0.16.0",
40 | "zod": "^3.24.4",
41 | "zod-to-json-schema": "^3.24.5"
42 | },
43 | "devDependencies": {
44 | "@tailwindcss/postcss": "^4",
45 | "@types/lodash": "^4.17.16",
46 | "@types/node": "^20",
47 | "@types/react": "^19",
48 | "@types/react-dom": "^19",
49 | "autoevals": "^0.0.129",
50 | "evalite": "^0.11.3",
51 | "tailwindcss": "^4",
52 | "tw-animate-css": "^1.2.9",
53 | "typescript": "^5",
54 | "vitest": "^3.1.3"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/public/add.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/camera.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nutlope/billsplit/d207a25004e84ba69c8b82a6455eda877f70d621/public/camera.png
--------------------------------------------------------------------------------
/public/camera.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/github.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nutlope/billsplit/d207a25004e84ba69c8b82a6455eda877f70d621/public/logo.png
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nutlope/billsplit/d207a25004e84ba69c8b82a6455eda877f70d621/public/og.png
--------------------------------------------------------------------------------
/public/together.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/public/trash.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/api/s3-upload/route.ts:
--------------------------------------------------------------------------------
1 | export { POST } from 'next-s3-upload/route';
2 |
--------------------------------------------------------------------------------
/src/app/api/vision/route.ts:
--------------------------------------------------------------------------------
1 | import { scrapeBill } from "../../../lib/scrapeBill";
2 |
3 | export async function POST(req: Request) {
4 | const { billUrl } = await req.json();
5 |
6 | const start = new Date();
7 | const output = await scrapeBill({
8 | billUrl,
9 | });
10 | const endJson = new Date();
11 |
12 | console.log(
13 | "Time it took to generate Bill JSON: ",
14 | (endJson.getTime() - start.getTime()) / 1000
15 | );
16 |
17 | return Response.json(output);
18 | }
19 |
--------------------------------------------------------------------------------
/src/app/app/InputPrice.tsx:
--------------------------------------------------------------------------------
1 | import { InputHTMLAttributes, useState, useEffect, useCallback } from "react";
2 | import Decimal from "decimal.js";
3 | import { debounce } from "lodash";
4 |
5 | type InputPriceProps = Omit<
6 | InputHTMLAttributes,
7 | "value" | "onChange"
8 | > & {
9 | className?: string;
10 | value?: Decimal;
11 | onChange?: (value: Decimal) => void;
12 | };
13 |
14 | export const InputPrice = ({
15 | className = "",
16 | value,
17 | onChange,
18 | ...props
19 | }: InputPriceProps) => {
20 | const [inputValue, setInputValue] = useState(value?.toString() || "");
21 |
22 | useEffect(() => {
23 | setInputValue(value?.toString() || "");
24 | }, [value]);
25 |
26 | const handleKeyDown = (e: React.KeyboardEvent) => {
27 | // Allow only numbers, decimal point, minus sign, and control keys
28 | const allowedKeys = [
29 | "-",
30 | ".",
31 | "Backspace",
32 | "Delete",
33 | "ArrowLeft",
34 | "ArrowRight",
35 | "Tab",
36 | ];
37 | if (!allowedKeys.includes(e.key) && !/^\d$/.test(e.key)) {
38 | e.preventDefault();
39 | }
40 | // Prevent multiple decimal points
41 | if (e.key === "." && inputValue.includes(".")) {
42 | e.preventDefault();
43 | }
44 | // Allow minus sign only at the start
45 | if (e.key === "-" && e.currentTarget.selectionStart !== 0) {
46 | e.preventDefault();
47 | }
48 | };
49 |
50 | const debouncedOnChange = useCallback(
51 | debounce((value: string) => {
52 | if (value === "" || value === ".") {
53 | return;
54 | }
55 | try {
56 | const decimalValue = new Decimal(value);
57 | onChange?.(decimalValue);
58 | } catch (error) {
59 | console.error("Invalid decimal value:", error);
60 | }
61 | }, 800),
62 | [onChange]
63 | );
64 |
65 | const handleChange = (e: React.ChangeEvent) => {
66 | let newValue = e.target.value;
67 |
68 | // Allow empty input or valid decimal format with max 2 decimal places
69 | if (newValue === "" || /^-?\d*\.?\d{0,2}$/.test(newValue)) {
70 | setInputValue(newValue);
71 | debouncedOnChange(newValue);
72 | }
73 | };
74 |
75 | const handleBlur = () => {
76 | if (inputValue === "") {
77 | onChange?.(new Decimal(0));
78 | return;
79 | }
80 |
81 | try {
82 | const normalizedValue = inputValue.replace(/,/g, ".");
83 | const decimalValue = new Decimal(normalizedValue);
84 | onChange?.(decimalValue);
85 | setInputValue(decimalValue.toString());
86 | } catch (error) {
87 | console.error("Invalid decimal value:", error);
88 | setInputValue(value?.toString() || "");
89 | }
90 | };
91 |
92 | return (
93 |
96 | $
97 |
107 |
108 | );
109 | };
110 |
--------------------------------------------------------------------------------
/src/app/app/InputText.tsx:
--------------------------------------------------------------------------------
1 | import { InputHTMLAttributes } from "react";
2 |
3 | type InputTextProps = InputHTMLAttributes & {
4 | className?: string;
5 | };
6 |
7 | export const InputText = ({ className = "", ...props }: InputTextProps) => {
8 | return (
9 |
12 |
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/src/app/app/layout.tsx:
--------------------------------------------------------------------------------
1 | export default function AppLayout({ children }: { children: React.ReactNode }) {
2 | return (
3 |
4 | {children}
5 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/app/loading.tsx:
--------------------------------------------------------------------------------
1 | export default function Loading() {
2 | return ;
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useRouter, useSearchParams } from "next/navigation";
3 | import { useForm } from "react-hook-form";
4 | import { parseAsStringLiteral, useQueryState } from "nuqs";
5 | import { useEffect } from "react";
6 | import { ReceiptItems } from "./subpages/ReceiptItems";
7 | import { BillForm } from "./types";
8 | import { PeopleAndSplit } from "./subpages/PeopleAndSplit";
9 | import { SplitSummary } from "./subpages/SplitSummary";
10 | import { UploadOrManualBill } from "./subpages/UploadOrManualBill";
11 |
12 | const viewOptions = ["intro", "items", "split", "splitSummary"] as const;
13 |
14 | export default function AppPage() {
15 | const router = useRouter();
16 | const searchParams = useSearchParams();
17 | const mode = searchParams.get("mode");
18 | const isManual = mode === "manual";
19 |
20 | const [view, setView] = useQueryState(
21 | "view",
22 | parseAsStringLiteral(viewOptions)
23 | );
24 |
25 | const formObject = useForm({
26 | defaultValues: {
27 | billItems: [],
28 | people: [],
29 | },
30 | });
31 |
32 | const { watch, setValue } = formObject;
33 |
34 | // Load saved form data from localStorage on component mount
35 | useEffect(() => {
36 | const savedFormData = localStorage.getItem("billFormData");
37 | if (savedFormData) {
38 | const parsedData = JSON.parse(savedFormData);
39 | Object.entries(parsedData).forEach(([key, value]) => {
40 | setValue(key as keyof BillForm, value as BillForm[keyof BillForm]);
41 | });
42 | }
43 | }, [setValue]);
44 |
45 | // Save form data to localStorage whenever it changes
46 | const formData = watch();
47 | useEffect(() => {
48 | localStorage.setItem("billFormData", JSON.stringify(formData));
49 | }, [formData]);
50 |
51 | if (view === "items") {
52 | return (
53 | setView("intro")}
56 | goForward={() => setView("split")}
57 | />
58 | );
59 | }
60 |
61 | if (view === "split") {
62 | return (
63 | setView("items")}
66 | goForward={() => setView("splitSummary")}
67 | />
68 | );
69 | }
70 |
71 | if (view === "splitSummary") {
72 | return (
73 | setView("split")} formObject={formObject} />
74 | );
75 | }
76 |
77 | return (
78 | router.back()}
81 | goForward={() => setView("items")}
82 | formObject={formObject}
83 | />
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/src/app/app/subpages/PeopleAndSplit.tsx:
--------------------------------------------------------------------------------
1 | import SubPageHeader from "@/components/SubPageHeader";
2 | import { Button } from "@/components/ui/button";
3 | import { UseFormReturn, useFieldArray } from "react-hook-form";
4 | import { BillForm } from "../types";
5 | import { InputText } from "../InputText";
6 | import { useMemo } from "react";
7 | import { createId } from "../utils";
8 |
9 | const TinyButton = ({
10 | isActive,
11 | onClick,
12 | children,
13 | className,
14 | }: {
15 | isActive?: boolean;
16 | onClick: () => void;
17 | children?: React.ReactNode;
18 | className?: string;
19 | }) => {
20 | return (
21 |
35 | );
36 | };
37 |
38 | export const PeopleAndSplit = ({
39 | goBack,
40 | goForward,
41 | formObject,
42 | }: {
43 | goBack: () => void;
44 | goForward: () => void;
45 | formObject: UseFormReturn;
46 | }) => {
47 | const {
48 | fields: people,
49 | append,
50 | remove,
51 | } = useFieldArray({
52 | control: formObject.control,
53 | name: "people",
54 | keyName: "_id",
55 | });
56 |
57 | const { fields: products, update: updateProduct } = useFieldArray({
58 | control: formObject.control,
59 | name: "billItems",
60 | keyName: "_id",
61 | });
62 |
63 | const handleAddPerson = () => {
64 | append({ name: "", id: createId() });
65 | };
66 |
67 | const isDisabled = useMemo(() => {
68 | const people = formObject.watch("people") || [];
69 | const products = formObject.watch("billItems") || [];
70 | const splitEvenly = formObject.watch("splitEvenly");
71 |
72 | if (people.length === 0 || people.some((field) => field.name === "")) {
73 | return true;
74 | }
75 |
76 | if (splitEvenly) {
77 | return false;
78 | }
79 |
80 | return products.some((product) => !product.assignedTo?.length);
81 | }, [
82 | formObject.watch("people"),
83 | formObject.watch("billItems"),
84 | formObject.watch("splitEvenly"),
85 | ]);
86 |
87 | const splitEvenly = formObject.watch("splitEvenly");
88 |
89 | const handleSplitEvenlyToggle = () => {
90 | if (splitEvenly) {
91 | formObject.setValue(
92 | "billItems",
93 | products.map((product) => ({
94 | ...product,
95 | assignedTo: [],
96 | }))
97 | );
98 | }
99 | formObject.setValue("splitEvenly", !splitEvenly);
100 | };
101 |
102 | return (
103 | <>
104 | goBack()}
108 | />
109 |
110 | {people.map((person, index) => (
111 |
115 |
120 |
121 |
129 |
130 | ))}
131 |
141 |
142 |
143 |
144 |
145 | Assign Items
146 |
147 |
152 | Split evenly
153 |
154 |
155 |
156 | {products?.map((product, productIndex) => {
157 | return (
158 |
159 |
160 |
161 |
{product.name}
162 |
163 | {people.map((person, personIndex) => {
164 | const personName = formObject.watch(
165 | `people.${personIndex}.name`
166 | );
167 | return (
168 | {
172 | const currentAssigned = product.assignedTo || [];
173 | const isAssigned = currentAssigned.includes(
174 | person.id
175 | );
176 | if (!isAssigned) {
177 | formObject.setValue("splitEvenly", false);
178 | }
179 | // If the person is already assigned, remove them from the assignedTo array
180 | // Otherwise, add them to the assignedTo array
181 | const newAssigned = isAssigned
182 | ? currentAssigned.filter(
183 | (id) => id !== person.id
184 | )
185 | : [...currentAssigned, person.id];
186 |
187 | console.log(
188 | "isAssigned",
189 | isAssigned,
190 | newAssigned
191 | );
192 | updateProduct(productIndex, {
193 | ...product,
194 | assignedTo: newAssigned,
195 | });
196 | }}
197 | className="rounded-lg"
198 | >
199 | {personName}
200 |
201 | );
202 | })}
203 |
204 |
205 |
206 | $
207 |
208 | {product.price.toString()}
209 |
210 |
211 |
212 |
213 | );
214 | })}
215 |
216 |
217 |
220 | >
221 | );
222 | };
223 |
--------------------------------------------------------------------------------
/src/app/app/subpages/ReceiptItems.tsx:
--------------------------------------------------------------------------------
1 | import SubPageHeader from "@/components/SubPageHeader";
2 | import { Button } from "@/components/ui/button";
3 | import { UseFormReturn, useFieldArray } from "react-hook-form";
4 | import { BillForm } from "../types";
5 | import { InputPrice } from "../InputPrice";
6 | import { useMemo } from "react";
7 | import { InputText } from "../InputText";
8 | import { createId, getTotal } from "../utils";
9 | import Decimal from "decimal.js";
10 |
11 | export const ReceiptItems = ({
12 | goBack,
13 | goForward,
14 | formObject,
15 | }: {
16 | goBack: () => void;
17 | goForward: () => void;
18 | formObject: UseFormReturn;
19 | }) => {
20 | const { fields, append, remove } = useFieldArray({
21 | control: formObject.control,
22 | name: "billItems",
23 | keyName: "_id",
24 | });
25 |
26 | const handleAddItem = () => {
27 | append({ name: "", price: new Decimal(0), id: createId() });
28 | };
29 |
30 | const total = useMemo(() => {
31 | return getTotal(formObject.watch());
32 | }, [formObject.watch()]);
33 |
34 | const isDisabled = useMemo(() => {
35 | const products = formObject.watch("billItems") || [];
36 | return (
37 | products.length === 0 ||
38 | products.some((field) => field.name === "") ||
39 | total.equals(0)
40 | );
41 | }, [formObject.watch("billItems"), total]);
42 |
43 | const tip = useMemo(() => {
44 | return formObject.watch("tip");
45 | }, [formObject.watch("tip")]);
46 |
47 | const tax = useMemo(() => {
48 | return formObject.watch("tax");
49 | }, [formObject.watch("tax")]);
50 |
51 | return (
52 | <>
53 | goBack()}
57 | />
58 |
59 | {fields.map((field, index) => (
60 |
64 |
68 |
{
71 | formObject.setValue(`billItems.${index}.price`, value);
72 | }}
73 | />
74 |
80 |
81 | ))}
82 |
91 |
92 |
93 |
94 |
Tip:
95 |
formObject.setValue("tip", value)}
98 | className="w-full"
99 | placeholder="0.00"
100 | />
101 |
102 |
103 |
Tax:
104 |
formObject.setValue("tax", value)}
107 | className="w-full"
108 | placeholder="0.00"
109 | />
110 |
111 |
112 |
113 |
114 | Total: $
115 |
116 |
117 | {total.toFixed(2)}
118 |
119 |
120 |
121 |
124 | >
125 | );
126 | };
127 |
--------------------------------------------------------------------------------
/src/app/app/subpages/SplitSummary.tsx:
--------------------------------------------------------------------------------
1 | import SubPageHeader from "@/components/SubPageHeader";
2 | import { Button } from "@/components/ui/button";
3 | import { UseFormReturn, useFieldArray } from "react-hook-form";
4 | import { BillForm } from "../types";
5 | import Link from "next/link";
6 | import { useEffect, useMemo, useState } from "react";
7 | import { getTotal } from "../utils";
8 | import Decimal from "decimal.js";
9 | import Confetti from "react-confetti-boom";
10 |
11 | export const SplitSummary = ({
12 | goBack,
13 | formObject,
14 | }: {
15 | goBack: () => void;
16 | formObject: UseFormReturn;
17 | }) => {
18 | const { fields } = useFieldArray({
19 | control: formObject.control,
20 | name: "people",
21 | keyName: "_id",
22 | });
23 |
24 | const isEvenly = useMemo(() => {
25 | return formObject.watch().splitEvenly;
26 | }, [formObject.watch()]);
27 |
28 | const total = getTotal(formObject.watch());
29 |
30 | const amountsForPeople = useMemo(() => {
31 | const people = formObject.watch().people || [];
32 |
33 | const amountOfPeople = people.length;
34 | // if we have 1 person, we want to give the total to them
35 | if (amountOfPeople === 1) {
36 | return [total];
37 | }
38 |
39 | if (isEvenly) {
40 | // if we have 2 people and we need to divide 15.15$ we want to give 7.57 to each person but
41 | // the remainder is 0.01 so we want to give 7.58 to the first person
42 | const amountForEachPerson = total
43 | .dividedBy(amountOfPeople)
44 | .toDecimalPlaces(2);
45 | const remainder = total.minus(amountForEachPerson.times(amountOfPeople));
46 |
47 | return people.map((_, index) => {
48 | // Add any remainder to the first person's amount
49 | return index === 0
50 | ? amountForEachPerson.plus(remainder)
51 | : amountForEachPerson;
52 | });
53 | }
54 |
55 | // Calculate each person's share of items they're assigned to
56 | const itemTotals = new Array(people.length).fill(new Decimal(0));
57 | const billItems = formObject.watch().billItems || [];
58 |
59 | billItems.forEach((item) => {
60 | const assignedPeople = item.assignedTo || [];
61 | if (assignedPeople.length > 0) {
62 | // Split item price equally among assigned people
63 | const pricePerPerson = item.price
64 | .dividedBy(assignedPeople.length)
65 | .toDecimalPlaces(2);
66 | const remainder = item.price.minus(
67 | pricePerPerson.times(assignedPeople.length)
68 | );
69 |
70 | assignedPeople.forEach((personId, index) => {
71 | const personIndex = people.findIndex((p) => p.id === personId);
72 | if (personIndex !== -1) {
73 | // Add remainder to first person's share
74 | itemTotals[personIndex] = itemTotals[personIndex].plus(
75 | index === 0 ? pricePerPerson.plus(remainder) : pricePerPerson
76 | );
77 | }
78 | });
79 | }
80 | });
81 |
82 | // Split tax and tip evenly
83 | const tax = formObject.watch().tax || new Decimal(0);
84 | const tip = formObject.watch().tip || new Decimal(0);
85 | const extraCharges = tax.plus(tip);
86 | const extraChargesPerPerson = extraCharges
87 | .dividedBy(people.length)
88 | .toDecimalPlaces(2);
89 | const extraChargesRemainder = extraCharges.minus(
90 | extraChargesPerPerson.times(people.length)
91 | );
92 |
93 | // Return final amounts with tax and tip included
94 | return itemTotals.map((amount, index) => {
95 | return amount.plus(
96 | index === 0
97 | ? extraChargesPerPerson.plus(extraChargesRemainder)
98 | : extraChargesPerPerson
99 | );
100 | });
101 | }, [formObject.watch()]);
102 |
103 | const [showConfetti, setShowConfetti] = useState(false);
104 |
105 | useEffect(() => {
106 | // Check if confetti has been shown in this session
107 | const hasShownConfetti = sessionStorage.getItem("hasShownConfetti");
108 | if (!hasShownConfetti) {
109 | setShowConfetti(true);
110 | sessionStorage.setItem("hasShownConfetti", "true");
111 | }
112 | }, []);
113 |
114 | return (
115 | <>
116 | {showConfetti && (
117 |
130 | )}
131 | goBack()}
135 | />
136 |
137 | {fields.map((field, index) => (
138 |
142 |
143 | {field.name}
144 |
145 |
146 |
147 | $
148 |
149 |
150 | {" "}
151 |
152 |
153 | {amountsForPeople.length > index
154 | ? amountsForPeople[index].toString()
155 | : "-"}
156 |
157 |
158 |
159 | ))}
160 |
161 |
201 |
202 |
221 |
222 | >
223 | );
224 | };
225 |
--------------------------------------------------------------------------------
/src/app/app/subpages/UploadOrManualBill.tsx:
--------------------------------------------------------------------------------
1 | import { DatePicker } from "@/components/DatePicker";
2 | import SubPageHeader from "@/components/SubPageHeader";
3 | import { Button } from "@/components/ui/button";
4 | import Link from "next/link";
5 | import { UseFormReturn } from "react-hook-form";
6 | import { BillForm } from "../types";
7 | import Dropzone from "react-dropzone";
8 | import { useEffect, useMemo, useState } from "react";
9 | import { useS3Upload } from "next-s3-upload";
10 | import { ExtractSchemaType } from "@/lib/scrapeBill";
11 | import { createId } from "../utils";
12 | import Decimal from "decimal.js";
13 |
14 | export const UploadOrManualBill = ({
15 | isManual,
16 | goBack,
17 | goForward,
18 | formObject,
19 | }: {
20 | isManual: boolean;
21 | goBack: () => void;
22 | goForward: () => void;
23 | formObject: UseFormReturn;
24 | }) => {
25 | const [file, setFile] = useState(null);
26 | const [isLoading, setIsLoading] = useState(false);
27 | const { uploadToS3 } = useS3Upload();
28 | const { register, watch } = formObject;
29 |
30 | useEffect(() => {
31 | const handleBeforeUnload = (e: BeforeUnloadEvent) => {
32 | if (isLoading) {
33 | e.preventDefault();
34 | e.returnValue = "";
35 | return "";
36 | }
37 | };
38 |
39 | window.addEventListener("beforeunload", handleBeforeUnload);
40 | return () => window.removeEventListener("beforeunload", handleBeforeUnload);
41 | }, [isLoading]);
42 |
43 | const isDisabled = useMemo(() => {
44 | return !file;
45 | }, [file]);
46 |
47 | const processBill = async () => {
48 | if (!file) return;
49 | setIsLoading(true);
50 | localStorage.removeItem("billFormData");
51 | try {
52 | const uploadedBill = await uploadToS3(file);
53 |
54 | uploadedBill.url;
55 |
56 | const response = await fetch("/api/vision", {
57 | method: "POST",
58 | body: JSON.stringify({
59 | billUrl: uploadedBill.url,
60 | }),
61 | headers: {
62 | "Content-Type": "application/json",
63 | },
64 | });
65 |
66 | const extractedData = (await response.json()) as ExtractSchemaType;
67 |
68 | formObject.setValue("businessName", extractedData.businessName);
69 | extractedData.date &&
70 | formObject.setValue("date", new Date(extractedData.date));
71 | formObject.setValue(
72 | "billItems",
73 | (extractedData?.billItems || []).map((item) => {
74 | return {
75 | id: createId(),
76 | name: item.name,
77 | price: new Decimal(item.price),
78 | assignedTo: [],
79 | };
80 | })
81 | );
82 | formObject.setValue("tax", new Decimal(extractedData?.tax || 0));
83 | formObject.setValue("tip", new Decimal(extractedData?.tip || 0));
84 |
85 | goForward();
86 | } catch (e) {
87 | // toast error couldn't process bill visually
88 | console.error("Error processing bill:", e);
89 | } finally {
90 | setIsLoading(false);
91 | }
92 | };
93 |
94 | if (isManual) {
95 | return (
96 | <>
97 |
102 |
103 |
136 |
139 | >
140 | );
141 | }
142 |
143 | return (
144 | <>
145 |
150 |
151 | {
157 | if (isLoading) return;
158 | const file = acceptedFiles[0];
159 | if (file.size > 15 * 1024 * 1024) {
160 | // 10MB in bytes
161 | // toast({
162 | // title: "📁 File Too Large",
163 | // description: "⚠️ File size must be less than 15MB",
164 | // });
165 | return;
166 | }
167 | setFile(file);
168 | }}
169 | >
170 | {({ getRootProps, getInputProps }) => (
171 |
176 |
177 | {file ? (
178 |
179 |
})
184 | {isLoading && (
185 |
186 |
187 |
188 |
189 |
190 |
191 | {
192 | [
193 | "Looking at receipt...",
194 | "Transcribing items...",
195 | "Checking tax and tips...",
196 | ][Math.floor((Date.now() / 2000) % 3)]
197 | }
198 |
199 |
200 |
201 |
202 | )}
203 | {!isLoading && (
204 |
225 | )}
226 |
227 | ) : (
228 |
229 |

234 |
235 |
236 | Take a photo
237 |
238 |
239 |
240 | or upload receipt
241 |
242 |
243 |
244 | )}
245 |
246 |
247 | )}
248 |
249 |
261 | >
262 | );
263 | };
264 |
--------------------------------------------------------------------------------
/src/app/app/types.ts:
--------------------------------------------------------------------------------
1 | type People = {
2 | id: string;
3 | name: string;
4 | };
5 |
6 | import Decimal from "decimal.js";
7 |
8 | type BillItem = {
9 | id: string;
10 | name: string;
11 | price: Decimal;
12 | assignedTo?: string[];
13 | };
14 |
15 | export type BillForm = {
16 | businessName?: string;
17 | date?: Date;
18 | billItems: BillItem[];
19 | subTotal?: Decimal;
20 | tax?: Decimal;
21 | tip?: Decimal;
22 | people: People[];
23 | splitEvenly?: boolean;
24 | };
25 |
--------------------------------------------------------------------------------
/src/app/app/utils.ts:
--------------------------------------------------------------------------------
1 | import { BillForm } from "./types";
2 | import Decimal from "decimal.js";
3 | import { nanoid } from "nanoid";
4 |
5 | export const getTotal = (bill: BillForm): Decimal => {
6 | let total = new Decimal(0);
7 | // sum all bill items + tip + tax
8 | const billItems = bill?.billItems || [];
9 | billItems.forEach((item) => {
10 | total = total.plus(item.price || 0);
11 | });
12 | const tip = bill.tip || new Decimal(0);
13 | const tax = bill.tax || new Decimal(0);
14 |
15 | const finalTotal = total.plus(tip).plus(tax);
16 |
17 | return finalTotal;
18 | };
19 |
20 | export const createId = () => {
21 | return nanoid(4);
22 | };
23 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nutlope/billsplit/d207a25004e84ba69c8b82a6455eda877f70d621/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @keyframes scan {
5 | 0% {
6 | top: 0;
7 | }
8 | 50% {
9 | top: 100%;
10 | }
11 | 100% {
12 | top: 0;
13 | }
14 | }
15 |
16 | .animate-scan-line {
17 | animation: scan 2s linear infinite;
18 | }
19 |
20 | @theme inline {
21 | --color-background: var(--background);
22 | --color-foreground: var(--foreground);
23 | --font-sans: var(--font-instrument-sans);
24 | --color-sidebar-ring: var(--sidebar-ring);
25 | --color-sidebar-border: var(--sidebar-border);
26 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
27 | --color-sidebar-accent: var(--sidebar-accent);
28 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
29 | --color-sidebar-primary: var(--sidebar-primary);
30 | --color-sidebar-foreground: var(--sidebar-foreground);
31 | --color-sidebar: var(--sidebar);
32 | --color-chart-5: var(--chart-5);
33 | --color-chart-4: var(--chart-4);
34 | --color-chart-3: var(--chart-3);
35 | --color-chart-2: var(--chart-2);
36 | --color-chart-1: var(--chart-1);
37 | --color-ring: var(--ring);
38 | --color-input: var(--input);
39 | --color-border: var(--border);
40 | --color-destructive: var(--destructive);
41 | --color-accent-foreground: var(--accent-foreground);
42 | --color-accent: var(--accent);
43 | --color-muted-foreground: var(--muted-foreground);
44 | --color-muted: var(--muted);
45 | --color-secondary-foreground: var(--secondary-foreground);
46 | --color-secondary: var(--secondary);
47 | --color-primary-foreground: var(--primary-foreground);
48 | --color-primary: var(--primary);
49 | --color-popover-foreground: var(--popover-foreground);
50 | --color-popover: var(--popover);
51 | --color-card-foreground: var(--card-foreground);
52 | --color-card: var(--card);
53 | --radius-sm: calc(var(--radius) - 4px);
54 | --radius-md: calc(var(--radius) - 2px);
55 | --radius-lg: var(--radius);
56 | --radius-xl: calc(var(--radius) + 4px);
57 | }
58 |
59 | :root {
60 | --radius: 0.625rem;
61 | --background: oklch(1 0 0);
62 | --foreground: oklch(0.145 0 0);
63 | --card: oklch(1 0 0);
64 | --card-foreground: oklch(0.145 0 0);
65 | --popover: oklch(1 0 0);
66 | --popover-foreground: oklch(0.145 0 0);
67 | --primary: oklch(0.205 0 0);
68 | --primary-foreground: oklch(0.985 0 0);
69 | --secondary: oklch(0.97 0 0);
70 | --secondary-foreground: oklch(0.205 0 0);
71 | --muted: oklch(0.97 0 0);
72 | --muted-foreground: oklch(0.556 0 0);
73 | --accent: oklch(0.97 0 0);
74 | --accent-foreground: oklch(0.205 0 0);
75 | --destructive: oklch(0.577 0.245 27.325);
76 | --border: oklch(0.922 0 0);
77 | --input: oklch(0.922 0 0);
78 | --ring: oklch(0.708 0 0);
79 | --chart-1: oklch(0.646 0.222 41.116);
80 | --chart-2: oklch(0.6 0.118 184.704);
81 | --chart-3: oklch(0.398 0.07 227.392);
82 | --chart-4: oklch(0.828 0.189 84.429);
83 | --chart-5: oklch(0.769 0.188 70.08);
84 | --sidebar: oklch(0.985 0 0);
85 | --sidebar-foreground: oklch(0.145 0 0);
86 | --sidebar-primary: oklch(0.205 0 0);
87 | --sidebar-primary-foreground: oklch(0.985 0 0);
88 | --sidebar-accent: oklch(0.97 0 0);
89 | --sidebar-accent-foreground: oklch(0.205 0 0);
90 | --sidebar-border: oklch(0.922 0 0);
91 | --sidebar-ring: oklch(0.708 0 0);
92 | }
93 |
94 | .dark {
95 | --background: oklch(0.145 0 0);
96 | --foreground: oklch(0.985 0 0);
97 | --card: oklch(0.205 0 0);
98 | --card-foreground: oklch(0.985 0 0);
99 | --popover: oklch(0.205 0 0);
100 | --popover-foreground: oklch(0.985 0 0);
101 | --primary: oklch(0.922 0 0);
102 | --primary-foreground: oklch(0.205 0 0);
103 | --secondary: oklch(0.269 0 0);
104 | --secondary-foreground: oklch(0.985 0 0);
105 | --muted: oklch(0.269 0 0);
106 | --muted-foreground: oklch(0.708 0 0);
107 | --accent: oklch(0.269 0 0);
108 | --accent-foreground: oklch(0.985 0 0);
109 | --destructive: oklch(0.704 0.191 22.216);
110 | --border: oklch(1 0 0 / 10%);
111 | --input: oklch(1 0 0 / 15%);
112 | --ring: oklch(0.556 0 0);
113 | --chart-1: oklch(0.488 0.243 264.376);
114 | --chart-2: oklch(0.696 0.17 162.48);
115 | --chart-3: oklch(0.769 0.188 70.08);
116 | --chart-4: oklch(0.627 0.265 303.9);
117 | --chart-5: oklch(0.645 0.246 16.439);
118 | --sidebar: oklch(0.205 0 0);
119 | --sidebar-foreground: oklch(0.985 0 0);
120 | --sidebar-primary: oklch(0.488 0.243 264.376);
121 | --sidebar-primary-foreground: oklch(0.985 0 0);
122 | --sidebar-accent: oklch(0.269 0 0);
123 | --sidebar-accent-foreground: oklch(0.985 0 0);
124 | --sidebar-border: oklch(1 0 0 / 10%);
125 | --sidebar-ring: oklch(0.556 0 0);
126 | }
127 |
128 | @layer base {
129 | * {
130 | @apply border-border outline-ring/50;
131 | }
132 | body {
133 | @apply bg-background text-foreground;
134 | }
135 | }
136 |
137 | @layer utilities {
138 | .hide-date-icon::-webkit-calendar-picker-indicator {
139 | display: none;
140 | -webkit-appearance: none;
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Instrument_Sans } from "next/font/google";
3 | import "./globals.css";
4 | import Header from "@/components/Header";
5 | import Footer from "@/components/Footer";
6 | import { NuqsAdapter } from "nuqs/adapters/next/app";
7 | import PlausibleProvider from "next-plausible";
8 | const instrumentSans = Instrument_Sans({
9 | variable: "--font-instrument-sans",
10 | subsets: ["latin"],
11 | });
12 |
13 | export const metadata: Metadata = {
14 | title: "BillSplit - Scan. Tap. Split.",
15 | description:
16 | "Snap the receipt, tap your items, see who owes what. No sign-ups, no math, no drama.",
17 | };
18 |
19 | export default function RootLayout({
20 | children,
21 | }: Readonly<{
22 | children: React.ReactNode;
23 | }>) {
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | {children}
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next";
2 | import ClearStorageLink from "@/components/ClearStorageLink";
3 |
4 | export const metadata: Metadata = {
5 | title: "BillSplit - Split your bill easily with AI",
6 | description:
7 | "Scan. Tap. Split. Snap the receipt, tap your items, see who owes what. No sign-ups, no math, no drama.",
8 | openGraph: {
9 | images: "https://usebillsplit.com/og.png",
10 | },
11 | };
12 |
13 | export default function Home() {
14 | return (
15 | <>
16 |
17 |
18 |

23 |
24 |
25 |
26 | Scan. Tap. Split.
27 |
28 |
29 | Snap the receipt, tap your items, see who owes what. No sign-ups,
30 | no math, no drama.
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | Scan Receipt
39 |
40 |
41 |
42 | Enter Manually
43 |
44 |
45 |
46 |
47 | >
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/ClearStorageLink.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 | import { Button } from "@/components/ui/button";
5 | import { ReactNode } from "react";
6 |
7 | interface ClearStorageLinkProps {
8 | href: string;
9 | children: ReactNode;
10 | variant?: "primary" | "secondary";
11 | }
12 |
13 | export default function ClearStorageLink({
14 | href,
15 | children,
16 | variant = "primary",
17 | }: ClearStorageLinkProps) {
18 | const handleClick = () => {
19 | localStorage.removeItem("billFormData");
20 | };
21 |
22 | return (
23 |
24 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/DatePicker.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { format } from "date-fns";
5 | import { Calendar as CalendarIcon } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 | import { Button } from "@/components/ui/button";
9 | import { Calendar } from "@/components/ui/calendar";
10 | import {
11 | Popover,
12 | PopoverContent,
13 | PopoverTrigger,
14 | } from "@/components/ui/popover";
15 |
16 | export function DatePicker({
17 | date,
18 | onDateChange,
19 | }: {
20 | date: Date | undefined;
21 | onDateChange: (date: Date | undefined) => void;
22 | }) {
23 | const [open, setOpen] = React.useState(false);
24 |
25 | const handleSelect = (selectedDate: Date | undefined) => {
26 | onDateChange(selectedDate);
27 | setOpen(false);
28 | };
29 |
30 | return (
31 |
32 |
33 |
43 |
44 |
45 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | export default function Footer() {
2 | return (
3 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | export default function Header() {
4 | return (
5 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/SubPageHeader.tsx:
--------------------------------------------------------------------------------
1 | export default function SubPageHeader({
2 | title,
3 | description,
4 | onBack,
5 | }: {
6 | title: string;
7 | description?: string;
8 | onBack?: () => void;
9 | }) {
10 | return (
11 |
12 |
{
14 | onBack?.();
15 | }}
16 | className="cursor-pointer flex items-center gap-2 text-sm text-[#4a5565] hover:text-[#1e2939]"
17 | >
18 |
33 | Back
34 |
35 |
{title}
36 | {description && (
37 |
{description}
38 | )}
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus:outline-none focus:ring-2 focus:ring-[#d04f17] focus:border-transparent px-3 py-2.5 rounded-lg text-base font-semibold border cursor-pointer",
9 | {
10 | variants: {
11 | variant: {
12 | primary: "bg-[#d04f17] text-white",
13 | secondary: "bg-[#fff9f6] text-[#364153] border-[#d1d5dc]",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "primary",
18 | },
19 | }
20 | );
21 |
22 | function Button({
23 | className,
24 | variant,
25 | asChild = false,
26 | ...props
27 | }: React.ComponentProps<"button"> &
28 | VariantProps & {
29 | asChild?: boolean;
30 | }) {
31 | const Comp = asChild ? Slot : "button";
32 |
33 | return (
34 |
39 | );
40 | }
41 |
42 | export { Button, buttonVariants };
43 |
--------------------------------------------------------------------------------
/src/components/ui/calendar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ChevronLeft, ChevronRight } from "lucide-react";
5 | import { DayPicker } from "react-day-picker";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | function Calendar({
10 | className,
11 | classNames,
12 | showOutsideDays = true,
13 | ...props
14 | }: React.ComponentProps) {
15 | return (
16 | .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
39 | : "[&:has([aria-selected])]:rounded-md"
40 | ),
41 | day: cn("size-8 p-0 font-normal aria-selected:opacity-100"),
42 | day_range_start:
43 | "day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
44 | day_range_end:
45 | "day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
46 | day_selected:
47 | "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
48 | day_today: "bg-accent text-accent-foreground",
49 | day_outside:
50 | "day-outside text-muted-foreground aria-selected:text-muted-foreground",
51 | day_disabled: "text-muted-foreground opacity-50",
52 | day_range_middle:
53 | "aria-selected:bg-accent aria-selected:text-accent-foreground",
54 | day_hidden: "invisible",
55 | ...classNames,
56 | }}
57 | components={{
58 | IconLeft: ({ className, ...props }) => (
59 |
60 | ),
61 | IconRight: ({ className, ...props }) => (
62 |
63 | ),
64 | }}
65 | {...props}
66 | />
67 | );
68 | }
69 |
70 | export { Calendar };
71 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Popover({
9 | ...props
10 | }: React.ComponentProps) {
11 | return
12 | }
13 |
14 | function PopoverTrigger({
15 | ...props
16 | }: React.ComponentProps) {
17 | return
18 | }
19 |
20 | function PopoverContent({
21 | className,
22 | align = "center",
23 | sideOffset = 4,
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
28 |
38 |
39 | )
40 | }
41 |
42 | function PopoverAnchor({
43 | ...props
44 | }: React.ComponentProps) {
45 | return
46 | }
47 |
48 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
49 |
--------------------------------------------------------------------------------
/src/lib/clients.ts:
--------------------------------------------------------------------------------
1 | import Together from "together-ai";
2 |
3 | const options: ConstructorParameters[0] = {
4 | apiKey: process.env.TOGETHER_API_KEY,
5 | };
6 |
7 | if (process.env.HELICONE_API_KEY) {
8 | options.baseURL = "https://together.helicone.ai/v1";
9 | options.defaultHeaders = {
10 | "Helicone-Auth": `Bearer ${process.env.HELICONE_API_KEY}`,
11 | "Helicone-Property-Appname": "billsplit",
12 | };
13 | }
14 |
15 | export const togetherBaseClient = new Together(options);
16 |
--------------------------------------------------------------------------------
/src/lib/scrapeBill.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { togetherBaseClient } from "./clients";
3 | import zodToJsonSchema from "zod-to-json-schema";
4 | import dedent from "dedent";
5 |
6 | export const extractSchema = z.object({
7 | businessName: z
8 | .string()
9 | .optional()
10 | .describe("Name of the business where the bill was created"),
11 | date: z.string().optional().describe("Date when the bill was created"),
12 | billItems: z
13 | .array(
14 | z.object({
15 | name: z.string().describe("Name of the item"),
16 | price: z.number().describe("Price of the item in decimal format"),
17 | })
18 | )
19 | .describe("List of items in the bill"),
20 | tax: z
21 | .number()
22 | .optional()
23 | .describe("Tax amount, not percentage we need money amount"),
24 | tip: z
25 | .number()
26 | .optional()
27 | .describe(
28 | "Tip or Gratuity amount, not percentage we need money amount and if multiple tips are shown just output the medium one"
29 | ),
30 | });
31 |
32 | export type ExtractSchemaType = z.infer;
33 |
34 | const systemPrompt = dedent`
35 | You are an expert at extracting information from receipts.
36 |
37 | Your task:
38 | 1. Analyze the receipt image provided
39 | 2. Extract all relevant billing information
40 | 3. Format the data in a structured way
41 |
42 | Guidelines for extraction:
43 | - Identify the restaurant/business name and location if available otherwise just return null
44 | - Find the receipt date or return null, date format should be YYYY-MM-DD but if day it's less than 10 don't add a 0 in front
45 | - Extract each item with its name and total price
46 | - Capture tax amount, if applicable and not percentage but the money amount otherwise return null
47 | - Identify any tips or gratuities, if multiple tips are shown just output the medium one otherwise return null
48 | - Ensure all numerical values are accurate
49 | - Convert all prices to decimal numbers
50 |
51 | IMPORTANT: Extract ONLY the information visible in the receipt. Do not make assumptions about missing data.
52 | `;
53 |
54 | export async function scrapeBill({
55 | billUrl,
56 | model = "meta-llama/Llama-4-Scout-17B-16E-Instruct",
57 | }: {
58 | billUrl: string;
59 | model?: string;
60 | }): Promise {
61 | const jsonSchema = zodToJsonSchema(extractSchema, {
62 | target: "openAi",
63 | });
64 |
65 | const extract = await togetherBaseClient.chat.completions.create({
66 | model: model,
67 | messages: [
68 | {
69 | role: "user",
70 | content: [
71 | { type: "text", text: systemPrompt },
72 | {
73 | type: "image_url",
74 | image_url: {
75 | url: billUrl,
76 | },
77 | },
78 | ],
79 | },
80 | ],
81 | response_format: { type: "json_object", schema: jsonSchema },
82 | });
83 |
84 | if (extract?.choices?.[0]?.message?.content) {
85 | const output = JSON.parse(extract.choices[0].message.content);
86 | return output;
87 | }
88 | throw new Error("No content returned from Llama 4 vision");
89 | }
90 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vitest/config";
2 |
3 | export default defineConfig({
4 | test: {
5 | globals: true,
6 | environment: "node",
7 | include: ["**/*.eval.ts"],
8 | setupFiles: ["dotenv/config"],
9 | },
10 | });
11 |
--------------------------------------------------------------------------------