Inspiration

People manage two to five credit cards and pick which one to use with a single crude heuristic — whichever has the highest cashback. That ignores utilization impact, statement/due-date float, and time-bound signup bonus deadlines. Rent is the largest predictable expense most people have, and Chexy lets you pay it by card — which makes it the single biggest lever in a payment strategy, but also the easiest one to mismanage.

We wanted an engine that decides not just which card to use but why, and that reasoning to visibly change when the user's goals do: keep utilization low ahead of a mortgage application this month, maximize travel rewards once that's done. Not a rewards calculator — a routing decision that understands the whole month, not just the next purchase.


What it does

CardIQ is two connected surfaces built on one deterministic core.

The CardIQ dashboard is the product: a portfolio of cards and recurring bills (rent, tuition, utilities, insurance, taxes) with a priority order you set by dragging bills up or down. Before every payment, CardIQ re-evaluates the best funding card given current balances, bonus progress, and utilization — so when rent completes a card's welcome bonus, the next bill correctly stops assuming it'll get that bonus too. An "off-optimal" figure shows the genuine cost of your priority ordering, not a phantom one. Payments run through an explicit state machine that survives declines, network timeouts, and double-clicks (an idempotency key returns the original transaction instead of charging twice), with scripted failure scenarios you can trigger to see the recovery paths.

The Optimizer page is the engine, exposed directly: type a goal — "I'm applying for a mortgage in 3 months so I need to keep utilization low, but I'd still like to hit my Amex bonus, and I pay $2,200 rent" — and CardIQ parses it into weights across six goals (cashback, travel, credit health, signup bonuses, cash flow, risk) plus hard constraints like a utilization ceiling until a date, shown as editable sliders and constraint chips. Plan my month runs an exact solver over a full month of purchases and returns a card-by-card allocation with structured decision cards: which factors won, the specific dollar gap to the runner-up, and which constraints were binding. Sampled strategies surfaces a handful of other non-dominated plans (max cashback vs. best credit health vs. balanced) so the tradeoff is visible instead of collapsed into one answer. What-if re-solves the whole month around one hypothetical change — move rent to a different card — and shows the live delta.


How we built it

The backend is Python 3.11 with FastAPI and SQLite. Every money field is Pydantic v2 StrictInt in integer cents or basis points with extra="forbid" — a float or a numeric string can't enter a financial calculation even by accident. The optimizer is a two-stage design: a largest-first greedy heuristic with bounded repair and local search runs first and is fast enough to always produce a plan, then an exact integer program (PuLP + CBC) proves optimality when it can. Every result carries an honest status — optimal, heuristic, heuristic_fallback, infeasible, or unresolved — instead of one blanket "here's your answer." CBC runs in an isolated worker process behind a 60-second watchdog, so a stuck solver can never hang the demo. A brute-force oracle test suite enumerates every possible assignment on small fixtures and checks the exact ILP against it.

Natural-language parsing is a small Qwen-based model fine-tuned on Freesolo's SFT platform, with a prompted Gemini call and a local fixture provider as fallback paths — if the trained model is unreachable or returns malformed JSON, the app falls back to a clearly labeled equal-weight default rather than guessing, and the money path never trusts raw model output.

The frontend was originally scoped as a Streamlit app; we ended up building a full Next.js 14 + TypeScript + Tailwind dashboard instead, because drag-to-reorder priorities, the payment lifecycle UI, and failure simulation needed more interactivity than a data-app framework comfortably gives you. All account data — balances, limits, transactions, bonus progress — is synthetic; the 8 underlying Canadian card products are real, paraphrased from official issuer pages with source URLs and a verification date. No real accounts, credentials, or money movement exist anywhere in the repo. 236 tests currently pass across the engine, intent, explain, API, data, oracle, and integration suites.


Challenges we faced

The hardest part was modeling things that are naturally non-linear — piecewise utilization penalties, all-or-nothing signup bonus completion — inside a linear integer program without leaky big-M tricks. We ended up enumerating the finite set of reachable spend states per card and giving each one a binary variable, scored by the exact same Python function the greedy heuristic and the explanation layer use, so the ILP and the rest of the app can never quietly disagree about what a number means.

A close second was resisting the urge to say "optimal" every time we had an answer. It was tempting to treat any output as the final word; instead we made the solver report what it actually proved — a greedy dead end and a proven-infeasible input are different claims, and blurring them is exactly the kind of overconfident statement a financial tool shouldn't make.

Getting the priority-ordering logic right on the product side was its own problem: making sure a one-time welcome bonus claimed by one bill wasn't silently re-credited to the next bill on the same card, and that the "how far off optimal is this ordering" figure reflected real lost value instead of double-counted savings.

And more mundanely — scope. The plan called for a lightweight Streamlit front end; we ended up needing a full interactive product to make the priority story land. Keeping that build moving in parallel with the language/eval track, and meeting cleanly at the Intent contract between them, was the actual time crunch of the weekend.


Accomplishments that we're proud of

A solver we can prove is right, not just trust: a brute-force oracle suite that enumerates every possible assignment on small cases and checks the exact ILP matches. 236 tests passing overall, including an AST-level check that fails the build outright if the language layer ever imports a scoring function, or the explanation layer ever imports the solver — so "the LLM never touches money math" is enforced in CI, not just claimed in the README.


What we learned

"Best card" isn't a single number — it depends on what the user is optimizing for this month, and that goal changes: the winning card before a mortgage application isn't the winning card once you just want travel points. We also learned that AI is most useful here as a translator of intent, not as a decision-maker. The deterministic solver is both the source of truth for the money math and a verifier for the model — we can check whether the model's predicted weights lead the solver to the same recommendation the correct weights would, which turns the eval into a measure of the thing that actually matters.


What's next for CardIQ

Real, read-only account integrations instead of a synthetic portfolio; multi-turn goal conversation so a user can refine a goal instead of retyping it; splitting a single purchase across multiple cards, which we explicitly scoped out of v1 to keep the solver tractable; a fuller frontier across all six goals instead of a bounded sweep over the top two or three; and, closer to Chexy's actual product, wiring the recommendation directly into a real rent-payment flow.


Built With

python · fastapi · pydantic · pulp · nextjs · typescript · react · tailwindcss · sqlite · pytest · gemini · freesolo · qwen

Built With

Share this project:

Updates