earnrate 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +13 -12
  2. package/data/CHANGELOG.md +35 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-cash-everyday.yaml +5 -4
  5. package/data/cards/chase-freedom-flex.yaml +1 -1
  6. package/data/cards/chase-southwest-plus.yaml +4 -0
  7. package/data/cards/chase-southwest-premier.yaml +4 -0
  8. package/data/cards/chase-southwest-priority.yaml +4 -0
  9. package/data/cards/citi-aadvantage-globe.yaml +7 -7
  10. package/data/cards/citi-costco-anywhere-visa-business.yaml +15 -1
  11. package/data/cards/citi-costco-anywhere-visa.yaml +15 -1
  12. package/data/merchants/overrides.yaml +12 -1
  13. package/data/merchants/seed.yaml +0 -6
  14. package/dist/cli/cheatsheet.js +1 -1
  15. package/dist/cli/explore.js +1 -1
  16. package/dist/cli/import.js +4 -3
  17. package/dist/cli/run.js +1 -1
  18. package/dist/data/file-store.js +3 -1
  19. package/dist/data/integrity.js +10 -0
  20. package/dist/data/store.js +9 -1
  21. package/dist/data/wallet-edit.js +8 -2
  22. package/dist/engine/cheat-sheet.js +44 -2
  23. package/dist/engine/explore.js +14 -5
  24. package/dist/engine/memberships.js +45 -0
  25. package/dist/engine/offers.js +22 -2
  26. package/dist/engine/recommend.js +91 -31
  27. package/dist/engine/setup.js +23 -4
  28. package/dist/engine/simulate.js +32 -10
  29. package/dist/engine/text.js +51 -0
  30. package/dist/engine/wallet-change.js +27 -7
  31. package/dist/engine/wallet-summary.js +133 -3
  32. package/dist/format/card-text.js +7 -13
  33. package/dist/format/cards.js +7 -8
  34. package/dist/format/explore.js +17 -8
  35. package/dist/format/offers.js +5 -9
  36. package/dist/format/recommendation.js +11 -5
  37. package/dist/format/wallet-yaml.js +18 -3
  38. package/dist/format/wallet.js +21 -18
  39. package/dist/mcp/context.js +7 -3
  40. package/dist/mcp/inputs.js +2 -2
  41. package/dist/mcp/schemas.js +18 -0
  42. package/dist/mcp/server.js +3 -3
  43. package/dist/mcp/tools/account.js +55 -7
  44. package/dist/mcp/tools/answers.js +24 -7
  45. package/dist/mcp/tools/wallet.js +49 -7
  46. package/dist/schema/card.js +14 -0
  47. package/dist/schema/wallet.js +4 -0
  48. package/package.json +1 -1
  49. package/wallet.example.yaml +1 -0
@@ -1,8 +1,10 @@
1
1
  import { resolveMerchantById } from "../merchants/resolve.js";
2
2
  import { categoryResolution, exploreCards, } from "./explore.js";
3
+ import { bonusStatus } from "./bonus.js";
3
4
  import { periodKey } from "./periods.js";
4
5
  import { recommend } from "./recommend.js";
5
6
  import { setupNotes } from "./setup.js";
7
+ import { listed, money, shortDate } from "./text.js";
6
8
  const CHEAT_SHEET_ITEMS = [
7
9
  { label: 'Groceries', category: 'grocery.supermarket' },
8
10
  { label: 'Dining', category: 'dining' },
@@ -17,6 +19,7 @@ const CHEAT_SHEET_ITEMS = [
17
19
  // The best card in the wallet for each everyday kind of purchase on `date`,
18
20
  // with caps, picks and activations as the wallet has them.
19
21
  export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
22
+ const ranked = [];
20
23
  const rows = items.map((item) => {
21
24
  const merchant = 'merchant' in item
22
25
  ? resolveMerchantById(catalog, item.merchant, null)
@@ -30,7 +33,10 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
30
33
  date,
31
34
  country: 'US',
32
35
  }, merchant);
33
- const top = rec.ranked.find((r) => r.cardId === rec.decision?.cardId);
36
+ // The card that earns the most, even when a welcome bonus would take the
37
+ // pick for one purchase: the sheet is for the whole quarter.
38
+ const top = rec.decision ? rec.ranked[0] : undefined;
39
+ ranked.push({ label: item.label, cards: rec.ranked });
34
40
  return {
35
41
  label: item.label,
36
42
  cardId: top?.cardId ?? null,
@@ -40,8 +46,44 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
40
46
  note: top?.warnings[0] ?? null,
41
47
  };
42
48
  });
49
+ const bonuses = wallet.cards.flatMap((held) => {
50
+ const status = bonusStatus(catalog, wallet, held, date);
51
+ if (status?.state !== 'open')
52
+ return [];
53
+ const name = held.checkout_label ??
54
+ catalog.cards.find((c) => c.id === held.id)?.name ??
55
+ held.id;
56
+ // Rows where the bonus card matches the row's card, or comes within a
57
+ // point and three quarters of its rate (as single answers mention it).
58
+ const matches = [];
59
+ const near = [];
60
+ for (const { label, cards } of ranked) {
61
+ const best = cards[0]?.effectiveReturnPct;
62
+ const own = cards.find((c) => c.cardId === held.id)?.effectiveReturnPct;
63
+ if (best === undefined || own === undefined)
64
+ continue;
65
+ if (best - own < 1e-9)
66
+ matches.push(label.toLowerCase());
67
+ else if (best - own <= 1 && own >= best * 0.75)
68
+ near.push(label.toLowerCase());
69
+ }
70
+ const where = [
71
+ ...(matches.length > 0
72
+ ? [`matches the listed card on ${listed(matches)}`]
73
+ : []),
74
+ ...(near.length > 0 ? [`comes within a point on ${listed(near)}`] : []),
75
+ ];
76
+ return [
77
+ `Working on ${name}'s welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
78
+ ? `It ${where.join(' and ')}, so those are the places to use it.`
79
+ : 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
80
+ ];
81
+ });
43
82
  const notes = [
44
- ...new Set(wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date).map((n) => n.text))),
83
+ ...new Set([
84
+ ...bonuses,
85
+ ...wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date).map((n) => n.text)),
86
+ ]),
45
87
  ];
46
88
  const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
47
89
  return {
@@ -1,10 +1,11 @@
1
1
  import { resolveMerchant } from "../merchants/resolve.js";
2
2
  import { evaluateCard, purchaseContext } from "./evaluate.js";
3
+ import { membershipNeeded, paidMemberships } from "./memberships.js";
3
4
  import { choicePeriod, periodKey } from "./periods.js";
4
5
  import { recommend } from "./recommend.js";
5
6
  import { rotationGap } from "./rotation.js";
6
7
  import { membershipsUsed } from "./setup.js";
7
- import { categoryPhrase, codesAs, earnText, money, pointValuesText, } from "./text.js";
8
+ import { categoryPhrase, choiceName, codesAs, earnText, membershipName, money, pointValuesText, words, } from "./text.js";
8
9
  import { cardValuation } from "./valuation.js";
9
10
  const EPSILON = 1e-9;
10
11
  export const ALL_PERSONAL_CARDS = {
@@ -150,12 +151,12 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
150
151
  function conditionsText(catalog, r) {
151
152
  const conditions = [];
152
153
  if (r.pick) {
153
- conditions.push(`Needs "${r.pick.options.join('", "')}" as its ${r.pick.choice} pick (${PICK_PERIODS[r.pick.period]}).`);
154
+ conditions.push(`Needs ${r.pick.options.map(words).join(' or ')} as its ${choiceName(r.pick.choice)} pick (${PICK_PERIODS[r.pick.period]}).`);
154
155
  }
155
156
  if (r.activation !== null)
156
- conditions.push(`Needs activating with the issuer ("${r.activation}").`);
157
+ conditions.push('Needs its bonus activated with the issuer (free).');
157
158
  if (r.membership !== null)
158
- conditions.push(`Needs ${r.membership}.`);
159
+ conditions.push(`Needs ${membershipName(r.membership)}.`);
159
160
  if (r.cap) {
160
161
  conditions.push(`Up to ${money(r.cap.amount)} ${CAP_PERIODS[r.cap.period] ?? ''} at this rate, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}.`);
161
162
  }
@@ -206,6 +207,11 @@ export function exploreCards(catalog, query, options, resolved) {
206
207
  };
207
208
  const ranked = [];
208
209
  const excluded = [];
210
+ // A Costco purchase means a Costco member: no membership to point out.
211
+ const paid = paidMemberships(catalog, {
212
+ memberships: options.memberships,
213
+ merchantIds: [merchant.merchantId],
214
+ });
209
215
  for (const card of catalog.cards) {
210
216
  if (!matchesFilters(card, options.filters))
211
217
  continue;
@@ -236,7 +242,10 @@ export function exploreCards(catalog, query, options, resolved) {
236
242
  firstYearFee: card.first_year_fee ?? null,
237
243
  picks,
238
244
  requirements,
239
- conditions: conditionsText(catalog, requirements),
245
+ conditions: [
246
+ ...conditionsText(catalog, requirements),
247
+ ...membershipNeeded(card, paid),
248
+ ],
240
249
  evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
241
250
  withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
242
251
  });
@@ -0,0 +1,45 @@
1
+ import { money } from "./text.js";
2
+ // Memberships the person already pays for: the ones they've said, the ones a
3
+ // card they hold needs, and the ones their spending shows (only members shop
4
+ // at Costco).
5
+ export function paidMemberships(catalog, { memberships, heldCards = [], merchantIds = [], }) {
6
+ const paid = new Set(memberships);
7
+ for (const card of catalog.cards) {
8
+ const m = card.requires_membership;
9
+ if (!m)
10
+ continue;
11
+ if (heldCards.includes(card.id) ||
12
+ m.members_shop_at.some((id) => merchantIds.includes(id)))
13
+ paid.add(m.id);
14
+ }
15
+ return paid;
16
+ }
17
+ // What these cards' memberships add, once each, leaving out the paid ones.
18
+ export function membershipCosts(cards, paid) {
19
+ const costs = new Map();
20
+ for (const card of cards) {
21
+ const m = card.requires_membership;
22
+ if (!m || paid.has(m.id))
23
+ continue;
24
+ const cost = costs.get(m.id) ?? {
25
+ id: m.id,
26
+ name: m.name,
27
+ annualFee: m.annual_fee,
28
+ cardIds: [],
29
+ };
30
+ cost.cardIds.push(card.id);
31
+ costs.set(m.id, cost);
32
+ }
33
+ return [...costs.values()];
34
+ }
35
+ // The card's membership as a condition, unless it's paid for.
36
+ export function membershipNeeded(card, paid) {
37
+ const m = card.requires_membership;
38
+ return m && !paid.has(m.id)
39
+ ? [`Needs ${m.name} (${money(m.annual_fee)} a year).`]
40
+ : [];
41
+ }
42
+ export function membershipNote(catalog, m) {
43
+ const names = m.cardIds.map((id) => catalog.cards.find((c) => c.id === id)?.name ?? id);
44
+ return `${names.join(' and ')} ${names.length === 1 ? 'needs' : 'need'} ${m.name}, ${money(m.annualFee)} a year, counted here like an annual fee. If you're already a member, add it to your memberships.`;
45
+ }
@@ -1,9 +1,24 @@
1
1
  import { bonusDollars } from "./bonus.js";
2
2
  import { matchesFilters } from "./explore.js";
3
+ import { membershipCosts, paidMemberships } from "./memberships.js";
3
4
  import { roundCents } from "./text.js";
4
5
  import { cardValuation } from "./valuation.js";
6
+ function ineligibleText(catalog, wallet, notWith) {
7
+ const held = wallet.cards
8
+ .filter((c) => notWith.includes(c.id))
9
+ .map((c) => c.checkout_label ??
10
+ catalog.cards.find((k) => k.id === c.id)?.name ??
11
+ c.id);
12
+ return held.length === 0
13
+ ? null
14
+ : `Likely not eligible: you hold ${held.join(' and ')}, and the issuer's terms rule out its cardholders.`;
15
+ }
5
16
  export function compareOffers(catalog, wallet, filters, today) {
6
17
  const held = new Set(wallet.cards.map((c) => c.id));
18
+ const paid = paidMemberships(catalog, {
19
+ memberships: wallet.memberships,
20
+ heldCards: [...held],
21
+ });
7
22
  const offers = [];
8
23
  const withoutOffer = [];
9
24
  for (const card of catalog.cards) {
@@ -22,6 +37,7 @@ export function compareOffers(catalog, wallet, filters, today) {
22
37
  const value = bonusDollars(card, valuation.centsPerUnit, offer.points, offer.dollars) ??
23
38
  0;
24
39
  const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
40
+ const membershipFee = membershipCosts([card], paid)[0]?.annualFee ?? 0;
25
41
  offers.push({
26
42
  cardId: card.id,
27
43
  cardName: card.name,
@@ -39,13 +55,17 @@ export function compareOffers(catalog, wallet, filters, today) {
39
55
  annualFee: card.annual_fee ?? null,
40
56
  firstYearFee,
41
57
  bonusValue: roundCents(value),
42
- firstYearValue: roundCents(value - (firstYearFee ?? 0)),
58
+ membershipFee,
59
+ ineligible: ineligibleText(catalog, wallet, offer.not_with ?? []),
60
+ firstYearValue: roundCents(value - (firstYearFee ?? 0) - membershipFee),
43
61
  held: held.has(card.id),
44
62
  lastVerified: card.last_verified,
45
63
  verifiedBy: card.verified_by,
46
64
  });
47
65
  }
48
- offers.sort((a, b) => b.firstYearValue - a.firstYearValue || a.cardId.localeCompare(b.cardId));
66
+ offers.sort((a, b) => Number(a.ineligible !== null) - Number(b.ineligible !== null) ||
67
+ b.firstYearValue - a.firstYearValue ||
68
+ a.cardId.localeCompare(b.cardId));
49
69
  const notes = [
50
70
  `Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
51
71
  "Each offer is the public one on the issuer's page on the date shown, checked weekly. Yours may differ: issuers test and target offers, so use the one shown to you when you apply. Issuers also limit who can get a bonus; each card's note says how.",
@@ -3,17 +3,15 @@ import { bonusStatus } from "./bonus.js";
3
3
  import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
4
4
  import { daysBetween } from "./periods.js";
5
5
  import { rotationGap } from "./rotation.js";
6
- import { categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, num, pointValuesText, } from "./text.js";
6
+ import { categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pointValuesText, shortDate, } from "./text.js";
7
7
  import { cardValuation } from "./valuation.js";
8
+ // An on-pace welcome bonus card is mentioned when it's this close to the
9
+ // best card: within a point, and at least three quarters of its rate.
10
+ const CLOSE_PCT = 1;
11
+ const CLOSE_SHARE = 0.75;
8
12
  const EPSILON = 1e-9;
9
13
  export const STALE_AFTER_DAYS = 180;
10
14
  const NEAR_CAP_SHARE = 0.2;
11
- const NETWORK_NAMES = {
12
- visa: 'Visa',
13
- mastercard: 'Mastercard',
14
- amex: 'American Express',
15
- discover: 'Discover',
16
- };
17
15
  const PERIOD_WORDS = {
18
16
  month: 'monthly',
19
17
  quarter: 'quarterly',
@@ -23,6 +21,11 @@ const PERIOD_WORDS = {
23
21
  const displayName = (e) => e.held.checkout_label ?? e.card.name;
24
22
  // "Worth X or more" must hold at X itself, so round up; "less than X" rounds down.
25
23
  const roundUp = (n) => String(Math.ceil(n * 100 - EPSILON) / 100);
24
+ // At exactly the break-even value the two cards tie, so only an exact
25
+ // threshold reads "more than".
26
+ const worthAbove = (n) => Math.abs(n * 100 - Math.round(n * 100)) < EPSILON
27
+ ? `more than ${num(n)}¢`
28
+ : `${roundUp(n)}¢ or more`;
26
29
  const roundDown = (n) => String(Math.floor(n * 100 + EPSILON) / 100);
27
30
  function compareEntries(a, b) {
28
31
  const byReturn = b.effectiveReturnPct - a.effectiveReturnPct;
@@ -149,7 +152,7 @@ function warningsFor(catalog, entry, purchase) {
149
152
  warnings.push(`Your chosen categories are set for ${evaluation.staleChoice}, not the current period, so chosen-category bonuses don't apply until you update them.`);
150
153
  }
151
154
  for (const rule of evaluation.needsActivation) {
152
- warnings.push(`Activate "${rule.id}" with the issuer to earn ${earnText(catalog, rule.rate, valuation.currency)} here.`);
155
+ warnings.push(`Activate this bonus with the issuer (it's free) to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
153
156
  }
154
157
  return warnings;
155
158
  }
@@ -218,7 +221,7 @@ function sensitivity(catalog, entries) {
218
221
  if (o.range && oRate > 0) {
219
222
  const threshold = winner.effectiveReturnPct / oRate;
220
223
  if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
221
- consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, o.currency)} points are worth ${roundUp(threshold)}¢ or more (you value them at ${num(o.centsPerUnit)}¢).`);
224
+ consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, o.currency)} points are worth ${worthAbove(threshold)} (you value them at ${num(o.centsPerUnit)}¢).`);
222
225
  }
223
226
  }
224
227
  }
@@ -259,7 +262,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
259
262
  const warnings = [];
260
263
  const notes = [];
261
264
  if (merchant.source === 'none') {
262
- warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply. A more specific name may find a bonus category.`);
265
+ warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply. If you know what kind of business it is, give its category (like dining or grocery) to find bonus rates.`);
263
266
  }
264
267
  if (merchant.source === 'caller') {
265
268
  warnings.push(`"${merchant.input}" isn't in earnrate's merchant list, so it uses the category given with the request. Check how it codes on your statement.`);
@@ -274,15 +277,38 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
274
277
  const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
275
278
  warnings.push(`${merchant.resolvedName} may code as ${alternatives.join(' or ')} instead: ${merchant.ambiguity.note}`);
276
279
  for (const category of merchant.ambiguity.mayCodeAs) {
277
- const alt = rankEntries(catalog, wallet, purchase, merchant, category)
278
- .entries[0];
279
- if (alt && winner && alt.card.id !== winner.card.id) {
280
- warnings.push(`If it codes as ${categoryPhrase(catalog, category)}, ${displayName(alt)} wins (${num(alt.effectiveReturnPct)}%).`);
281
- }
280
+ const alts = rankEntries(catalog, wallet, purchase, merchant, category).entries;
281
+ const alt = alts[0];
282
+ if (!alt || !winner)
283
+ continue;
284
+ const as = `If it codes as ${categoryPhrase(catalog, category)}`;
285
+ // The same pick there may share the top with another card.
286
+ const twin = alts.find((e) => e.card.id !== winner.card.id &&
287
+ alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
288
+ if (alt.card.id !== winner.card.id)
289
+ warnings.push(`${as}, ${displayName(alt)} wins (${num(alt.effectiveReturnPct)}%).`);
290
+ else if (twin)
291
+ warnings.push(`${as}, ${displayName(twin)} earns as much (${num(twin.effectiveReturnPct)}%).`);
282
292
  }
283
293
  }
284
294
  if (!winner) {
285
295
  warnings.push(`None of your cards work at ${merchant.resolvedName}.`);
296
+ // Only where the merchant's data says what its website takes: without
297
+ // that, every network is assumed, and a gas pump has no website.
298
+ const record = [
299
+ ...catalog.merchants.overrides,
300
+ ...catalog.merchants.seed,
301
+ ].find((m) => m.id === merchant.merchantId);
302
+ const online = merchant.channel === 'in_store' &&
303
+ merchant.merchantId !== null &&
304
+ record?.online?.accepts !== undefined
305
+ ? resolveMerchantById(catalog, merchant.merchantId, 'online')
306
+ : null;
307
+ const alt = online
308
+ ? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
309
+ : undefined;
310
+ if (alt)
311
+ warnings.push(`Ordering online instead? ${displayName(alt)} works there (${num(alt.effectiveReturnPct)}%).`);
286
312
  }
287
313
  else if (merchant.accepts === null &&
288
314
  (winner.card.network === 'amex' || winner.card.network === 'discover')) {
@@ -300,9 +326,6 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
300
326
  warnings.push(`${name}'s rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them. If this purchase is in that period's categories, ${name} may earn more. An earnrate update adds them once the issuer announces them.`);
301
327
  }
302
328
  }
303
- const tie = tieNote(entries);
304
- if (tie)
305
- notes.push(tie);
306
329
  for (const id of merchant.bookedVia) {
307
330
  const via = resolveMerchantById(catalog, id, purchase.channel);
308
331
  if (!via || !winner)
@@ -313,19 +336,40 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
313
336
  notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${num(alt.effectiveReturnPct)}%.`);
314
337
  }
315
338
  }
316
- const bonus = bonusPick(catalog, wallet, entries, purchase.date);
339
+ const open = bonusPick(catalog, wallet, entries, purchase.date);
340
+ // Behind pace, the bonus card takes the pick; on pace, only where it earns
341
+ // as much as the best card (it costs nothing there). Within a point, it's
342
+ // mentioned; otherwise the bonus isn't this purchase's business.
343
+ const gap = open && winner
344
+ ? winner.effectiveReturnPct - open.entry.effectiveReturnPct
345
+ : Infinity;
346
+ // Already the best card, it's the pick anyway: a tie needs no note.
347
+ const isBest = open !== null && open.entry === winner;
348
+ const ties = !isBest && gap <= EPSILON;
349
+ const bonus = open && (open.behind || ties) ? open : null;
350
+ const best = winner?.effectiveReturnPct ?? 0;
351
+ if (open &&
352
+ !bonus &&
353
+ gap <= CLOSE_PCT &&
354
+ open.entry.effectiveReturnPct >= best * CLOSE_SHARE) {
355
+ notes.push(`${displayName(open.entry)} earns ${num(open.entry.effectiveReturnPct)}% here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${num(best)}%, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
356
+ }
317
357
  const chosen = bonus?.entry ?? winner;
358
+ // A tie among the top cards doesn't matter when a welcome bonus decides.
359
+ const tie = bonus ? null : tieNote(entries);
360
+ if (tie)
361
+ notes.push(tie);
318
362
  const top = ranked.find((r) => r.cardId === chosen?.card.id);
319
- const best = ranked[0];
363
+ const first = ranked[0];
320
364
  let bonusResult = null;
321
- if (bonus && top && best) {
365
+ if (bonus && top && first) {
322
366
  const { status } = bonus;
323
367
  const remaining = status.remaining ?? 0;
324
368
  const deadline = status.deadline ?? purchase.date;
325
369
  const daysLeft = status.daysLeft ?? 0;
326
- const costHere = top.cardId === best.cardId || top.earned === null || best.earned === null
370
+ const costHere = ties || top.earned === null || first.earned === null
327
371
  ? null
328
- : Math.round((best.earned - top.earned) * 100) / 100;
372
+ : Math.round((first.earned - top.earned) * 100) / 100;
329
373
  bonusResult = {
330
374
  cardId: top.cardId,
331
375
  checkoutLabel: top.checkoutLabel,
@@ -341,11 +385,13 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
341
385
  const worth = status.value === null
342
386
  ? ''
343
387
  : `, worth about ${money(Math.round(status.value))}`;
344
- notes.unshift(`You're working on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${top.cardId === best.cardId
345
- ? ''
346
- : costHere === null
347
- ? ` It earns ${num(top.effectiveReturnPct)}% here against ${num(best.effectiveReturnPct)}% on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
348
- : ` Using it here earns ${money(costHere)} less than ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`}`);
388
+ notes.unshift(ties
389
+ ? `${displayName(bonus.entry)} earns as much here as any of your cards, so it's the pick: it helps its welcome bonus, ${money(remaining)} more to spend by ${deadline}${worth}.`
390
+ : `You're behind pace on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
391
+ ? ''
392
+ : costHere === null
393
+ ? ` It earns ${num(top.effectiveReturnPct)}% here against ${num(first.effectiveReturnPct)}% on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
394
+ : ` Using it here earns ${money(costHere)} less than ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`}`);
349
395
  }
350
396
  let confidence = merchant.confidence;
351
397
  if (confidence === 'high' && chosen && top) {
@@ -373,17 +419,31 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
373
419
  bonus: bonusResult,
374
420
  };
375
421
  }
422
+ // Behind a steady pace: less spent than an even share of the spending needed
423
+ // for the days gone by. Ahead of it, the bonus will likely be met anyway, so
424
+ // it doesn't justify a card that earns less.
425
+ function behindPace(status, date) {
426
+ if (status.spend === null || status.deadline === null)
427
+ return false;
428
+ const window = Math.max(1, daysBetween(status.opened, status.deadline));
429
+ const elapsed = Math.min(window, daysBetween(status.opened, date));
430
+ return status.spent < (status.spend * elapsed) / window;
431
+ }
376
432
  // The open welcome bonus that most needs this purchase: the one with the most
377
- // spend still needed per day left.
433
+ // spend still needed per day left. Only a bonus behind pace takes the pick
434
+ // (`behind`); one on pace is only mentioned.
378
435
  function bonusPick(catalog, wallet, entries, date) {
379
436
  let pick = null;
380
437
  for (const entry of entries) {
381
438
  const status = bonusStatus(catalog, wallet, entry.held, date);
382
439
  if (status?.state !== 'open' || date < status.opened)
383
440
  continue;
441
+ const behind = behindPace(status, date);
384
442
  const pace = (status.remaining ?? 0) / Math.max(1, status.daysLeft ?? 1);
385
- if (!pick || pace > pick.pace)
386
- pick = { entry, status, pace };
443
+ if (!pick ||
444
+ (behind && !pick.behind) ||
445
+ (behind === pick.behind && pace > pick.pace))
446
+ pick = { entry, status, behind, pace };
387
447
  }
388
448
  return pick;
389
449
  }
@@ -45,11 +45,11 @@ function matcher(card) {
45
45
  (token.length >= 5 &&
46
46
  haystack.some((w) => editDistance(w, token) <= (token.length >= 8 ? 2 : 1)));
47
47
  }
48
- function needsAnniversary(card) {
48
+ export function needsAnniversary(card) {
49
49
  return (card.caps.some((c) => c.period === 'cardmember_year') ||
50
50
  card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year'));
51
51
  }
52
- function activationRules(card, today) {
52
+ export function activationRules(card, today) {
53
53
  return card.rules
54
54
  .filter((r) => r.requires?.activation === true &&
55
55
  (r.valid_to === undefined || r.valid_to >= today) &&
@@ -57,13 +57,24 @@ function activationRules(card, today) {
57
57
  daysBetween(today, r.valid_from) <= ACTIVATION_LOOKAHEAD_DAYS))
58
58
  .map((r) => r.id);
59
59
  }
60
+ // Bank of America Preferred Rewards tiers: a person holds one at a time, so
61
+ // with one saved the others aren't missing.
62
+ const TIER = /^bofa-rewards-/;
60
63
  export function membershipsUsed(card) {
61
64
  return [
62
65
  ...new Set(card.rules.flatMap((r) => r.requires?.membership === undefined ? [] : [r.requires.membership])),
63
66
  ];
64
67
  }
68
+ // Memberships that matter to a card: ones its rates need, and one the card
69
+ // itself needs (a Costco membership for Costco Anywhere).
70
+ export function membershipsFor(card) {
71
+ return [
72
+ ...membershipsUsed(card),
73
+ ...(card.requires_membership ? [card.requires_membership.id] : []),
74
+ ];
75
+ }
65
76
  export function knownMemberships(catalog) {
66
- return [...new Set(catalog.cards.flatMap(membershipsUsed))].sort();
77
+ return [...new Set(catalog.cards.flatMap(membershipsFor))].sort();
67
78
  }
68
79
  export function cardInfo(card, wallet, today) {
69
80
  return {
@@ -84,6 +95,13 @@ export function cardInfo(card, wallet, today) {
84
95
  default: c.default ?? null,
85
96
  })),
86
97
  memberships: membershipsUsed(card),
98
+ requiresMembership: card.requires_membership
99
+ ? {
100
+ id: card.requires_membership.id,
101
+ name: card.requires_membership.name,
102
+ annualFee: card.requires_membership.annual_fee,
103
+ }
104
+ : null,
87
105
  activation: activationRules(card, today),
88
106
  needsAnniversary: needsAnniversary(card),
89
107
  };
@@ -137,7 +155,8 @@ export function setupNotes(catalog, wallet, held, today) {
137
155
  : `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
138
156
  });
139
157
  }
140
- const missing = membershipsUsed(card).filter((m) => !wallet.memberships.includes(m));
158
+ const hasTier = wallet.memberships.some((m) => TIER.test(m));
159
+ const missing = membershipsUsed(card).filter((m) => !wallet.memberships.includes(m) && !(hasTier && TIER.test(m)));
141
160
  if (missing.length > 0) {
142
161
  notes.push({
143
162
  kind: 'membership',
@@ -1,6 +1,7 @@
1
1
  import { resolveMerchant } from "../merchants/resolve.js";
2
2
  import { evaluateCard, purchaseContext, usableAt } from "./evaluate.js";
3
3
  import { categoryResolution, findCategory, idealHolding, matchesFilters, } from "./explore.js";
4
+ import { membershipCosts, membershipNote, paidMemberships, } from "./memberships.js";
4
5
  import { choicePeriod, periodKey } from "./periods.js";
5
6
  import { applyBooking, capBooking } from "./record.js";
6
7
  import { rotationGap } from "./rotation.js";
@@ -44,7 +45,10 @@ function withCurrentPicks(card, held, date) {
44
45
  // Spends `lines` every month for a year. Each dollar goes to the card that
45
46
  // earns the most on it right then, so spend moves to another card once a
46
47
  // cap runs out.
47
- export function simulateWallet(catalog, wallet, lines, start) {
48
+ export function simulateWallet(catalog, wallet, lines, start,
49
+ // Cards the person already has: their first year is behind them and any
50
+ // membership they need is paid for.
51
+ held = []) {
48
52
  const cards = wallet.cards.map((held) => {
49
53
  const card = catalog.cards.find((c) => c.id === held.id);
50
54
  if (!card)
@@ -120,7 +124,16 @@ export function simulateWallet(catalog, wallet, lines, start) {
120
124
  });
121
125
  const rewards = roundCents(lineResults.reduce((sum, l) => sum + l.earned, 0));
122
126
  const annualFees = cards.reduce((sum, c) => sum + (c.card.annual_fee ?? 0), 0);
127
+ const paid = paidMemberships(catalog, {
128
+ memberships: wallet.memberships,
129
+ heldCards: held,
130
+ merchantIds: lines.map((l) => l.merchant.merchantId),
131
+ });
132
+ const memberships = membershipCosts(cards.map((c) => c.card), paid);
133
+ const membershipFees = memberships.reduce((sum, m) => sum + m.annualFee, 0);
123
134
  const notes = [];
135
+ for (const m of memberships)
136
+ notes.push(membershipNote(catalog, m));
124
137
  const dates = monthDates(start);
125
138
  for (const { card } of cards) {
126
139
  const gaps = [
@@ -130,7 +143,8 @@ export function simulateWallet(catalog, wallet, lines, start) {
130
143
  notes.push(`${card.name}'s rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
131
144
  if (card.annual_fee === undefined)
132
145
  notes.push(`${card.name}'s annual fee isn't on file, so none is subtracted.`);
133
- else if (card.first_year_fee !== undefined &&
146
+ else if (!held.includes(card.id) &&
147
+ card.first_year_fee !== undefined &&
134
148
  card.first_year_fee !== card.annual_fee)
135
149
  notes.push(`${card.name}'s fee is $${String(card.first_year_fee)} the first year; the yearly figure uses the $${String(card.annual_fee)} that follows.`);
136
150
  }
@@ -142,7 +156,9 @@ export function simulateWallet(catalog, wallet, lines, start) {
142
156
  spend: roundCents(lineResults.reduce((sum, l) => sum + l.spend, 0)),
143
157
  rewards,
144
158
  annualFees,
145
- net: roundCents(rewards - annualFees),
159
+ memberships,
160
+ membershipFees,
161
+ net: roundCents(rewards - annualFees - membershipFees),
146
162
  lines: lineResults,
147
163
  notes,
148
164
  };
@@ -192,7 +208,7 @@ function walletWith(base, card, picks, options) {
192
208
  function bestSimulation(catalog, base, card, lines, options) {
193
209
  let best = null;
194
210
  for (const picks of candidatePicks(catalog, card, lines, options.memberships, options.start)) {
195
- const sim = simulateWallet(catalog, walletWith(base, card, picks, options), lines, options.start);
211
+ const sim = simulateWallet(catalog, walletWith(base, card, picks, options), lines, options.start, options.held);
196
212
  if (!best || sim.net > best.net + EPSILON)
197
213
  best = sim;
198
214
  }
@@ -200,7 +216,8 @@ function bestSimulation(catalog, base, card, lines, options) {
200
216
  throw new Error(`no simulation for ${card.id}`);
201
217
  return best;
202
218
  }
203
- const byNet = (a, b) => b.net - a.net || a.annualFees - b.annualFees;
219
+ const costs = (s) => s.annualFees + s.membershipFees;
220
+ const byNet = (a, b) => b.net - a.net || costs(a) - costs(b);
204
221
  // Pairs that tie and share their first card differ only in an
205
222
  // interchangeable second card (several flat 2% cards), so they fold into one.
206
223
  function foldTies(pairs) {
@@ -208,7 +225,7 @@ function foldTies(pairs) {
208
225
  for (const pair of pairs) {
209
226
  const [a, b] = pair.cards;
210
227
  const twin = folded.find((f) => Math.abs(f.net - pair.net) < 0.005 &&
211
- f.annualFees === pair.annualFees &&
228
+ costs(f) === costs(pair) &&
212
229
  f.cards.some((c) => c.cardId === a?.cardId || c.cardId === b?.cardId));
213
230
  if (!twin) {
214
231
  folded.push({ ...pair, alternatives: [] });
@@ -275,8 +292,9 @@ function projectedWallet(wallet, catalog, start) {
275
292
  }
276
293
  export function simulateAdditions(catalog, wallet, lines, options) {
277
294
  const projected = projectedWallet(wallet, catalog, options.start);
278
- const current = simulateWallet(catalog, projected, lines, options.start);
279
- const held = new Set(wallet.cards.map((c) => c.id));
295
+ const heldIds = wallet.cards.map((c) => c.id);
296
+ const current = simulateWallet(catalog, projected, lines, options.start, heldIds);
297
+ const held = new Set(heldIds);
280
298
  const additions = [];
281
299
  for (const card of catalog.cards) {
282
300
  if (held.has(card.id) || !matchesFilters(card, options.filters))
@@ -285,19 +303,23 @@ export function simulateAdditions(catalog, wallet, lines, options) {
285
303
  ...options,
286
304
  memberships: wallet.memberships,
287
305
  valuations: wallet.valuations,
306
+ held: heldIds,
288
307
  });
289
308
  const extraRewards = roundCents(simulation.rewards - current.rewards);
290
309
  const annualFee = card.annual_fee ?? 0;
310
+ const membershipFee = roundCents(simulation.membershipFees - current.membershipFees);
291
311
  additions.push({
292
312
  cardId: card.id,
293
313
  picks: simulation.cards.at(-1)?.picks ?? {},
294
314
  extraRewards,
295
315
  annualFee,
296
- gain: roundCents(extraRewards - annualFee),
316
+ membershipFee,
317
+ gain: roundCents(extraRewards - annualFee - membershipFee),
297
318
  simulation,
298
319
  });
299
320
  }
300
- additions.sort((a, b) => b.gain - a.gain || a.annualFee - b.annualFee);
321
+ additions.sort((a, b) => b.gain - a.gain ||
322
+ a.annualFee + a.membershipFee - (b.annualFee + b.membershipFee));
301
323
  return { current, additions };
302
324
  }
303
325
  // Rough monthly amounts for a U.S. household: the starting point when someone