DEV Community

Tahseen Rahman
Tahseen Rahman

Posted on • Originally published at rewardly.ca

How We Built a Canadian Credit Card Optimizer That Respects Annual Caps (the detail nobody gets right)

How We Built a Canadian Credit Card Optimizer That Respects Annual Caps (the detail nobody gets right)


tags: webdev, startup, react, canada


Here's a dirty secret about most credit card reward calculators: they lie.

Not intentionally. They just don't model the complexity of real card T&Cs. And in Canada, that complexity is brutal.

I'm building Rewardly — a credit card reward optimizer for Canadians. And the single hardest engineering problem we've faced isn't scraping data, isn't building a clean UI, isn't even getting our 410-card database to load fast. It's correctly modeling annual category caps.

Let me show you why this is hard, how we got it wrong the first time, and what the actual solution looks like.


The Problem: Annual Caps Are Complicated

Every "premium" Canadian credit card has a variation of this structure:

Earn 5x points on grocery purchases up to $500/month, then 1x.

Sounds simple. But real cards look more like this (paraphrasing the Scotiabank Gold American Express):

Earn 6x Scene+ points on:

  • Grocery purchases at eligible Canadian grocery stores
  • Dining at eligible Canadian restaurants
  • Food delivery and grocery subscriptions

Up to a combined annual cap of $50,000 in purchases per year across these categories, then 1x.

Notice what that says: combined annual cap across three categories. It's not $50K per category — it's $50K total, pooled. Spend $50K on groceries alone, and your dining earns 1x for the rest of the year.

Now multiply this across 410 cards, each with slightly different:

  • Cap period (monthly vs annual vs "billing cycle")
  • Pooling logic (per-category vs combined)
  • Category definitions (does "grocery" include Costco? Walmart? Shoppers Drug Mart?)
  • Multiplier tiers (3x up to $5K, then 2x up to $15K, then 1x)

Most calculators just don't handle this. They give you the headline rate without tracking whether you'd hit the cap. The result: grossly inflated earnings estimates.


Our First (Wrong) Approach

When I built the MVP, I went with the naive model:

interface Card {
  categories: {
    [category: string]: {
      rate: number;    // e.g. 0.05 for 5%
      cap?: number;    // e.g. 500 (monthly)
    }
  }
}

function calculateEarnings(card: Card, monthlySpend: SpendProfile): number {
  let total = 0;
  for (const [cat, amount] of Object.entries(monthlySpend)) {
    const catData = card.categories[cat];
    if (!catData) continue;

    const effectiveSpend = catData.cap 
      ? Math.min(amount, catData.cap) 
      : amount;

    total += effectiveSpend * catData.rate;
    // At cap, no base rate applied -- oops!
  }
  return total;
}
Enter fullscreen mode Exit fullscreen mode

Two problems immediately:

  1. Missing the base rate beyond the cap. If a card earns 5% up to $500 and 1% after, I was earning $0 on the amount above $500 instead of 1%.

  2. No pooling logic. I was treating each category independently, so the Scotiabank Gold was giving full 6x on both groceries AND dining even when combined they'd hit the $50K pool.

Our earnings estimates were off by 15–40% for heavy spenders. That's a material error that would cause users to make the wrong card choice.


The Right Data Model

After a full rewrite, here's what our card model looks like (simplified):

interface EarnRule {
  categories: string[];      // which categories this rule applies to
  rate: number;              // earn rate (e.g. 0.05)
  baseRate: number;          // earn rate after cap (e.g. 0.01)
  cap: {
    amount: number;          // dollar cap
    period: 'monthly' | 'annual' | 'calendar_year';
    pooled: boolean;         // if true, cap is shared across all listed categories
  } | null;
}

interface Card {
  id: string;
  name: string;
  rules: EarnRule[];         // ordered by priority (more specific rules first)
  defaultRate: number;       // fallback for uncategorized spend
}
Enter fullscreen mode Exit fullscreen mode

And the calculation engine:

function calculateAnnualEarnings(
  card: Card,
  annualSpend: SpendProfile,  // { grocery: 8000, dining: 3000, gas: 2400, ... }
): number {
  // Track cap consumption per rule across the year
  const capUsed: Map<string, number> = new Map();

  let totalEarnings = 0;

  for (const [category, totalAmount] of Object.entries(annualSpend)) {
    // Find the applicable rule for this category
    const rule = findApplicableRule(card.rules, category);

    if (!rule) {
      totalEarnings += totalAmount * card.defaultRate;
      continue;
    }

    if (!rule.cap) {
      // No cap — simple calculation
      totalEarnings += totalAmount * rule.rate;
      continue;
    }

    // Handle capped rules
    const ruleId = getRuleId(rule);  // deterministic ID based on rule definition
    const used = capUsed.get(ruleId) ?? 0;
    const remaining = Math.max(0, rule.cap.amount - used);

    const atHighRate = Math.min(totalAmount, remaining);
    const atBaseRate = totalAmount - atHighRate;

    totalEarnings += atHighRate * rule.rate;
    totalEarnings += atBaseRate * rule.baseRate;

    // Update cap usage
    if (rule.cap.pooled) {
      // The cap is shared — update the pool's usage
      capUsed.set(ruleId, used + atHighRate);
    } else {
      // Per-category cap
      capUsed.set(`${ruleId}-${category}`, used + atHighRate);
    }
  }

  return totalEarnings;
}
Enter fullscreen mode Exit fullscreen mode

The key insight is the pooled flag. When pooled: true, all categories sharing that rule consume from the same cap bucket. When pooled: false, each category has an independent cap.


The Category Definitions Problem

Even with the right math, we kept getting wrong answers because category definitions vary wildly by card and issuer.

Examples from our actual database:

Category Label Scotiabank Gold Amex TD First Class PC MasterCard
Grocery Eligible CA grocery stores (excludes Costco, Walmart) All grocery stores (incl. Costco) Loblaws banner stores only
Drug Store Included in "other" 2x Not a category
Travel 3x 8x on Expedia For TD 0x

When a user selects "grocery" in our spend input, which definition applies? The wrong approach: use the same definition for every card. The right approach: store issuer-specific category mappings in the card data.

interface CategoryMapping {
  canonical: string;    // our standard category name
  issuerId: string;
  included: string[];   // merchant types included by this issuer
  excluded: string[];   // merchant types explicitly excluded
  notes?: string;       // any edge cases from T&Cs
}
Enter fullscreen mode Exit fullscreen mode

This is why our 410-card database took way longer to build than expected. It's not just scraped data — we manually reviewed T&Cs for every card's category inclusions and exclusions. The Scotiabank Gold alone has 847 words of grocery category definition.


Why Competitors Get This Wrong

The US-based tools (MaxRewards, CardPointers, AwardWallet) have a structural problem: they were built for the US market and ported awkwardly to Canada. US card structures are simpler — most have flat rates per category, fewer annual caps, and cleaner category definitions.

Canadian cards reflect the bank oligopoly: BMO, TD, Scotia, RBC, CIBC each have their own points currencies, their own category definitions, and their own cap structures. There's no standard.

We've seen MaxRewards give estimates 30%+ higher than actual for Canadian premium cards. Not a knock on them — they optimize for the US case. But it means Canadian users who rely on them make systematically wrong card choices.


What This Means for Users

Once we got the math right, we found some counterintuitive results:

  • The Scotiabank Gold Amex is often beaten by simpler cards for heavy spenders. Its 6x grocery rate sounds incredible until you hit the $50K combined cap in August if you're maxing dining + grocery + food delivery. After that, a flat-2% card wins.

  • Multi-card strategies are dramatically underutilized. Our optimizer can recommend a 2-card combo that out-earns any single premium card by 15–25% for most spending profiles. No one builds for this use case.

  • Category caps matter more at the extremes. If you spend $1,000/month on groceries (vs the average $600), the cap math changes your optimal card completely.


What's Next

We're working on a few improvements to the engine:

  1. Monthly cap simulation — projecting which month you'd hit each cap based on spending patterns, not just annual totals
  2. Promo rate handling — temporary elevated rates on some cards (e.g. "6x for 3 months on grocery")
  3. Churning scenarios — for users who rotate cards to harvest welcome bonuses

If you're a Canadian developer with credit card data or T&C parsing experience, I'd love to talk. This is genuinely hard domain knowledge.


Rewardly is live at rewardly.ca — currently free beta for Canadians. The optimizer handles all 410+ Canadian credit cards and accounts for annual caps, pooled categories, and multi-card strategies. We're 23 signups in with zero paying customers — classic indie hacker moment. Feedback welcome.


Discussion questions: How do you model complex financial product rules in your DB? Anyone else dealing with the "each customer's contract is slightly different" problem?

Top comments (0)