Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "frontend",
"runtimeExecutable": "bun",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}
2 changes: 0 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +0,0 @@
LLAMA_CLOUD_API_KEY=your_api_key_here
PORT=3001
92 changes: 23 additions & 69 deletions backend/bun.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,18 @@
"build": "tsc"
},
"dependencies": {
"@llamaindex/core": "^0.6.19",
"@types/cors": "^2.8.19",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"llama-cloud-services": "^0.5.3",
"multer": "^1.4.5-lts.1"
"multer": "^1.4.5-lts.1",
"pdf-parse": "^2.4.5"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.5",
"@types/pdf-parse": "^1.1.5",
"tsx": "^4.19.2",
"typescript": "^5.9.3"
}
Expand Down
103 changes: 58 additions & 45 deletions backend/server.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
/**
* Phone Bill Parser Backend API
* Extracts phone line details from AT&T PDF bills using LlamaExtract
* Extracts phone line details from AT&T PDF bills using pdf-parse + regex
*/

import express, { Request, Response } from "express";
import multer from "multer";
import cors from "cors";
import * as dotenv from "dotenv";
import { promises as fs } from "fs";
import { LlamaExtract } from "llama-cloud-services";
import { PDFParse } from "pdf-parse";
import path from "path";
import { fileURLToPath } from "url";

Expand Down Expand Up @@ -55,16 +55,6 @@ const frontendDistPath = path.join(__dirname, '../frontend/dist');
app.use(express.static(frontendDistPath));

// Types
interface PhoneLine {
number: string;
user: string;
total: number;
}

interface ExtractionResult {
service_activity_lines: PhoneLine[];
}

interface BillData {
total_amount: number;
line_count: number;
Expand All @@ -76,41 +66,71 @@ interface BillData {
}

/**
* Extract phone bill data using LlamaExtract agent
* Extract phone bill data from AT&T PDF using regex parsing.
*
* Strategy:
* 1. Pull full names from section headers — "Phone, XXX.XXX.XXXX\nFULL NAME"
* (the page 2 summary table truncates names like "NAVEEN KUMAR ...")
* 2. Pull totals from the unique "Total for XXX.XXX.XXXX $XX.XX" lines
* (appears exactly once per line, no duplicates possible)
* 3. Join on phone number
* 4. Sanity check: sum of line totals must match "Total for Wireless $X"
*/
async function extractPhoneBill(filePath: string): Promise<BillData> {
// Initialize LlamaExtract client (reads LLAMA_CLOUD_API_KEY from env)
const extractor = new LlamaExtract();
const buffer = await fs.readFile(filePath);
const parser = new PDFParse({ data: buffer });
const { text } = await parser.getText();

// Step 1: phone → full name
// Matches "Phone, 214.957.3190\nKODUMURI VAMSHI" and "Wearable, 945.214.5965\nAPPLE WATCH"
const nameMap = new Map<string, string>();
const headerRegex = /(?:Phone|Wearable),\s+(\d{3}\.\d{3}\.\d{4})\s*\n\s*([A-Z][A-Z ]+)/g;
let match: RegExpExecArray | null;
while ((match = headerRegex.exec(text)) !== null) {
nameMap.set(match[1], match[2].trim());
}

// Get the extraction agent by name
const agent = await extractor.getAgent("att bill extract");
if (!agent) {
throw new Error("Extraction agent 'att bill extract' not found");
// Step 2: phone → total
// Matches "Total for 214.957.3190 $76.58" — appears exactly once per line
const totalMap = new Map<string, number>();
const totalRegex = /Total for (\d{3}\.\d{3}\.\d{4})\s+\$([0-9,]+\.\d{2})/g;
while ((match = totalRegex.exec(text)) !== null) {
totalMap.set(match[1], parseFloat(match[2].replace(/,/g, '')));
}

// Run extraction
const result = await agent.extract(filePath);
if (totalMap.size === 0) {
throw new Error(
"No line totals found in the PDF. " +
"Make sure this is an AT&T wireless bill — other bill formats are not supported yet."
);
}

// Type guard to safely extract the data
const data = result?.data as unknown;
if (!data || typeof data !== 'object' || !('service_activity_lines' in data)) {
throw new Error("No service activity lines found in extraction result");
// Step 3: build line items, join name + total on phone number
const lines = Array.from(totalMap.entries()).map(([phone, amount]) => ({
phone_number: phone,
line_name: nameMap.get(phone) ?? "Unknown",
amount_owed: amount,
}));

// Step 4: sanity check — line totals must sum to the wireless section total
const lineSum = Math.round(lines.reduce((sum, l) => sum + l.amount_owed, 0) * 100) / 100;
const wirelessMatch = text.match(/Total for Wireless\s+\$([0-9,]+\.\d{2})/);
if (wirelessMatch) {
const wirelessTotal = parseFloat(wirelessMatch[1].replace(/,/g, ''));
if (Math.abs(lineSum - wirelessTotal) > 0.02) {
throw new Error(
`Parse validation failed: line totals sum ($${lineSum.toFixed(2)}) ` +
`does not match wireless total ($${wirelessTotal.toFixed(2)}). ` +
"The bill may have an unsupported charge type — please verify manually."
);
}
}

const extractedData = data as ExtractionResult;

// Transform to desired format
const billData: BillData = {
total_amount: extractedData.service_activity_lines.reduce((sum, line) => sum + line.total, 0),
line_count: extractedData.service_activity_lines.length,
lines: extractedData.service_activity_lines.map(line => ({
phone_number: line.number,
line_name: line.user,
amount_owed: line.total
}))
return {
total_amount: lineSum,
line_count: lines.length,
lines,
};

return billData;
}

// Health check endpoint
Expand All @@ -126,13 +146,6 @@ app.post("/api/extract", upload.single("file"), async (req: Request, res: Respon
let uploadedFilePath: string | undefined;

try {
// Validate API key
if (!process.env.LLAMA_CLOUD_API_KEY) {
return res.status(500).json({
error: "Server configuration error: LLAMA_CLOUD_API_KEY not set"
});
}

// Validate file upload
if (!req.file) {
return res.status(400).json({
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"tailwind-merge": "^3.4.0",
Expand Down
29 changes: 16 additions & 13 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { GroupsView } from '@/components/GroupsView';
import { parsePDF } from '@/utils/parsePDF';
import type { ParsedBill, UploadStatus } from '@/types/bill.types';
import { Button } from '@/components/ui/button';
import { Printer } from 'lucide-react';
import { DEMO_BILL, DEMO_GROUPS } from '@/utils/demoData';
import { useGroupStore } from '@/stores/groupStore';

Expand Down Expand Up @@ -84,15 +85,19 @@ function App() {
<main className="space-y-8">
{status === 'success' && parsedBill ? (
<>
{/* Action buttons */}
<div className="flex justify-center gap-4">
{/* Action buttons — hidden in print */}
<div className="flex justify-center gap-4 print:hidden">
<Button onClick={handleReset} variant="outline">
Upload Another Bill
</Button>
<Button onClick={() => window.print()} variant="outline" className="gap-2">
<Printer className="w-4 h-4" />
Export PDF
</Button>
</div>

{/* View toggle buttons */}
<div className="flex justify-center gap-2">
{/* View toggle buttons — hidden in print */}
<div className="flex justify-center gap-2 print:hidden">
<Button
onClick={() => setViewMode('table')}
variant={viewMode === 'table' ? 'default' : 'outline'}
Expand All @@ -109,6 +114,12 @@ function App() {
</Button>
</div>

{/* Print-only header: shown only when printing */}
<div className="hidden print:block text-center mb-4">
<h2 className="text-xl font-bold text-gray-900">AT&T Bill Summary</h2>
<p className="text-sm text-gray-500 mt-1">Generated by SplitBill</p>
</div>

{/* Conditional view rendering */}
{viewMode === 'table' ? (
<ResultsTable bill={parsedBill} />
Expand All @@ -126,7 +137,7 @@ function App() {
</main>
</div>

<footer className="border-t border-gray-200 bg-white mt-16">
<footer className="border-t border-gray-200 bg-white mt-16 print:hidden">
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
{/* Left: Copyright */}
Expand All @@ -147,14 +158,6 @@ function App() {
</div>
</footer>

{/* Floating Feedback Button */}
<button
onClick={() => window.open('https://github.com/venwork-dev/split-bill/issues/new?labels=feedback&template=feedback.md', '_blank')}
className="fixed bottom-6 right-6 bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-200 flex items-center gap-2 font-medium z-50"
>
<span>💬</span>
<span>Feedback</span>
</button>
</div>
);
}
Expand Down
Loading
Loading