Two weeks. One idea. 410 credit cards. A working subscription app with AI built in.
This is the technical story of how I built Rewardly — a credit card rewards optimizer for Canadians — from zero to production, solo, in 14 days.
The Problem I Was Solving
Canadians have some of the best credit card rewards programs in the world. But the comparison tools are terrible. Most sites are US-first, affiliate-stuffed, and tell you nothing useful about your specific spending patterns.
The real question isn't "what's the best credit card?" It's: given how I actually spend money, which card — or combination of cards — maximizes my rewards?
That's a data problem. I knew I could build something better.
Tech Stack (Made in 10 Minutes)
- React Native + Expo — cross-platform from day one, web included
- Supabase — Postgres, auth, edge functions, RLS, all in one
- Stripe — payments, no thinking required
- Vercel — deploy on push
- Claude Haiku — the AI advisor ("Sage")
No custom backend. Just Supabase edge functions for anything touching money.
The Data Model
The hardest part was the data, not the code. 410+ Canadian credit cards, each with category reward rates, signup bonuses, annual fees, FX rates, and issuer-specific rules.
cards (id, name, issuer, annual_fee, fx_rate, ...)
category_rewards (card_id, category, reward_rate, reward_type)
signup_bonuses (card_id, bonus_points, min_spend, timeframe_days)
profiles (user_id, spending_profile jsonb, wallet card_ids[])
The spending_profile JSONB blob lets me calculate optimal reward allocation in a single Postgres query:
SELECT
c.name,
SUM(cr.reward_rate * sp.monthly_spend) as estimated_monthly_rewards
FROM cards c
JOIN category_rewards cr ON cr.card_id = c.id
JOIN jsonb_each_text(user_spending_profile) sp ON sp.key = cr.category
GROUP BY c.id, c.name
ORDER BY estimated_monthly_rewards DESC;
Supabase Edge Functions for Stripe
All Stripe logic lives in edge functions — never in the client.
create-checkout:
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY"));
const user = await supabase.auth.getUser(token);
const session = await stripe.checkout.sessions.create({
customer_email: user.data.user?.email,
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: successUrl,
metadata: { userId: user.data.user?.id },
});
return new Response(JSON.stringify({ url: session.url }));
Gotcha: deploy with --no-verify-jwt and validate tokens manually using the service role client. Trips everyone up the first time.
stripe-webhook updates subscription status in real-time:
const event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
if (event.type === "customer.subscription.updated") {
await supabase.from("subscriptions").upsert({
user_id: sub.metadata.userId,
status: sub.status,
plan: sub.items.data[0].price.id,
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
});
}
sage-chat-stream streams Claude Haiku with your wallet + spending profile in context:
const stream = await anthropic.messages.stream({
model: "claude-haiku-20240307",
max_tokens: 1024,
system: buildSystemPrompt(userCards, spendingProfile),
messages: [{ role: "user", content: userMessage }],
});
return new Response(stream.toReadableStream(), {
headers: { "Content-Type": "text/event-stream" },
});
Sage answers "Should I use my Cobalt or Sapphire at this restaurant?" with actual numbers from your wallet.
RLS That Actually Works
Key lesson: both anon and authenticated need explicit SELECT on public reference tables.
CREATE POLICY "cards_public_read" ON cards
FOR SELECT TO anon, authenticated USING (true);
CREATE POLICY "profiles_own_data" ON profiles
FOR ALL USING (auth.uid() = user_id);
Forget to grant anon access and your landing page breaks silently. Fun to debug at 11pm.
CSV Statement Parser
Import your bank statement and instantly see your spending profile. I support 8 Canadian bank formats (TD, RBC, Scotia, BMO, CIBC, Amex, Tangerine, Capital One) — each with different layouts, date formats, and amount sign conventions.
Everything normalizes into:
interface Transaction {
date: Date;
merchant: string;
amount: number; // always positive
category: SpendingCategory; // regex-classified
}
Fast enough to classify 12 months of transactions client-side with no API call.
Freemium + Lifetime Deal
- Free — core optimizer, 3 AI messages/month
- Pro ($5.99/mo) — advanced analytics
- Max ($12.99/mo) — everything + CSV import + full AI
- Lifetime ($49.99) — one-time deal, permanent Pro access
The lifetime deal is a Stripe one-time product (not a subscription). The webhook sets is_lifetime: true on the profile and bypasses all subscription checks.
Affiliate links on 7 screens with UTM tracking monetize the free tier — clicking "Apply Now" is how I make money on free users.
What I'd Do Differently
Fewer cards at launch. 410 was ambitious. Data quality issues haunted me for weeks. 50 perfect cards beats 410 imperfect ones.
Next.js for the SEO shell. The Expo web build works, but static HTML outperforms it in search. I'd use Next.js for the marketing layer and keep React Native for the app.
Cache invalidation upfront. I ended up with cards_cache_v4_ prefix I bump manually. Technical debt I'd plan for from day one.
The Numbers
- 1,187 passing tests across 39 suites before launch
- 410+ cards, 45 fully verified with all rates
- 8 bank CSV formats supported
- 14 days from idea to production
Rewardly is live at rewardly.ca — free to use, no credit card required.
If you're Canadian with a wallet full of cards you're not optimizing, Sage will tell you exactly how much you're leaving on the table.
Roadmap: receipt scanning, Apple Wallet integration, open banking for automatic import.
Top comments (0)