├── .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 | Self 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 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/camera.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nutlope/billsplit/d207a25004e84ba69c8b82a6455eda877f70d621/public/camera.png -------------------------------------------------------------------------------- /public/camera.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nutlope/billsplit/d207a25004e84ba69c8b82a6455eda877f70d621/public/logo.png -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nutlope/billsplit/d207a25004e84ba69c8b82a6455eda877f70d621/public/og.png -------------------------------------------------------------------------------- /public/together.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/trash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 |
104 |
105 | 111 | 118 |
119 |
120 | 126 | { 129 | register("date").onChange({ 130 | target: { value: date, name: "date" }, 131 | }); 132 | }} 133 | /> 134 |
135 |
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 | Receipt preview 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 | Camera icon 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 | Main Logo 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 |
6 | 7 | Logo 8 |

9 | 10 | Bill 11 | 12 | 13 | Split 14 | 15 |

16 | 17 | 18 |
19 | {/* */} 29 | 35 | GitHub Logo 40 | 41 |

42 | Star on GitHub 43 |

44 |
45 |
46 |
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 | 25 | 32 | 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 | --------------------------------------------------------------------------------