1// recommend-engine.js â shared card-recommendation engine. 2// 3// This is the single source of truth for "which cards match this user" logic, 4// extracted from quiz.html (the only page that used to score against the full 5// window.ALL_CARDS database with a rich multi-factor model). index.html's 6// homepage banner, account.html's Matches tab, get-started.html's signup 7// flow, and quiz.html itself all call into window.CCEngine instead of each 8// keeping their own hardcoded 5-card copy. 9// 10// Load after cards-data.js: <script src="recommend-engine.js?v=4"></script> 11 12(function () { 13 'use strict'; 14 15 // Legacy inline array kept only as a fallback if cards-data.js fails to load. 16 var CARDS_FALLBACK = [ 17 { 18 id: 'csp', name: 'Chase Sapphire Preferred®', issuer: 'Chase', 19 cardImage: 'https://cdn.prodstatic.com/shared/images/cards/219x139/ae837480-ed19-11ee-b6aa-83dad90afdcd.png', 20 badgeClass: 'ci-chase', badgeText: 'CHASE', annualFee: 95, minScore: 'good', 21 bonus: '60,000 Points', bonusValue: 750, 22 applyUrl: 'https://creditcards.chase.com/rewards-credit-cards/sapphire/preferred', 23 rateLabel: '5à on travel via Chase', 24 rates: { travel: 5, dining: 3, grocery: 3, gas: 2, other: 1, online: 1 }, 25 categories: ['travel', 'dining'], 26 pros: ['5à points on Chase Travel, 3à on dining & groceries', '60,000-point sign-up bonus worth ~$750 in travel', 27 'Transfer to United, Hyatt, Southwest, and more', 'No foreign transaction fees'] 28 }, 29 { 30 id: 'amex_gold', name: 'American Express® Gold Card', issuer: 'Amex', 31 cardImage: 'https://cdn.prodstatic.com/shared/images/cards/219x139/d2bef540-4974-11f1-84f5-41ce5a3f4820.png', 32 badgeClass: 'ci-amex', badgeText: 'AMEX', annualFee: 250, minScore: 'good', 33 bonus: '60,000 Points', bonusValue: 1200, 34 applyUrl: 'https://www.americanexpress.com/us/credit-cards/card/gold-card/', 35 rateLabel: '4à at restaurants worldwide', 36 rates: { travel: 3, dining: 4, grocery: 4, gas: 1, other: 1, online: 1 }, 37 categories: ['travel', 'dining', 'hotel'], 38 pros: ['4à at restaurants worldwide & U.S. supermarkets', '3à on flights booked directly or via Amex Travel', 39 '$120 dining credit + $120 Uber Cash annually', 'No foreign transaction fees'] 40 }, 41 { 42 id: 'venture_x', name: 'Capital One Venture X', issuer: 'Capital One', 43 cardImage: 'https://cdn.prodstatic.com/shared/images/cards/219x139/a5c974a0-eb11-11f0-8b83-5b931725a212.png', 44 badgeClass: 'ci-cap', badgeText: 'CAP1', annualFee: 395, minScore: 'excellent', 45 bonus: '75,000 Miles', bonusValue: 1312, 46 applyUrl: 'https://www.capitalone.com/credit-cards/venture-x/', 47 rateLabel: '10à miles on hotels via Capital One', 48 rates: { travel: 10, dining: 2, grocery: 2, gas: 2, other: 2, online: 2 }, 49 categories: ['travel'], 50 pros: ['$300 annual travel credit', '75,000-mile welcome bonus worth ~$1,312', 51 'Unlimited Priority Pass lounge access', '10à on hotels & car rentals via Cap One'] 52 }, 53 { 54 id: 'discover_it', name: 'Discover it® Cash Back', issuer: 'Discover', 55 badgeClass: 'ci-disc', badgeText: 'DISC', annualFee: 0, minScore: 'fair', 56 bonus: 'Cashback Match', bonusValue: 0, 57 applyUrl: 'https://www.discover.com/credit-cards/cash-back/it-card.html', 58 rateLabel: '5% rotating categories', 59 rates: { travel: 1, dining: 1, grocery: 5, gas: 5, other: 1, online: 1 }, 60 categories: ['cashback', 'nofee'], 61 pros: ['5% rotating categories (up to quarterly max)', 'No annual fee', 62 'Unlimited dollar-for-dollar cashback match first year'] 63 }, 64 { 65 id: 'citi_dc', name: 'Citi Double Cash® Card', issuer: 'Citi', 66 badgeClass: 'ci-citi', badgeText: 'CITI', annualFee: 0, minScore: 'good', 67 bonus: '$200 Cash', bonusValue: 200, 68 applyUrl: 'https://www.citi.com/credit-cards/citi-double-cash-credit-card', 69 rateLabel: '2% on everything', 70 rates: { travel: 2, dining: 2, grocery: 2, gas: 2, other: 2, online: 2 }, 71 categories: ['cashback', 'nofee'], 72 pros: ['Flat 2% cash back on every purchase', 'No annual fee', 'No categories to track'] 73 } 74 ]; 75 76 function getCardPool() { 77 return window.ALL_CARDS || CARDS_FALLBACK; 78 } 79 80 // Bank/investment "relationship" balance-tier reward-boost programs. 81 // One table per issuer program; tiers sorted ascending by minBalance. 82 // NOTE: verify against the issuer's current published tiers before 83 // changing -- Bank of America itself replaced this exact table (Preferred 84 // Rewards -> BofA Rewards) in May 2026; treat this as a "known to drift" fact. 85 var RELATIONSHIP_PROGRAMS = { 86 'Bank of America': { 87 label: 'BofA Rewards', 88 tiers: [ 89 { name: 'Member', minBalance: 0, bonus: 0.10 }, 90 { name: 'Preferred Plus', minBalance: 30000, bonus: 0.25 }, 91 { name: 'Preferred Honors', minBalance: 100000, bonus: 0.50 }, 92 { name: 'Premier', minBalance: 1000000, bonus: 0.75 } 93 ] 94 }, 95 // U.S. Bank doesn't name these tiers (no "Gold"/"Platinum"-style labels 96 // like BofA) -- thresholds/percentages per usbank.com's own Smartly 97 // program rules page. Applies only to the U.S. Bank Smartly Visa 98 // Signature Card, not U.S. Bank's other cards. 99 'U.S. Bank': { 100 label: 'Smartly Rewards', 101 tiers: [ 102 { name: '$10K+ Tier', minBalance: 10000, bonus: 0.25 }, 103 { name: '$50K+ Tier', minBalance: 50000, bonus: 0.50 }, 104 { name: '$100K+ Tier', minBalance: 100000, bonus: 1.00 } 105 ] 106 } 107 }; 108 109 function getRelationshipTier(issuer, balance) { 110 var program = RELATIONSHIP_PROGRAMS[issuer]; 111 if (!program || !(balance > 0)) return { program: program || null, tier: null }; 112 var tier = null;
113 program.tiers.forEach(function (t) { if (balance >= t.minBalance) tier = t; }); 114 return { program: program, tier: tier }; 115 } 116 117 // Resolves a card's relationship-bonus multiplier against a profile's 118 // declared bank relationships. Always safe to call -- missing 119 // bankRelationships, no matching issuer, or a zero balance all resolve 120 // to multiplier 1 (no-op). 121 function getRelationshipMultiplier(card, a) { 122 if (!card.relationshipBonus) return { multiplier: 1, tier: null, program: null }; 123 var rel = (a.bankRelationships || []).find(function (r) { return r.issuer === card.relationshipBonus.issuer; }); 124 var info = getRelationshipTier(card.relationshipBonus.issuer, rel ? rel.balance : 0); 125 return { multiplier: 1 + (info.tier ? info.tier.bonus : 0), tier: info.tier, program: info.program }; 126 } 127 128 function scoreCard(card, a) { 129 var s = 0; 130 131 // SPEND REWARDS VALUE (monthly spend à earn rate, scaled) 132 var spVal = 133 (a.spend.travel || 0) * (card.rates.travel || 1) + 134 (a.spend.dining || 0) * (card.rates.dining || 1) + 135 (a.spend.grocery || 0) * (card.rates.grocery || 1) + 136 (a.spend.gas || 0) * (card.rates.gas || 1) + 137 (a.spend.other || 0) * (card.rates.other || 1) + 138 (a.spend.online || 0) * (card.rates.online || 1); 139 var rel = getRelationshipMultiplier(card, a); 140 s += Math.min(Math.round(spVal * rel.multiplier * 0.05), 40); 141 142 // GOAL ALIGNMENT 143 var goalCats = { 144 travel: ['travel'], 145 cashback: ['cashback'], 146 airline: ['airline'], 147 hotel: ['hotel'], 148 credit: ['secured', 'student'], 149 business: ['business'] 150 }; 151 (goalCats[a.goal] || []).forEach(function (cat) { if ((card.categories || []).includes(cat)) s += 22; }); 152 if (a.goal === 'travel' && (card.categories || []).includes('airline')) s += 7; 153 if (a.goal === 'travel' && (card.categories || []).includes('hotel')) s += 5; 154 if (a.goal === 'cashback' && (card.categories || []).includes('nofee')) s += 5; 155 156 // TRAVEL FREQUENCY 157 var isTravel = ['travel', 'airline', 'hotel'].some(function (c) { return (card.categories || []).includes(c); }); 158 if (isTravel) { 159 s += ({ frequent: 18, monthly: 12, occasional: 6, rarely: -8 })[a.travelFreq] || 0; 160 } 161 if (a.travelFreq === 'rarely') { 162 if ((card.categories || []).includes('cashback')) s += 10; 163 if ((card.categories || []).includes('nofee')) s += 5; 164 } 165 166 // AIRLINE PREFERENCE 167 var airlineIds = { 168 united: ['united-explorer'], 169 delta: ['delta-gold-amex', 'delta-platinum-amex', 'delta-reserve-amex'], 170 american: ['citi-aadvantage-platinum', 'aadvantage-mileup'], 171 southwest: ['southwest-rapid-rewards-plus', 'southwest-rapid-rewards-priority'], 172 international: ['chase-sapphire-preferred', 'chase-sapphire-reserve', 'amex-gold', 'amex-platinum', 173 'capital-one-venture-x', 'capital-one-venture', 'citi-premier'] 174 }; 175 (a.airlines || []).forEach(function (al) { 176 if (al === 'none') { 177 if ((card.categories || []).includes('travel') && !(card.categories || []).includes('airline')) s += 8; 178 } else { 179 var matched = airlineIds[al] || []; 180 if (matched.includes(card.id)) s += 20; 181 if ((card.categories || []).includes('airline') && !matched.includes(card.id)) s -= 12; 182 } 183 }); 184 185 // HOTEL PREFERENCE 186 var hotelIds = { 187 marriott: ['marriott-bonvoy-boundless', 'marriott-bonvoy-brilliant'], 188 hilton: ['hilton-honors-surpass', 'hilton-honors-amex', 'hilton-honors-aspire'], 189 hyatt: ['world-of-hyatt'], 190 ihg: ['ihg-one-rewards-premier'], 191 flexible: ['chase-sapphire-preferred', 'chase-sapphire-reserve', 'amex-gold', 192 'capital-one-venture-x', 'capital-one-venture', 'citi-premier', 'ink-business-preferred'], 193 notimportant: [] 194 }; 195 if (a.hotel) { 196 var matchedHotel = hotelIds[a.hotel] || []; 197 if (matchedHotel.includes(card.id)) s += 20; 198 if (a.hotel !== 'flexible' && a.hotel !== 'notimportant' && 199 (card.categories || []).includes('hotel') && !matchedHotel.includes(card.id)) s -= 10; 200 if (a.hotel === 'notimportant' && 201 (card.categories || []).includes('hotel') && !(card.categories || []).includes('travel')) s
201-= 8; 202 } 203 204 // INTERNATIONAL TRAVEL 205 var noFTF = card.foreignFee === 'None'; 206 if (a.intl === 'often') s += noFTF ? 14 : -14; 207 else if (a.intl === 'sometimes') s += noFTF ? 7 : -4; 208 209 // ANNUAL FEE 210 var feeMax = { nofee: 0, low: 100, mid: 350, high: 1000 }; 211 var maxFee = feeMax[a.fee] != null ? feeMax[a.fee] : 1000; 212 if (card.annualFee === 0 && a.fee === 'nofee') s += 15; 213 else if (card.annualFee <= maxFee && a.fee !== 'nofee') { 214 if (a.fee === 'high' && card.annualFee >= 300) s += 8; 215 else if (a.fee === 'mid' && card.annualFee > 0 && card.annualFee <= 350) s += 6; 216 else if (a.fee === 'low' && card.annualFee > 0 && card.annualFee <= 100) s += 5; 217 } else if (card.annualFee > maxFee) s -= 35; 218 219 // CREDIT SCORE GATE 220 var scoreOrd = ['poor', 'fair', 'good', 'verygood', 'excellent']; 221 var minIdx = scoreOrd.indexOf(card.minScore || 'good'); 222 var userIdx = a.score === 'unknown' ? 2 : scoreOrd.indexOf(a.score); 223 if (userIdx < minIdx) s -= 60; 224 else if (userIdx >= minIdx + 2) s += 5; 225 226 // BUSINESS FILTER 227 var isBiz = (card.categories || []).includes('business'); 228 if (isBiz && a.goal !== 'business') s -= 35; 229 if (!isBiz && a.goal === 'business') s -= 3; 230 231 // SECURED / STUDENT FILTER 232 var isSecured = (card.categories || []).includes('secured'); 233 var isStudent = (card.categories || []).includes('student'); 234 if (isSecured && a.goal !== 'credit') s -= 25; 235 if (isStudent && a.goal !== 'credit' && !['poor', 'fair'].includes(a.score)) s -= 15; 236 if (['poor', 'fair'].includes(a.score) && isSecured) s += 30; 237 if (['poor', 'fair'].includes(a.score) && isStudent) s += 15; 238 239 return Math.max(s, 0); 240 } 241 242 function getTopSpend(a) { 243 var cats = ['travel', 'dining', 'grocery', 'gas', 'online', 'other']; 244 return cats.reduce(function (top, c) { return (a.spend[c] || 0) > (a.spend[top] || 0) ? c : top; }, 'other'); 245 } 246 247 function buildReason(card, a) { 248 var parts = []; 249 var rel = getRelationshipMultiplier(card, a); 250 if (rel.tier) { 251 parts.push(rel.program.label + ' ' + rel.tier.name + ' status boosts your rewards by ' + 252 Math.round(rel.tier.bonus * 100) + '% on this card'); 253 } 254 var catLabels = { 255 travel: 'travel', dining: 'dining', grocery: 'groceries', 256 gas: 'gas', online: 'online shopping', other: 'everyday purchases' 257 }; 258 var top = getTopSpend(a); 259 if ((a.spend[top] || 0) > 0 && (card.rates[top] || 1) >= 3 && parts.length < 2) { 260 parts.push(card.rates[top] + 'Ã on ' + catLabels[top] + ' matches your highest spend category'); 261 } 262 var goalMsgs = { 263 travel: (card.categories || []).includes('travel') ? 'Matches your goal of earning flexible travel rewards' : null, 264 cashback: (card.categories || []).includes('cashback') ? 'Maximizes cash back on your spending profile' : null, 265 airline: (card.categories || []).includes('airline') ? 'Earns airline miles as your primary goal' : null, 266 hotel: (card.categories || []).includes('hotel') ? 'Earns hotel points and elite status benefits' : null, 267 credit: 'Designed to build or establish credit history', 268 business: (card.categories || []).includes('business') ? 'Built for business spending and expense management' : null 269 }; 270 if (goalMsgs[a.goal] && parts.length < 2) parts.push(goalMsgs[a.goal]); 271 var airlineNameMap = { delta: 'Delta SkyMiles', united: 'United MileagePlus', american: 'American AAdvantage', southwest: 'Southwest Rapid Rewards' }; 272 var airlineCardMap2 = { 273 delta: ['delta-gold-amex', 'delta-platinum-amex', 'delta-reserve-amex'], 274 united: ['united-explorer'], 275 american: ['citi-aadvantage-platinum', 'aadvantage-mileup'], 276 southwest: ['southwest-rapid-rewards-plus', 'southwest-rapid-rewards-priority'] 277 }; 278 for (var i = 0; i < (a.airlines || []).length; i++) { 279 var al = a.airlines[i]; 280 if (airlineCardMap2[al] && airlineCardMap2[al].includes(card.id) && parts.length < 2) { 281 parts.push('Earns ' + airlineNameMap[al] + ' miles directly at your preferred airline'); 282 break; 283 } 284 } 285 var hotelMsgMap = { 286 marriott: { ids: ['marriott-bonvoy-boundless', 'marriott-bonvoy-brilliant'], msg: 'Earns Marriott Bonvoy points at your preferred hotel chain' }, 287 hilton: { ids: ['hilton-honors-surpass', 'hilton-honors-amex', 'hilton-honors-aspire'], msg: 'Earns Hilton Honors points and status at your preferred chain' }, 288 hyatt: { ids: ['world-of-hyatt'], msg: 'Best Hyatt card â exceptional point value at World of Hyatt properties' }, 289 ihg: { ids: ['ihg-one-rewards-premier'], msg: 'Earns IHG One Rewards points at your preferred chain' } 290 };
291 Object.keys(hotelMsgMap).forEach(function (chain) { 292 var info = hotelMsgMap[chain]; 293 if (a.hotel === chain && info.ids.includes(card.id) && parts.length < 2) parts.push(info.msg); 294 }); 295 if (a.intl === 'often' && card.foreignFee === 'None' && parts.length < 2) 296 parts.push('No foreign transaction fees for your frequent international travel'); 297 if (a.fee === 'nofee' && card.annualFee === 0 && parts.length < 2) 298 parts.push('$0 annual fee matches your preference'); 299 var total = Object.values(a.spend).reduce(function (s, v) { return s + v; }, 0); 300 if (total > 500 && card.annualFee > 0 && parts.length < 2) { 301 var estAnnual = Math.round(total * 12 * 0.02); 302 parts.push('Your $' + total.toLocaleString() + '/mo spend can generate ~$' + estAnnual.toLocaleString() + '/yr in rewards'); 303 } 304 if (!parts.length) parts.push('Strong overall match for your spending and travel profile'); 305 return parts.slice(0, 2).join(' · '); 306 } 307 308 // Ranks the full card pool (or a supplied pool) against a profile. 309 // opts: { limit, excludeIds: [], minScore: 0, pool } 310 function rankCards(profile, opts) { 311 opts = opts || {}; 312 var pool = opts.pool || getCardPool(); 313 var exclude = opts.excludeIds || []; 314 var minScore = opts.minScore != null ? opts.minScore : 0; 315 var scored = pool 316 .filter(function (c) { return !exclude.includes(c.id); }) 317 .map(function (c) { return Object.assign({}, c, { quizScore: scoreCard(c, profile) }); }) 318 .filter(function (c) { return c.quizScore > minScore; }) 319 .sort(function (a, b) { return b.quizScore - a.quizScore; }); 320 return opts.limit ? scored.slice(0, opts.limit) : scored; 321 } 322 323 // One-time spend-allocation weights used only to spread a lump-sum monthly 324 // spend figure (from the 4-field quick forms) across categories â matches 325 // the SPEND_ALLOC table used historically on index.html/account.html and 326 // in functions/api/auth/migrate.js's legacy-account migration. 327 var SPEND_ALLOC = { 328 travel: { travel: .40, dining: .25, grocery: .15, gas: .08, other: .12 }, 329 cashback: { travel: .10, dining: .20, grocery: .25, gas: .15, other: .30 }, 330 category: { travel: .08, dining: .15, grocery: .30, gas: .22, other: .25 }, 331 bonus: { travel: .20, dining: .25, grocery: .20, gas: .10, other: .25 }, 332 credit: { travel: .10, dining: .20, grocery: .25, gas: .15, other: .30 } 333 }; 334 335 function feeDollarsToTolerance(maxFee) { 336 var n = parseFloat(maxFee); 337 if (!n || n <= 0) return 'nofee'; 338 if (n <= 100) return 'low'; 339 if (n <= 250) return 'mid'; 340 return 'high'; 341 } 342 343 // D1 `preferences` row -> engine profile shape. Lossless once Milestone 5's 344 // expanded Preferences UI is live; degrades gracefully for legacy rows. 345 function normalizeProfile(prefsRow) { 346 if (!prefsRow) return null; 347 var airlines = []; 348 try { airlines = JSON.parse(prefsRow.airlines || '[]'); } catch (e) {} 349 var bankRelationships = []; 350 try { bankRelationships = JSON.parse(prefsRow.bank_relationships || '[]'); } catch (e) {} 351 return { 352 goal: prefsRow.goal || null, 353 travelFreq: prefsRow.travel_freq || null, 354 airlines: airlines, 355 hotel: prefsRow.hotel || null, 356 intl: prefsRow.intl_travel || null, 357 fee: prefsRow.fee_tolerance || null, 358 score: prefsRow.credit_score || null, 359 bankRelationships: bankRelationships, 360 spend: { 361 travel: prefsRow.spend_travel || 0, 362 dining: prefsRow.spend_dining || 0, 363 grocery: prefsRow.spend_grocery || 0, 364 gas: prefsRow.spend_gas || 0, 365 online: prefsRow.spend_online || 0, 366 other: prefsRow.spend_other || 0 367 } 368 }; 369 } 370 371 // Adapts the minimal 4-field quick-quiz shape (index.html banner, 372 // get-started.html signup) into a full profile, filling missing richer 373 // fields (travel frequency, airline/hotel preference, international 374 // travel) with neutral defaults. 375 function profileFromQuickForm(goal, spend, creditTier, maxFeeDollars) { 376 var alloc = SPEND_ALLOC[goal] || SPEND_ALLOC.cashback; 377 var lump = parseFloat(spend) || 0; 378 var creditMap = { excellent: 'excellent', good: 'good', fair: 'fair', building: 'poor' }; 379 return { 380 goal: goal || 'cashback', 381 travelFreq: 'occasional', 382 airlines: [], 383 hotel: 'flexible', 384 intl: 'rarely', 385 fee: feeDollarsToTolerance(maxFeeDollars), 386 score: creditMap[creditTier] || 'good', 387 spend: { 388 travel: lump * alloc.travel, dining: lump * alloc.dining, grocery: lump * alloc.grocery, 389 gas: lump * alloc.gas, online: 0, other: lump * alloc.other 390 } 391 }; 392 } 393 394 // Estimated dollar value of a single transaction on a given card, used by 395 // the expense-tracking feature (Milestone 6) for both per-expense verdicts 396 // and the "apply for a better card" aggregate rollup â one formula, reused 397 // everywhere, so results stay internally consistent. 398 // 399 // card.rates[category] is a per-dollar multiplier (points/miles cards) or 400 // a literal cashback percentage (flat-rate cards) â cards-data.js uses the 401 // same numeric convention for both (e.g. Citi Double Cash rates.other=2 402 // means 2% cash back; Chase Sapphire Preferred rates.travel=5 means 5 403 // points per $1). Valuing each point/mile at ~1¢ (POINT_VALUE_CENTS) makes 404 // amount * rate * (POINT_VALUE_CENTS/100) correct for flat-cashback cards 405 // and a defensible conservative estimate for points cards. 406 var POINT_VALUE_CENTS = 1.0; 407 // Hotel points are worth well under a cent, so at 1 cent a Hilton card (14x) 408 // outranked everything on every page. These are the site's own published 409 // Free Night values from rewards-calculator.html; scripts/check-spend-rank.js 410 // fails if the two drift apart. Programs the site values at 1 cent or more 411 // (airlines, Hyatt, flexible points) stay at the conservative 1 cent above. 412 var HOTEL_POINT_CENTS = [ 413 [/Hilton/i, 0.5], 414 [/IHG/i, 0.55], 415 [/Marriott|Bonvoy/i, 0.7], 416 [/Wyndham/i, 0.9] 417 ]; 418 function pointValueCents(card) { 419 for (var i = 0; i < HOTEL_POINT_CENTS.length; i++) { 420 if (HOTEL_POINT_CENTS[i][0].test(card.name || '')) return HOTEL_POINT_CENTS[i][1]; 421 } 422 return POINT_VALUE_CENTS; 423 } 424 function estimateEarn(card, category, amount, profile) { 425 var rate = (card.rates && card.rates[category]) || 0; 426 var rel = profile ? getRelationshipMultiplier(card, profile) : { multiplier: 1 }; 427 return amount * rate * rel.multiplier * (pointValueCents(card) / 100); 428 } 429 430 // Homepage spend calculator. Ranks cards on entered monthly spend alone: 431 // year one = rewards + welcome offer - annual fee; after = rewards - fee. 432 // The welcome offer is counted at the conservative CARD_FRESHNESS floor, and
433 // only when this spending would meet the offer's own requirement. 434 // Hotel and airline co-brands stay out even with per-program point values: their 435 // points only pay off if you stay with that chain or fly that airline, which this 436 // calculator cannot know and the quiz asks outright. With them in, the Wyndham 437 // card wins half of all "every year after" states. 438 var SPEND_POOL_EXCLUDE = ['business', 'secured', 'student', 'hotel', 'airline']; 439 440 // "75,000 miles after $4,000 spend in 3 months" -> { amount: 4000, months: 3 } 441 function parseBonusRequirement(card) { 442 var m = /\$([\d,]+)[^$]*?\bin\s+(?:the\s+)?(?:first\s+)?(\d+)\s+(month|day)/i.exec(card.bonusDesc || ''); 443 if (!m) return null; 444 var n = parseInt(m[2], 10); 445 return { amount: parseInt(m[1].replace(/,/g, ''), 10), months: m[3].toLowerCase() === 'day' ? Math.round(n / 30) : n }; 446 } 447 448 // "Pick your category" cards (Venmo: 3% on your top category, 2% on the second, 449 // 1% on the rest) cannot be a fixed rates table: card.rates holds the bonus rate 450 // on every eligible category at once, which overstates anyone who spends in more 451 // than one. card.rateTiers = { among, tiers, base } hands tiers[0] to the 452 // visitor's biggest category in `among`, tiers[1] to the next, and `base` to the 453 // rest of `among`. Categories outside `among` keep card.rates. 454 function effectiveRates(card, spend) { 455 var out = {}; 456 Object.keys(spend).forEach(function (k) { out[k] = (card.rates && card.rates[k]) || 0; }); 457 var t = card.rateTiers; 458 if (!t) return out; 459 t.among.filter(function (k) { return k in spend; }) 460 .sort(function (a, b) { return (spend[b] || 0) - (spend[a] || 0) || t.among.indexOf(a) - t.among.indexOf(b); }) 461 .forEach(function (k, i) { out[k] = i < t.tiers.length ? t.tiers[i] : t.base; }); 462 return out; 463 } 464 465 function rankBySpend(spend, opts) { 466 opts = opts || {}; 467 var fresh = window.CARD_FRESHNESS || {}; 468 var cats = Object.keys(spend); 469 var monthly = cats.reduce(function (sum, k) { return sum + (spend[k] || 0); }, 0); 470 return getCardPool().filter(function (c) { 471 return !(c.categories || []).some(function (k) { return SPEND_POOL_EXCLUDE.indexOf(k) !== -1; }); 472 }).map(function (c) { 473 var perCat = {}; 474 var rewards = 0; 475 var rates = effectiveRates(c, spend); 476 cats.forEach(function (k) { 477 perCat[k] = Math.round((spend[k] || 0) * rates[k] * (pointValueCents(c) / 100) * 12); 478 rewards += perCat[k]; 479 }); 480 var f = fresh[c.id] || {}; 481 var offer = f.bonusFloor != null ? f.bonusFloor : Math.round((c.bonusValue || 0) / 2); 482 var req = parseBonusRequirement(c); 483 var met = !req || monthly * req.months >= req.amount; 484 var welcome = met ? offer : 0; 485 var fee = c.annualFee || 0; 486 return { 487 card: c, rates: rates, perCat: perCat, rewards: rewards, welcome: welcome, offer: offer, met: met, req: req, fee: fee, 488 yearOne: rewards + welcome - fee, after: rewards - fee, checked: f.bonusUpdated || null 489 }; 490 }).sort(function (a, b) { 491 var k = opts.mode === 'after' ? 'after' : 'yearOne'; 492 return b[k] - a[k] || a.fee - b.fee || (a.card.id < b.card.id ? -1 : 1); 493 }); 494 } 495 496 var CATEGORIES = ['travel', 'dining', 'grocery', 'gas', 'online', 'other']; 497 498 // For each category, which of the user's owned cards earns the most â 499 // used both by the Expenses tab's per-transaction verdicts and by 500 // rewards-optimizer.html's signed-in "your best card" column (Milestone 7), 501 // neither of which needs expense history, just the wallet. 502 function bestOwnedPerCategory(walletCards) { 503 var result = {}; 504 CATEGORIES.forEach(function (cat) { 505 var best = null; 506 walletCards.forEach(function (c) { 507 if (!best || (c.rates[cat] || 0) > (best.rates[cat] || 0)) best = c; 508 }); 509 result[cat] = best; 510 }); 511 return result; 512 } 513 514 // Full expense analysis: a verdict per logged expense (did the card used 515 // earn the most among owned cards for that category?) plus a category- 516 // level rollup comparing the best owned card against the best card in the 517 // ENTIRE pool, surfacing an "apply for X" recommendation when the gap is 518 // large enough to matter. Pure computation over already-fetched data â no 519 // dedicated /api/analyze endpoint needed. 520 // 521 // Thresholds are named constants, not magic numbers, so they're easy to 522 // retune: ignore verdict deltas under 50 cents (avoids flagging trivial 523 // rounding-level differences on small purchases), and only recommend an 524 // unowned card when the projected annual delta clears $50/year. 525 var VERDICT_DELTA_THRESHOLD = 0.50; 526 var ANNUAL_DELTA_THRESHOLD = 50; 527 528 function analyzeExpenses(expenses, walletCards, allCards, profile) { 529 var bestOwned = bestOwnedPerCategory(walletCards); 530 531 var perExpenseVerdicts = expenses.map(function (exp) { 532 var usedCard = walletCards.find(function (c) { return c.id === exp.cardId; }); 533 if (!usedCard) return Object.assign({}, exp, { optimal: null, usedEarn: 0, bestEarn: 0, bestOwned: null, delta: 0 }); 534 var best = bestOwned[exp.category] || usedCard; 535 var usedEarn = estimateEarn(usedCard, exp.category, exp.amount, profile); 536 var bestEarn = estimateEarn(best, exp.category, exp.amount, profile); 537 var delta = bestEarn - usedEarn; 538 return Object.assign({}, exp, { 539 optimal: best.id === usedCard.id || delta <= VERDICT_DELTA_THRESHOLD, 540 usedEarn: usedEarn, bestEarn: bestEarn, bestOwned: best, delta: delta 541 }); 542 }); 543 544 // Category-level rollup: trailing spend by category -> best owned vs. 545 // best in the entire pool -> recommend applying only if the annualized 546 // gap clears the threshold. 547 var categoryRollups = {}; 548 CATEGORIES.forEach(function (cat) { 549 var spent = expenses.filter(function (e) { return e.category === cat; }) 550 .reduce(function (sum, e) { return sum + e.amount; }, 0); 551 if (spent <= 0) return; 552 553 var bestOverall = null; 554 allCards.forEach(function (c) { 555 if (!bestOverall || (c.rates[cat] || 0) > (bestOverall.rates[cat] || 0)) bestOverall = c; 556 }); 557 var ownedBest = bestOwned[cat]; 558 if (!bestOverall) return; 559 560 // Annualize the trailing sample (expenses are assumed to cover ~90 561 // days of history â see Expenses tab caller) for a yearly-delta estimate. 562 var annualizedSpend = spent * (365 / 90); 563 var ownedAnnualEarn = ownedBest ? estimateEarn(ownedBest, cat, annualizedSpend, profile) : 0; 564 var overallAnnualEarn = estimateEarn(bestOverall, cat, annualizedSpend, profile); 565 var annualDelta = overallAnnualEarn - ownedAnnualEarn; 566 var ownedHasIt = ownedBest && ownedBest.id === bestOverall.id; 567 568 categoryRollups[cat] = { 569 spent: spent, bestOwned: ownedBest, bestOverall: bestOverall, 570 annualDelta: annualDelta, 571 recommendApply: !ownedHasIt && annualDelta >= ANNUAL_DELTA_THRESHOLD 572 }; 573 }); 574 575 return { perExpenseVerdicts: perExpenseVerdicts, categoryRollups: categoryRollups }; 576 } 577 578 window.CCEngine = { 579 getCardPool: getCardPool, 580 scoreCard: scoreCard, 581 buildReason: buildReason, 582 getTopSpend: getTopSpend, 583 rankCards: rankCards, 584 normalizeProfile: normalizeProfile, 585 profileFromQuickForm: profileFromQuickForm, 586 estimateEarn: estimateEarn, 587 rankBySpend: rankBySpend, 588 pointValueCents: pointValueCents, 589 parseBonusRequirement: parseBonusRequirement, 590 bestOwnedPerCategory: bestOwnedPerCategory, 591 analyzeExpenses: analyzeExpenses, 592 getRelationshipTier: getRelationshipTier, 593 getRelationshipMultiplier: getRelationshipMultiplier, 594 _fallbackPool: CARDS_FALLBACK 595 }; 596})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.