Every perk. Claimed.
Track credit card benefits, and find out which card you should have swiped.
Live app → · Full build journal
Most people have no idea what their credit cards actually offer, and no idea how much they lose by putting the wrong card down at the till. CardPerks fixes both halves of that: it tracks the credits you haven't claimed yet, and it reads your bank statement to show you what a different card would have earned.
The statement reader is the interesting part — it runs on two LoRA adapters I fine-tuned myself, not a cloud LLM API.
Statement analysis is a two-stage pass over a PDF, both stages served by the same 1.5B base model with different adapters swapped in:
PDF text (raw, wildly bank-specific)
↓ /parse ← parser LoRA: structured extraction
[{ description, amount }]
↓ /categorize ← categorizer LoRA: 6-class classification
[{ description, amount, category }]
| Categorizer | Parser | |
|---|---|---|
| Job | "TRADER JOE S #041 SAN LUIS OBISPO CA" → groceries |
Raw statement line → MERCHANT|AMOUNT or SKIP |
| Training rows | 900 synthetic (720 / 180 split) | ~770 across 4 real statement formats + 170 negatives |
| Train time | ~15 min, T4 | ~20 min, T4 |
Base model: Qwen/Qwen2.5-1.5B-Instruct — small enough to fine-tune on a free Colab
T4 and to serve on a single T4 in production, instruction-tuned enough to hold a rigid
output format.
Adapter config — attention layers only, which is where nearly all the task-specific signal lives for classification:
LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["q_proj", "v_proj"],
bias="none", task_type=TaskType.CAUSAL_LM,
)Final training loss 0.34, down from 2.25.
A hosted LLM would have worked, but every categorization becomes a paid network round trip, and a statement is hundreds of them. A 1.5B model with a task-specific adapter beats a general model prompted zero-shot on this narrow a task, costs effectively nothing per call, and keeps statement text inside infrastructure I control.
The adapters live in a private HuggingFace repo and are baked into a
Modal image at build time, so cold starts don't re-download weights.
Inference is a @app.cls(gpu="T4") exposing /parse, /categorize, and /health as
FastAPI endpoints, with scaledown_window=300 to stay warm between requests.
Real-world cost so far: $0.19/day against $30/month of free credit.
Two details worth knowing if you read the code:
- Cold starts take ~30s, which is longer than a Vercel function will wait. Every LLM call
therefore has a short
AbortSignal.timeout()and a deterministic fallback behind it — a keyword ruleset for categorization, a regex parser for extraction. The app degrades instead of erroring. - Opening the Analysis page fires a warm-up ping at both endpoints so the container is usually already up by the time a file is dropped.
Every category in the results table is an editable dropdown. Corrections are written to
category_correction and become training data for the next adapter revision — the
synthetic training set was the bootstrap, real user corrections are the improvement loop.
- Benefits Hub — monthly, annual, one-time, and always-on credits across every card, each with a claim checkbox
- Statement Analysis — upload a PDF, get spend by category and a per-card comparison of what you'd have earned
- Insights — your cards ranked by annual benefit value per category
- My Cards — annual credits vs. annual fee, so you can see which cards actually pay for themselves
- Try as guest — full app, no signup
- Dark mode, responsive, keyboard-accessible
No card numbers, no bank connection, ever. Uploaded PDFs are parsed in memory and discarded — only merchant name, amount, and category are persisted. Statement text is sent only to the CardPerks inference endpoint, never to a third-party model provider.
| Layer | Technology |
|---|---|
| Frontend | React 18, TypeScript, Vite, Tailwind CSS v4 |
| Routing | React Router v7 |
| API | Vercel Edge Functions (+ Node runtime where a Node-only lib is needed) |
| Auth | Custom JWT (jose), HttpOnly cookies |
| Database | Neon serverless PostgreSQL |
| ML | Qwen2.5-1.5B-Instruct + 2× LoRA (PEFT), trained on Colab T4 |
| Inference | Modal serverless GPU (T4), FastAPI |
Two runtime notes: auth routes must run on the edge runtime because jose is ESM-only
and throws ERR_REQUIRE_ESM under Node CJS; PDF parsing must run on Node because
pdf-parse isn't edge-compatible.
US — 9 cards
| Card | Bank | Network |
|---|---|---|
| Chase Sapphire Preferred | Chase | Visa |
| Chase Sapphire Reserve | Chase | Visa |
| Chase Freedom Unlimited | Chase | Visa |
| Chase Freedom Flex | Chase | Mastercard |
| Amex Platinum | American Express | Amex |
| Amex Gold | American Express | Amex |
| Amex Blue Cash Everyday | American Express | Amex |
| Amex Blue Cash Preferred | American Express | Amex |
| Citi Double Cash | Citi | Mastercard |
India — 11 cards
| Card | Bank | Network |
|---|---|---|
| HDFC Infinia | HDFC Bank | Visa |
| HDFC Regalia | HDFC Bank | Visa |
| HDFC Millennia | HDFC Bank | Mastercard |
| HDFC Diners Club Black | HDFC Bank | Diners |
| Axis Atlas | Axis Bank | Visa |
| Flipkart Axis Bank | Axis Bank | Visa |
| SBI SimplyCLICK | SBI Card | Visa |
| ICICI Sapphiro | ICICI Bank | Visa |
| ICICI Amazon Pay | ICICI Bank | Visa |
| ICICI Coral | ICICI Bank | Visa |
| ICICI Rubyx | ICICI Bank | Mastercard |
INR and USD cards are ranked separately in Insights — comparing raw numbers across currencies is meaningless.
/
├── api/ # Vercel functions
│ ├── auth/ # sign-in, sign-up, sign-out, me, guest
│ ├── cron/ # scheduled cleanup of stale guest accounts
│ ├── analyses.ts # saved statement analyses
│ ├── analyze-statement.ts# categorize a transaction (LLM + keyword fallback)
│ ├── parse-pdf.ts # PDF → transactions (LLM + regex fallback)
│ ├── corrections.ts # user category corrections → training data
│ └── warmup.ts # pre-boots the Modal containers
├── cardperks-llm/ # ML pipeline
│ ├── generate_data.py # synthetic categorizer training set
│ ├── generate_parse_data.py
│ ├── train_lora.py # LoRA fine-tune
│ ├── colab_train.ipynb # T4 training notebook
│ └── infer_server.py # Modal GPU inference server
├── src/
│ ├── components/ # Nav, AuthShell, BenefitsDrawer, AddCardModal
│ ├── lib/ # auth context, apiFetch, shared links
│ └── pages/ # Landing, Dashboard, Cards, Insights, Analysis, SignIn, SignUp
└── supabase/migrations/ # numbered SQL migrations
Requires Node 18+ and a Neon database (free tier is fine).
git clone https://github.com/pihujha/CardPerks
cd CardPerks
npm installCreate .env.local:
DATABASE_URL=postgresql://... # Neon connection string
JWT_SECRET=your-secret-here
LLM_CATEGORIZE_URL=https://... # optional — falls back to keyword rules
LLM_PARSE_URL=https://... # optional — falls back to regex parsing
CRON_SECRET=another-secret # optional — guards the cleanup cronRun the migrations:
for f in supabase/migrations/*.sql; do psql "$DATABASE_URL" -f "$f"; doneStart the dev server. Use vercel dev rather than npm run dev — Vite alone serves the
frontend but not the /api routes, which it proxies to port 3000:
npx vercel devcd cardperks-llm
python generate_data.py # → data/transactions.csv
python generate_parse_data.py # → data/parse_train.csvThen run colab_train.ipynb on a T4. Note that T4 is Turing, so fp16=True — bf16
needs Ampere or newer and will fail.
| Table | Purpose |
|---|---|
user |
email, hashed password, name |
cards |
card catalog — name, bank, network, tier, annual_fee |
benefits |
per-card benefits: category, frequency, value, proof URL |
user_cards |
which cards a user has added |
benefit_usage |
claim log (period = YYYY-MM / YYYY / lifetime) |
statement_analyses |
saved analyses, transactions as JSONB |
category_correction |
user category corrections, for adapter retraining |
benefit_frequency: monthly, quarterly, annual, one-time, ongoing
benefit_type: credit, reward_rate, perk, insurance
Passive benefits like fuel surcharge waivers use frequency = 'ongoing' and live in their
own "Always On" tab rather than cluttering the monthly view.
MIT
