Skip to content

Commit 8564b37

Browse files
authored
feat: add bundle check CI (#10)
1 parent ea86888 commit 8564b37

14 files changed

Lines changed: 232 additions & 349 deletions

File tree

.claude/launch.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"version": "0.0.1",
3+
"configurations": [
4+
{
5+
"name": "frontend",
6+
"runtimeExecutable": "bun",
7+
"runtimeArgs": ["run", "dev"],
8+
"port": 5173
9+
}
10+
]
11+
}

backend/.env.example

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +0,0 @@
1-
LLAMA_CLOUD_API_KEY=your_api_key_here
2-
PORT=3001

backend/bun.lock

Lines changed: 23 additions & 69 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,18 @@
99
"build": "tsc"
1010
},
1111
"dependencies": {
12-
"@llamaindex/core": "^0.6.19",
1312
"@types/cors": "^2.8.19",
1413
"cors": "^2.8.5",
1514
"dotenv": "^16.4.5",
1615
"express": "^4.21.2",
17-
"llama-cloud-services": "^0.5.3",
18-
"multer": "^1.4.5-lts.1"
16+
"multer": "^1.4.5-lts.1",
17+
"pdf-parse": "^2.4.5"
1918
},
2019
"devDependencies": {
2120
"@types/express": "^5.0.0",
2221
"@types/multer": "^1.4.12",
2322
"@types/node": "^22.10.5",
23+
"@types/pdf-parse": "^1.1.5",
2424
"tsx": "^4.19.2",
2525
"typescript": "^5.9.3"
2626
}

backend/server.ts

Lines changed: 58 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
/**
22
* Phone Bill Parser Backend API
3-
* Extracts phone line details from AT&T PDF bills using LlamaExtract
3+
* Extracts phone line details from AT&T PDF bills using pdf-parse + regex
44
*/
55

66
import express, { Request, Response } from "express";
77
import multer from "multer";
88
import cors from "cors";
99
import * as dotenv from "dotenv";
1010
import { promises as fs } from "fs";
11-
import { LlamaExtract } from "llama-cloud-services";
11+
import { PDFParse } from "pdf-parse";
1212
import path from "path";
1313
import { fileURLToPath } from "url";
1414

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

5757
// Types
58-
interface PhoneLine {
59-
number: string;
60-
user: string;
61-
total: number;
62-
}
63-
64-
interface ExtractionResult {
65-
service_activity_lines: PhoneLine[];
66-
}
67-
6858
interface BillData {
6959
total_amount: number;
7060
line_count: number;
@@ -76,41 +66,71 @@ interface BillData {
7666
}
7767

7868
/**
79-
* Extract phone bill data using LlamaExtract agent
69+
* Extract phone bill data from AT&T PDF using regex parsing.
70+
*
71+
* Strategy:
72+
* 1. Pull full names from section headers — "Phone, XXX.XXX.XXXX\nFULL NAME"
73+
* (the page 2 summary table truncates names like "NAVEEN KUMAR ...")
74+
* 2. Pull totals from the unique "Total for XXX.XXX.XXXX $XX.XX" lines
75+
* (appears exactly once per line, no duplicates possible)
76+
* 3. Join on phone number
77+
* 4. Sanity check: sum of line totals must match "Total for Wireless $X"
8078
*/
8179
async function extractPhoneBill(filePath: string): Promise<BillData> {
82-
// Initialize LlamaExtract client (reads LLAMA_CLOUD_API_KEY from env)
83-
const extractor = new LlamaExtract();
80+
const buffer = await fs.readFile(filePath);
81+
const parser = new PDFParse({ data: buffer });
82+
const { text } = await parser.getText();
83+
84+
// Step 1: phone → full name
85+
// Matches "Phone, 214.957.3190\nKODUMURI VAMSHI" and "Wearable, 945.214.5965\nAPPLE WATCH"
86+
const nameMap = new Map<string, string>();
87+
const headerRegex = /(?:Phone|Wearable),\s+(\d{3}\.\d{3}\.\d{4})\s*\n\s*([A-Z][A-Z ]+)/g;
88+
let match: RegExpExecArray | null;
89+
while ((match = headerRegex.exec(text)) !== null) {
90+
nameMap.set(match[1], match[2].trim());
91+
}
8492

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

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

94-
// Type guard to safely extract the data
95-
const data = result?.data as unknown;
96-
if (!data || typeof data !== 'object' || !('service_activity_lines' in data)) {
97-
throw new Error("No service activity lines found in extraction result");
108+
// Step 3: build line items, join name + total on phone number
109+
const lines = Array.from(totalMap.entries()).map(([phone, amount]) => ({
110+
phone_number: phone,
111+
line_name: nameMap.get(phone) ?? "Unknown",
112+
amount_owed: amount,
113+
}));
114+
115+
// Step 4: sanity check — line totals must sum to the wireless section total
116+
const lineSum = Math.round(lines.reduce((sum, l) => sum + l.amount_owed, 0) * 100) / 100;
117+
const wirelessMatch = text.match(/Total for Wireless\s+\$([0-9,]+\.\d{2})/);
118+
if (wirelessMatch) {
119+
const wirelessTotal = parseFloat(wirelessMatch[1].replace(/,/g, ''));
120+
if (Math.abs(lineSum - wirelessTotal) > 0.02) {
121+
throw new Error(
122+
`Parse validation failed: line totals sum ($${lineSum.toFixed(2)}) ` +
123+
`does not match wireless total ($${wirelessTotal.toFixed(2)}). ` +
124+
"The bill may have an unsupported charge type — please verify manually."
125+
);
126+
}
98127
}
99128

100-
const extractedData = data as ExtractionResult;
101-
102-
// Transform to desired format
103-
const billData: BillData = {
104-
total_amount: extractedData.service_activity_lines.reduce((sum, line) => sum + line.total, 0),
105-
line_count: extractedData.service_activity_lines.length,
106-
lines: extractedData.service_activity_lines.map(line => ({
107-
phone_number: line.number,
108-
line_name: line.user,
109-
amount_owed: line.total
110-
}))
129+
return {
130+
total_amount: lineSum,
131+
line_count: lines.length,
132+
lines,
111133
};
112-
113-
return billData;
114134
}
115135

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

128148
try {
129-
// Validate API key
130-
if (!process.env.LLAMA_CLOUD_API_KEY) {
131-
return res.status(500).json({
132-
error: "Server configuration error: LLAMA_CLOUD_API_KEY not set"
133-
});
134-
}
135-
136149
// Validate file upload
137150
if (!req.file) {
138151
return res.status(400).json({

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"@radix-ui/react-slot": "^1.2.4",
1818
"class-variance-authority": "^0.7.1",
1919
"clsx": "^2.1.1",
20-
"lucide-react": "^0.562.0",
20+
"lucide-react": "^0.562.0",
2121
"react": "^19.2.3",
2222
"react-dom": "^19.2.3",
2323
"tailwind-merge": "^3.4.0",

frontend/src/App.tsx

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { GroupsView } from '@/components/GroupsView';
55
import { parsePDF } from '@/utils/parsePDF';
66
import type { ParsedBill, UploadStatus } from '@/types/bill.types';
77
import { Button } from '@/components/ui/button';
8+
import { Printer } from 'lucide-react';
89
import { DEMO_BILL, DEMO_GROUPS } from '@/utils/demoData';
910
import { useGroupStore } from '@/stores/groupStore';
1011

@@ -84,15 +85,19 @@ function App() {
8485
<main className="space-y-8">
8586
{status === 'success' && parsedBill ? (
8687
<>
87-
{/* Action buttons */}
88-
<div className="flex justify-center gap-4">
88+
{/* Action buttons — hidden in print */}
89+
<div className="flex justify-center gap-4 print:hidden">
8990
<Button onClick={handleReset} variant="outline">
9091
Upload Another Bill
9192
</Button>
93+
<Button onClick={() => window.print()} variant="outline" className="gap-2">
94+
<Printer className="w-4 h-4" />
95+
Export PDF
96+
</Button>
9297
</div>
9398

94-
{/* View toggle buttons */}
95-
<div className="flex justify-center gap-2">
99+
{/* View toggle buttons — hidden in print */}
100+
<div className="flex justify-center gap-2 print:hidden">
96101
<Button
97102
onClick={() => setViewMode('table')}
98103
variant={viewMode === 'table' ? 'default' : 'outline'}
@@ -109,6 +114,12 @@ function App() {
109114
</Button>
110115
</div>
111116

117+
{/* Print-only header: shown only when printing */}
118+
<div className="hidden print:block text-center mb-4">
119+
<h2 className="text-xl font-bold text-gray-900">AT&T Bill Summary</h2>
120+
<p className="text-sm text-gray-500 mt-1">Generated by SplitBill</p>
121+
</div>
122+
112123
{/* Conditional view rendering */}
113124
{viewMode === 'table' ? (
114125
<ResultsTable bill={parsedBill} />
@@ -126,7 +137,7 @@ function App() {
126137
</main>
127138
</div>
128139

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

150-
{/* Floating Feedback Button */}
151-
<button
152-
onClick={() => window.open('https://github.com/venwork-dev/split-bill/issues/new?labels=feedback&template=feedback.md', '_blank')}
153-
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"
154-
>
155-
<span>💬</span>
156-
<span>Feedback</span>
157-
</button>
158161
</div>
159162
);
160163
}

0 commit comments

Comments
 (0)