Skip to content
pihujhaPublic

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

CardPerks

CardPerks

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.

The ML pipeline

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.

Why fine-tune instead of calling an API

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.

Serving

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.

Teaching it from real usage

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.

The app

  • 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

Privacy

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.

Tech stack

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.

Supported cards

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.

Project structure

/
├── 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

Running locally

Requires Node 18+ and a Neon database (free tier is fine).

git clone https://github.com/pihujha/CardPerks
cd CardPerks
npm install

Create .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 cron

Run the migrations:

for f in supabase/migrations/*.sql; do psql "$DATABASE_URL" -f "$f"; done

Start 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 dev

Training the adapters yourself

cd cardperks-llm
python generate_data.py          # → data/transactions.csv
python generate_parse_data.py    # → data/parse_train.csv

Then run colab_train.ipynb on a T4. Note that T4 is Turing, so fp16=True — bf16 needs Ampere or newer and will fail.

Database schema

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.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages