earnrate 0.2.4 → 0.2.6

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 (116) hide show
  1. package/README.md +18 -15
  2. package/data/CHANGELOG.md +79 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-business-plus.yaml +14 -6
  5. package/data/cards/amex-blue-cash-everyday.yaml +12 -3
  6. package/data/cards/amex-blue-cash-preferred.yaml +17 -3
  7. package/data/cards/amex-gold.yaml +23 -3
  8. package/data/cards/amex-platinum.yaml +19 -3
  9. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +15 -3
  10. package/data/cards/bank-of-america-atmos-rewards-business.yaml +19 -4
  11. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +9 -2
  12. package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -4
  13. package/data/cards/bilt-obsidian.yaml +4 -3
  14. package/data/cards/capital-one-kohls-card.yaml +11 -2
  15. package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
  16. package/data/cards/capital-one-savor.yaml +32 -10
  17. package/data/cards/capital-one-venture-x.yaml +19 -4
  18. package/data/cards/capital-one-venture.yaml +19 -5
  19. package/data/cards/chase-aeroplan.yaml +15 -3
  20. package/data/cards/chase-freedom-flex.yaml +50 -10
  21. package/data/cards/chase-freedom-unlimited.yaml +6 -2
  22. package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
  23. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
  24. package/data/cards/chase-ihg-one-rewards-premier.yaml +19 -2
  25. package/data/cards/chase-ihg-one-rewards.yaml +15 -2
  26. package/data/cards/chase-ink-business-cash.yaml +7 -3
  27. package/data/cards/chase-ink-business-preferred.yaml +9 -2
  28. package/data/cards/chase-prime-visa.yaml +24 -5
  29. package/data/cards/chase-sapphire-preferred.yaml +59 -6
  30. package/data/cards/chase-sapphire-reserve.yaml +12 -2
  31. package/data/cards/chase-southwest-performance-business.yaml +22 -4
  32. package/data/cards/chase-southwest-plus.yaml +13 -3
  33. package/data/cards/chase-southwest-premier-business.yaml +22 -5
  34. package/data/cards/chase-southwest-premier.yaml +13 -3
  35. package/data/cards/chase-southwest-priority.yaml +18 -2
  36. package/data/cards/chase-united-business.yaml +5 -2
  37. package/data/cards/chase-united-club-business.yaml +2 -2
  38. package/data/cards/chase-united-club.yaml +17 -4
  39. package/data/cards/chase-united-explorer.yaml +5 -2
  40. package/data/cards/chase-united-gateway.yaml +2 -2
  41. package/data/cards/chase-united-quest.yaml +12 -3
  42. package/data/cards/citi-aadvantage-business.yaml +10 -4
  43. package/data/cards/citi-aadvantage-executive.yaml +8 -8
  44. package/data/cards/citi-aadvantage-mileup.yaml +7 -4
  45. package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
  46. package/data/cards/citi-costco-anywhere-visa-business.yaml +6 -9
  47. package/data/cards/citi-costco-anywhere-visa.yaml +7 -10
  48. package/data/cards/citi-custom-cash.yaml +43 -12
  49. package/data/cards/citi-double-cash.yaml +9 -3
  50. package/data/cards/citi-my-best-buy-visa.yaml +6 -3
  51. package/data/cards/citi-strata-elite.yaml +12 -2
  52. package/data/cards/citi-strata-premier.yaml +24 -2
  53. package/data/cards/sofi-unlimited-2.yaml +17 -3
  54. package/data/cards/td-bank-nordstrom-credit-card.yaml +16 -3
  55. package/data/cards/td-bank-nordstrom-visa.yaml +23 -3
  56. package/data/cards/td-bank-target-circle-card.yaml +13 -4
  57. package/data/cards/usbank-cash-plus.yaml +29 -7
  58. package/data/cards/wells-fargo-active-cash.yaml +9 -2
  59. package/data/cards/wells-fargo-autograph.yaml +31 -7
  60. package/data/categories.yaml +12 -0
  61. package/data/currencies.yaml +2 -0
  62. package/data/issuers.yaml +32 -0
  63. package/data/memberships.yaml +33 -0
  64. package/data/merchants/overrides.yaml +25 -0
  65. package/data/merchants/seed.yaml +7 -0
  66. package/dist/cli/bin.js +10 -1
  67. package/dist/cli/cheatsheet.js +11 -6
  68. package/dist/cli/explore.js +17 -23
  69. package/dist/cli/import.js +10 -10
  70. package/dist/cli/offers.js +19 -8
  71. package/dist/cli/run.js +31 -20
  72. package/dist/cli/wallet.js +50 -5
  73. package/dist/data/file-store.js +9 -7
  74. package/dist/data/integrity.js +22 -2
  75. package/dist/data/load.js +13 -2
  76. package/dist/data/store.js +13 -22
  77. package/dist/data/wallet-edit.js +108 -8
  78. package/dist/engine/cheat-sheet.js +15 -19
  79. package/dist/engine/compare.js +10 -0
  80. package/dist/engine/evaluate.js +2 -4
  81. package/dist/engine/explore.js +13 -56
  82. package/dist/engine/memberships.js +41 -15
  83. package/dist/engine/offers.js +18 -5
  84. package/dist/engine/periods.js +18 -0
  85. package/dist/engine/profiles.js +8 -0
  86. package/dist/engine/recommend.js +23 -37
  87. package/dist/engine/setup.js +28 -40
  88. package/dist/engine/simulate.js +11 -10
  89. package/dist/engine/text.js +49 -23
  90. package/dist/engine/valuation.js +1 -1
  91. package/dist/engine/wallet-change.js +26 -11
  92. package/dist/engine/wallet-summary.js +85 -31
  93. package/dist/format/card-text.js +19 -36
  94. package/dist/format/cards.js +3 -3
  95. package/dist/format/explore.js +15 -16
  96. package/dist/format/offers.js +10 -15
  97. package/dist/format/recommendation.js +6 -6
  98. package/dist/format/wallet.js +5 -2
  99. package/dist/import/analyze.js +2 -1
  100. package/dist/mcp/context.js +61 -13
  101. package/dist/mcp/inputs.js +16 -7
  102. package/dist/mcp/schemas.js +9 -0
  103. package/dist/mcp/server.js +8 -1
  104. package/dist/mcp/tools/account.js +45 -17
  105. package/dist/mcp/tools/answers.js +84 -29
  106. package/dist/mcp/tools/wallet.js +109 -22
  107. package/dist/merchants/cache.js +0 -10
  108. package/dist/merchants/resolve.js +81 -4
  109. package/dist/schema/card.js +8 -12
  110. package/dist/schema/category.js +4 -0
  111. package/dist/schema/currency.js +2 -0
  112. package/dist/schema/issuer.js +8 -0
  113. package/dist/schema/membership.js +15 -0
  114. package/dist/schema/merchant.js +4 -0
  115. package/dist/schema/wallet.js +1 -1
  116. package/package.json +1 -1
@@ -1,13 +1,14 @@
1
- import { resolveMerchant } from "../merchants/resolve.js";
1
+ import { categoryResolution, resolvePlace } from "../merchants/resolve.js";
2
2
  import { evaluateCard, purchaseContext } from "./evaluate.js";
3
- import { membershipNeeded, paidMemberships } from "./memberships.js";
3
+ import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
4
4
  import { choicePeriod, periodKey } from "./periods.js";
5
5
  import { recommend } from "./recommend.js";
6
6
  import { rotationGap } from "./rotation.js";
7
7
  import { membershipsUsed } from "./setup.js";
8
- import { categoryPhrase, choiceName, codesAs, earnText, membershipName, money, pointValuesText, words, } from "./text.js";
8
+ import { optionName } from "./wallet-summary.js";
9
+ import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, listed, money, PICK_PERIODS, pointValuesText, } from "./text.js";
9
10
  import { cardValuation } from "./valuation.js";
10
- const EPSILON = 1e-9;
11
+ import { EPSILON } from "./compare.js";
11
12
  export const ALL_PERSONAL_CARDS = {
12
13
  kind: 'any',
13
14
  business: false,
@@ -48,50 +49,6 @@ export function idealHolding(card, date, picks = {}) {
48
49
  : {}),
49
50
  };
50
51
  }
51
- const PICK_PERIODS = {
52
- month: 'set each month',
53
- quarter: 'picked each quarter',
54
- calendar_year: 'picked each year',
55
- until_changed: 'until you change it',
56
- };
57
- const CAP_PERIODS = {
58
- month: 'a month',
59
- quarter: 'a quarter',
60
- calendar_year: 'a calendar year',
61
- cardmember_year: 'a cardmember year',
62
- };
63
- const plainWords = (s) => s
64
- .toLowerCase()
65
- .replace(/[^a-z0-9]+/g, ' ')
66
- .trim();
67
- // A category from everyday words: an alias ("groceries") first, then the
68
- // name, then the id, so "grocery" means supermarkets rather than the parent.
69
- export function findCategory(catalog, text) {
70
- const wanted = plainWords(text);
71
- if (wanted === '')
72
- return null;
73
- const by = (key) => catalog.categories.find((c) => key(c).map(plainWords).includes(wanted))?.id;
74
- return (by((c) => c.aliases) ?? by((c) => [c.name]) ?? by((c) => [c.id]) ?? null);
75
- }
76
- export function categoryResolution(catalog, category, channel) {
77
- const found = catalog.categories.find((c) => c.id === category);
78
- if (!found)
79
- throw new Error(`unknown category '${category}'`);
80
- return {
81
- input: found.name,
82
- merchantId: null,
83
- resolvedName: found.name,
84
- category,
85
- likelyMcc: null,
86
- accepts: null,
87
- ambiguity: null,
88
- channel,
89
- confidence: 'high',
90
- source: 'category',
91
- bookedVia: [],
92
- inference: null,
93
- };
94
- }
95
52
  function walletOf(held, options) {
96
53
  return {
97
54
  valuations: options.valuations,
@@ -148,17 +105,17 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
148
105
  : null,
149
106
  };
150
107
  }
151
- function conditionsText(catalog, r) {
108
+ function conditionsText(catalog, card, r) {
152
109
  const conditions = [];
153
110
  if (r.pick) {
154
- conditions.push(`Needs ${r.pick.options.map(words).join(' or ')} as its ${choiceName(r.pick.choice)} pick (${PICK_PERIODS[r.pick.period]}).`);
111
+ conditions.push(`Needs ${r.pick.options.map((o) => optionName(catalog, card, r.pick?.choice ?? '', o)).join(' or ')} as its ${choiceName(r.pick.choice)} pick (${r.pick.period === 'until_changed' ? '' : 'picked '}${PICK_PERIODS[r.pick.period]}).`);
155
112
  }
156
113
  if (r.activation !== null)
157
114
  conditions.push('Needs its bonus activated with the issuer (free).');
158
115
  if (r.membership !== null)
159
- conditions.push(`Needs ${membershipName(r.membership)}.`);
116
+ conditions.push(`With your ${membershipName(catalog, r.membership)}.`);
160
117
  if (r.cap) {
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)}.`);
118
+ 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)}.`);
162
119
  }
163
120
  return conditions;
164
121
  }
@@ -196,7 +153,7 @@ function compareExplored(a, b) {
196
153
  export function exploreCards(catalog, query, options, resolved) {
197
154
  const merchant = resolved ??
198
155
  (query.merchant !== null
199
- ? resolveMerchant(catalog, query.merchant, query.channel)
156
+ ? resolvePlace(catalog, query.merchant, query.channel)
200
157
  : categoryResolution(catalog, query.category ?? 'other', query.channel));
201
158
  const purchase = {
202
159
  merchant: merchant.input,
@@ -243,8 +200,8 @@ export function exploreCards(catalog, query, options, resolved) {
243
200
  picks,
244
201
  requirements,
245
202
  conditions: [
246
- ...conditionsText(catalog, requirements),
247
- ...membershipNeeded(card, paid),
203
+ ...conditionsText(catalog, card, requirements),
204
+ ...membershipNeeded(catalog, card, paid),
248
205
  ],
249
206
  evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
250
207
  withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
@@ -270,7 +227,7 @@ function exploreAssumptions(catalog, merchant, ranked, options) {
270
227
  assumptions.push('Assumes each card is set up for this purchase: the right category pick, any activation done, and none of its cap used yet. A card you hold may differ.');
271
228
  assumptions.push(options.memberships.length === 0
272
229
  ? 'Assumes no memberships (like Amazon Prime). Rates that need one are listed separately.'
273
- : `Assumes these memberships: ${options.memberships.join(', ')}.`);
230
+ : `Assumes these memberships: ${listed(options.memberships.map((m) => membershipName(catalog, m)))}.`);
274
231
  const values = pointValuesText(catalog, ranked.slice(0, 10));
275
232
  if (values !== null)
276
233
  assumptions.push(values);
@@ -1,30 +1,48 @@
1
- import { money } from "./text.js";
1
+ import { money, words } from "./text.js";
2
+ export function membership(catalog, id) {
3
+ return catalog.memberships.find((m) => m.id === id);
4
+ }
5
+ export function membershipName(catalog, id) {
6
+ return membership(catalog, id)?.name ?? words(id);
7
+ }
8
+ // "a Costco membership", for a sentence.
9
+ function withArticle(name) {
10
+ return `${/^[aeiou]/i.test(name) ? 'an' : 'a'} ${name}`;
11
+ }
12
+ // Tiers of one membership: holding one, the others in its group aren't
13
+ // missing (BofA Preferred Rewards).
14
+ export function sameGroup(catalog, a, b) {
15
+ const group = membership(catalog, a)?.group;
16
+ return group !== undefined && group === membership(catalog, b)?.group;
17
+ }
2
18
  // Memberships the person already pays for: the ones they've said, the ones a
3
19
  // card they hold needs, and the ones their spending shows (only members shop
4
20
  // at Costco).
5
21
  export function paidMemberships(catalog, { memberships, heldCards = [], merchantIds = [], }) {
6
22
  const paid = new Set(memberships);
7
23
  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);
24
+ const id = card.requires_membership;
25
+ if (id !== undefined && heldCards.includes(card.id))
26
+ paid.add(id);
14
27
  }
28
+ for (const m of catalog.memberships)
29
+ if (m.members_shop_at.some((id) => merchantIds.includes(id)))
30
+ paid.add(m.id);
15
31
  return paid;
16
32
  }
17
33
  // What these cards' memberships add, once each, leaving out the paid ones.
18
- export function membershipCosts(cards, paid) {
34
+ export function membershipCosts(catalog, cards, paid) {
19
35
  const costs = new Map();
20
36
  for (const card of cards) {
21
- const m = card.requires_membership;
37
+ const m = card.requires_membership === undefined
38
+ ? undefined
39
+ : membership(catalog, card.requires_membership);
22
40
  if (!m || paid.has(m.id))
23
41
  continue;
24
42
  const cost = costs.get(m.id) ?? {
25
43
  id: m.id,
26
44
  name: m.name,
27
- annualFee: m.annual_fee,
45
+ annualFee: m.annual_fee ?? 0,
28
46
  cardIds: [],
29
47
  };
30
48
  cost.cardIds.push(card.id);
@@ -32,14 +50,22 @@ export function membershipCosts(cards, paid) {
32
50
  }
33
51
  return [...costs.values()];
34
52
  }
53
+ // "a Costco membership ($65 a year)"
54
+ export function membershipText(catalog, id) {
55
+ const m = membership(catalog, id);
56
+ const name = withArticle(m?.name ?? words(id));
57
+ return m?.annual_fee === undefined
58
+ ? name
59
+ : `${name} (${money(m.annual_fee)} a year)`;
60
+ }
35
61
  // 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).`]
62
+ export function membershipNeeded(catalog, card, paid) {
63
+ const id = card.requires_membership;
64
+ return id !== undefined && !paid.has(id)
65
+ ? [`Needs ${membershipText(catalog, id)}.`]
40
66
  : [];
41
67
  }
42
68
  export function membershipNote(catalog, m) {
43
69
  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.`;
70
+ return `${names.join(' and ')} ${names.length === 1 ? 'needs' : 'need'} ${withArticle(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
71
  }
@@ -1,7 +1,8 @@
1
1
  import { bonusDollars } from "./bonus.js";
2
2
  import { matchesFilters } from "./explore.js";
3
3
  import { membershipCosts, paidMemberships } from "./memberships.js";
4
- import { roundCents } from "./text.js";
4
+ import { inEffect } from "./periods.js";
5
+ import { money, roundCents } from "./text.js";
5
6
  import { cardValuation } from "./valuation.js";
6
7
  function ineligibleText(catalog, wallet, notWith) {
7
8
  const held = wallet.cards
@@ -13,6 +14,18 @@ function ineligibleText(catalog, wallet, notWith) {
13
14
  ? null
14
15
  : `Likely not eligible: you hold ${held.join(' and ')}, and the issuer's terms rule out its cardholders.`;
15
16
  }
17
+ export const OFFERS_MAY_DIFFER = 'Yours may differ: issuers test and target offers, so use the one shown to you when you apply.';
18
+ // "Up to 60,000 Chase Ultimate Rewards after $4,000 in purchases within 3
19
+ // months": one wording for the site, the search pages and the CLI.
20
+ export function offerTerms(o, currencyName) {
21
+ const amount = o.points !== null && o.points !== undefined
22
+ ? `${o.points.toLocaleString('en-US')} ${currencyName}`
23
+ : money(o.dollars ?? 0);
24
+ const spend = o.spend > 0
25
+ ? `after ${money(o.spend)} in purchases within ${String(o.months)} month${o.months === 1 ? '' : 's'}`
26
+ : 'with no spending required';
27
+ return `${o.varies ? 'Up to ' : ''}${amount} ${spend}`;
28
+ }
16
29
  export function compareOffers(catalog, wallet, filters, today) {
17
30
  const held = new Set(wallet.cards.map((c) => c.id));
18
31
  const paid = paidMemberships(catalog, {
@@ -29,7 +42,7 @@ export function compareOffers(catalog, wallet, filters, today) {
29
42
  if (!matchesFilters(card, { ...filters, issuer: null, includeClosed: true }))
30
43
  continue;
31
44
  const offer = card.welcome_offer;
32
- if (!offer || (offer.valid_to !== undefined && offer.valid_to < today)) {
45
+ if (!offer || !inEffect(offer, today)) {
33
46
  withoutOffer.push(card.id);
34
47
  continue;
35
48
  }
@@ -37,7 +50,7 @@ export function compareOffers(catalog, wallet, filters, today) {
37
50
  const value = bonusDollars(card, valuation.centsPerUnit, offer.points, offer.dollars) ??
38
51
  0;
39
52
  const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
40
- const membershipFee = membershipCosts([card], paid)[0]?.annualFee ?? 0;
53
+ const membershipFee = membershipCosts(catalog, [card], paid)[0]?.annualFee ?? 0;
41
54
  offers.push({
42
55
  cardId: card.id,
43
56
  cardName: card.name,
@@ -68,8 +81,8 @@ export function compareOffers(catalog, wallet, filters, today) {
68
81
  a.cardId.localeCompare(b.cardId));
69
82
  const notes = [
70
83
  `Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
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.",
72
- "First-year value is the bonus at your point values minus the first year's annual fee. It leaves out what the card earns on your spending and any credits or perks.",
84
+ `Each offer is the public one on the issuer's page on the date shown, checked weekly. ${OFFERS_MAY_DIFFER} Issuers also limit who can get a bonus; each card's note says how.`,
85
+ "First-year value is the bonus at your point values minus the first year's annual fee and any membership the card needs that you don't have. It leaves out what the card earns on your spending and any credits or perks.",
73
86
  ];
74
87
  return { offers, withoutOffer, notes };
75
88
  }
@@ -38,3 +38,21 @@ export function choicePeriod(card) {
38
38
  ];
39
39
  return periods.length > 1 ? null : (periods[0] ?? 'calendar_year');
40
40
  }
41
+ // Whether a dated rule or offer is in effect on `date`; an open end has no
42
+ // limit on that side.
43
+ export const inEffect = (window, date) => (window.valid_from === undefined || window.valid_from <= date) &&
44
+ (window.valid_to === undefined || window.valid_to >= date);
45
+ // The last day of a period key ("2026-Q4", "2026-10", "2026").
46
+ export function periodEnd(period) {
47
+ const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
48
+ if (quarter) {
49
+ const end = ['03-31', '06-30', '09-30', '12-31'][Number(quarter[2]) - 1];
50
+ return `${quarter[1] ?? ''}-${end ?? ''}`;
51
+ }
52
+ const month = /^(\d{4})-(\d{2})$/.exec(period);
53
+ if (month) {
54
+ const last = new Date(Date.UTC(Number(month[1]), Number(month[2]), 0));
55
+ return last.toISOString().slice(0, 10);
56
+ }
57
+ return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
58
+ }
@@ -2,6 +2,14 @@ export const VALUATION_PROFILES = {
2
2
  flat: { MR: 1.0, UR: 1.0 },
3
3
  'points-heavy': { MR: 1.7, UR: 1.7 },
4
4
  };
5
+ // "MR and UR at 1.7¢": what a profile sets, for help text.
6
+ export function profileText(profile) {
7
+ const values = Object.entries(VALUATION_PROFILES[profile]);
8
+ const cents = [...new Set(values.map(([, v]) => v))];
9
+ return cents.length === 1
10
+ ? `${values.map(([id]) => id).join(' and ')} at ${String(cents[0])}¢`
11
+ : values.map(([id, v]) => `${id} at ${String(v)}¢`).join(', ');
12
+ }
5
13
  export function isValuationProfile(name) {
6
14
  return Object.hasOwn(VALUATION_PROFILES, name);
7
15
  }
@@ -1,23 +1,15 @@
1
- import { resolveMerchant, resolveMerchantById } from "../merchants/resolve.js";
1
+ import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
2
2
  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, NETWORK_NAMES, num, pointValuesText, shortDate, } from "./text.js";
6
+ import { EPSILON, isClose } from "./compare.js";
7
+ import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
7
8
  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;
12
- const EPSILON = 1e-9;
13
9
  export const STALE_AFTER_DAYS = 180;
10
+ // The warning when no card works; the CLI shows it as the answer instead.
11
+ export const noCardText = (merchant) => `None of your cards work at ${merchant}.`;
14
12
  const NEAR_CAP_SHARE = 0.2;
15
- const PERIOD_WORDS = {
16
- month: 'monthly',
17
- quarter: 'quarterly',
18
- calendar_year: 'calendar-year',
19
- cardmember_year: 'cardmember-year',
20
- };
21
13
  const displayName = (e) => e.held.checkout_label ?? e.card.name;
22
14
  // "Worth X or more" must hold at X itself, so round up; "less than X" rounds down.
23
15
  const roundUp = (n) => String(Math.ceil(n * 100 - EPSILON) / 100);
@@ -112,7 +104,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
112
104
  lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
113
105
  }
114
106
  if (currency !== 'cash') {
115
- lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${num(entry.effectiveReturnPct)}% back.`);
107
+ lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.effectiveReturnPct)} back.`);
116
108
  }
117
109
  return lines;
118
110
  }
@@ -121,7 +113,7 @@ function warningsFor(catalog, entry, purchase) {
121
113
  const { best, nominal } = evaluation;
122
114
  const warnings = [];
123
115
  const capLabel = (e) => e.cap
124
- ? `${money(e.cap.cap.amount)} ${PERIOD_WORDS[e.cap.cap.period] ?? ''} cap`
116
+ ? `${money(e.cap.cap.amount)} ${CAP_ADJECTIVES[e.cap.cap.period]} cap`
125
117
  : '';
126
118
  if (best.cap && best.rule) {
127
119
  const { remaining, cap, period } = best.cap;
@@ -152,7 +144,7 @@ function warningsFor(catalog, entry, purchase) {
152
144
  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.`);
153
145
  }
154
146
  for (const rule of evaluation.needsActivation) {
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)}`}.`);
147
+ warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
156
148
  }
157
149
  return warnings;
158
150
  }
@@ -191,7 +183,7 @@ function tieNote(entries) {
191
183
  tied.some((e) => e.evaluation.best.cap !== null)
192
184
  ? 'because its rate has no cap'
193
185
  : 'by a fixed order; either works';
194
- return `${names} also ${verb} ${num(winner.effectiveReturnPct)}% here. ${displayName(winner)} wins the tie ${why}.`;
186
+ return `${names} also ${verb} ${pct(winner.effectiveReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
195
187
  }
196
188
  // Finds the closest point inside a currency's value range where the winner
197
189
  // stops winning, varying one currency at a time. Returns are linear in the
@@ -286,19 +278,18 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
286
278
  const twin = alts.find((e) => e.card.id !== winner.card.id &&
287
279
  alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
288
280
  if (alt.card.id !== winner.card.id)
289
- warnings.push(`${as}, ${displayName(alt)} wins (${num(alt.effectiveReturnPct)}%).`);
281
+ warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.effectiveReturnPct)}).`);
290
282
  else if (twin)
291
- warnings.push(`${as}, ${displayName(twin)} earns as much (${num(twin.effectiveReturnPct)}%).`);
283
+ warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.effectiveReturnPct)}).`);
292
284
  }
293
285
  }
294
286
  if (!winner) {
295
- warnings.push(`None of your cards work at ${merchant.resolvedName}.`);
287
+ warnings.push(noCardText(merchant.resolvedName));
296
288
  // Only where the merchant's data says what its website takes: without
297
289
  // 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);
290
+ const record = merchant.merchantId === null
291
+ ? undefined
292
+ : findMerchant(catalog, merchant.merchantId);
302
293
  const online = merchant.channel === 'in_store' &&
303
294
  merchant.merchantId !== null &&
304
295
  record?.online?.accepts !== undefined
@@ -308,7 +299,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
308
299
  ? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
309
300
  : undefined;
310
301
  if (alt)
311
- warnings.push(`Ordering online instead? ${displayName(alt)} works there (${num(alt.effectiveReturnPct)}%).`);
302
+ warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.effectiveReturnPct)}).`);
312
303
  }
313
304
  else if (merchant.accepts === null &&
314
305
  (winner.card.network === 'amex' || winner.card.network === 'discover')) {
@@ -333,7 +324,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
333
324
  const alt = rankEntries(catalog, wallet, purchase, via, via.category)
334
325
  .entries[0];
335
326
  if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
336
- notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${num(alt.effectiveReturnPct)}%.`);
327
+ notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.effectiveReturnPct)}.`);
337
328
  }
338
329
  }
339
330
  const open = bonusPick(catalog, wallet, entries, purchase.date);
@@ -348,11 +339,8 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
348
339
  const ties = !isBest && gap <= EPSILON;
349
340
  const bonus = open && (open.behind || ties) ? open : null;
350
341
  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.`);
342
+ if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
343
+ notes.push(`${displayName(open.entry)} earns ${pct(open.entry.effectiveReturnPct)} here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${pct(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
344
  }
357
345
  const chosen = bonus?.entry ?? winner;
358
346
  // A tie among the top cards doesn't matter when a welcome bonus decides.
@@ -369,7 +357,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
369
357
  const daysLeft = status.daysLeft ?? 0;
370
358
  const costHere = ties || top.earned === null || first.earned === null
371
359
  ? null
372
- : Math.round((first.earned - top.earned) * 100) / 100;
360
+ : roundCents(first.earned - top.earned);
373
361
  bonusResult = {
374
362
  cardId: top.cardId,
375
363
  checkoutLabel: top.checkoutLabel,
@@ -380,17 +368,15 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
380
368
  costHere,
381
369
  };
382
370
  const pace = daysLeft >= 7
383
- ? ` (about ${money(Math.round(remaining / (daysLeft / 7)))} a week)`
371
+ ? ` (about ${wholeDollars(remaining / (daysLeft / 7))} a week)`
384
372
  : '';
385
- const worth = status.value === null
386
- ? ''
387
- : `, worth about ${money(Math.round(status.value))}`;
373
+ const worth = status.value === null ? '' : `, worth about ${wholeDollars(status.value)}`;
388
374
  notes.unshift(ties
389
375
  ? `${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
376
  : `You're behind pace on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
391
377
  ? ''
392
378
  : costHere === null
393
- ? ` It earns ${num(top.effectiveReturnPct)}% here against ${num(first.effectiveReturnPct)}% on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
379
+ ? ` It earns ${pct(top.effectiveReturnPct)} here against ${pct(first.effectiveReturnPct)} on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
394
380
  : ` Using it here earns ${money(costHere)} less than ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`}`);
395
381
  }
396
382
  let confidence = merchant.confidence;
@@ -1,19 +1,11 @@
1
1
  import { isActivated, pickedCategories } from "./evaluate.js";
2
+ import { membership, membershipName, sameGroup } from "./memberships.js";
2
3
  import { daysBetween } from "./periods.js";
3
4
  import { rotationGap } from "./rotation.js";
5
+ import { idList, listed } from "./text.js";
4
6
  // Rules that start within this many days are worth activating now.
5
7
  const ACTIVATION_LOOKAHEAD_DAYS = 45;
6
- const ISSUER_NICKNAMES = {
7
- amex: 'american express',
8
- 'bank-of-america': 'bofa boa bankofamerica',
9
- 'capital-one': 'capitalone',
10
- citi: 'citibank',
11
- usbank: 'us bank',
12
- 'wells-fargo': 'wf wellsfargo',
13
- 'td-bank': 'td',
14
- column: 'bilt',
15
- };
16
- const words = (s) => s
8
+ const searchWords = (s) => s
17
9
  .toLowerCase()
18
10
  .replace(/\+/g, ' plus ')
19
11
  .replace(/&/g, ' and ')
@@ -32,12 +24,12 @@ function editDistance(a, b) {
32
24
  }
33
25
  return row[b.length] ?? 0;
34
26
  }
35
- function matcher(card) {
27
+ function matcher(catalog, card) {
36
28
  const haystack = [
37
- ...words(card.id),
38
- ...words(card.name),
39
- ...words(card.issuer),
40
- ...words(ISSUER_NICKNAMES[card.issuer] ?? ''),
29
+ ...searchWords(card.id),
30
+ ...searchWords(card.name),
31
+ ...searchWords(card.issuer),
32
+ ...(catalog.issuers.find((i) => i.id === card.issuer)?.search ?? []).flatMap(searchWords),
41
33
  ];
42
34
  const compact = haystack.join('');
43
35
  return (token) => haystack.some((w) => w.startsWith(token)) ||
@@ -57,9 +49,6 @@ export function activationRules(card, today) {
57
49
  daysBetween(today, r.valid_from) <= ACTIVATION_LOOKAHEAD_DAYS))
58
50
  .map((r) => r.id);
59
51
  }
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-/;
63
52
  export function membershipsUsed(card) {
64
53
  return [
65
54
  ...new Set(card.rules.flatMap((r) => r.requires?.membership === undefined ? [] : [r.requires.membership])),
@@ -70,13 +59,13 @@ export function membershipsUsed(card) {
70
59
  export function membershipsFor(card) {
71
60
  return [
72
61
  ...membershipsUsed(card),
73
- ...(card.requires_membership ? [card.requires_membership.id] : []),
62
+ ...(card.requires_membership ? [card.requires_membership] : []),
74
63
  ];
75
64
  }
76
65
  export function knownMemberships(catalog) {
77
- return [...new Set(catalog.cards.flatMap(membershipsFor))].sort();
66
+ return catalog.memberships.map((m) => m.id).sort();
78
67
  }
79
- export function cardInfo(card, wallet, today) {
68
+ export function cardInfo(catalog, card, wallet, today) {
80
69
  return {
81
70
  id: card.id,
82
71
  name: card.name,
@@ -95,13 +84,13 @@ export function cardInfo(card, wallet, today) {
95
84
  default: c.default ?? null,
96
85
  })),
97
86
  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
+ requiresMembership: card.requires_membership === undefined
88
+ ? null
89
+ : {
90
+ id: card.requires_membership,
91
+ name: membershipName(catalog, card.requires_membership),
92
+ annualFee: membership(catalog, card.requires_membership)?.annual_fee ?? 0,
93
+ },
105
94
  activation: activationRules(card, today),
106
95
  needsAnniversary: needsAnniversary(card),
107
96
  };
@@ -110,12 +99,12 @@ export function cardInfo(card, wallet, today) {
110
99
  // issuer (as a prefix, or with a typo or two in a long word). With no full
111
100
  // match, the cards matching the most words come back instead.
112
101
  export function searchCards(catalog, query, wallet, today) {
113
- const tokens = words(query);
102
+ const tokens = searchWords(query);
114
103
  const scored = catalog.cards.map((card) => {
115
- const matches = matcher(card);
104
+ const matches = matcher(catalog, card);
116
105
  return { card, score: tokens.filter((t) => matches(t)).length };
117
106
  });
118
- const byShortestId = (a, b) => words(a.card.id).length - words(b.card.id).length ||
107
+ const byShortestId = (a, b) => searchWords(a.card.id).length - searchWords(b.card.id).length ||
119
108
  a.card.id.localeCompare(b.card.id);
120
109
  const full = scored.filter((s) => s.score === tokens.length);
121
110
  const best = Math.max(0, ...scored.map((s) => s.score));
@@ -129,11 +118,10 @@ export function searchCards(catalog, query, wallet, today) {
129
118
  .sort(byShortestId)
130
119
  .slice(0, 5);
131
120
  return {
132
- cards: chosen.map((s) => cardInfo(s.card, wallet, today)),
121
+ cards: chosen.map((s) => cardInfo(catalog, s.card, wallet, today)),
133
122
  exact,
134
123
  };
135
124
  }
136
- const list = (ids) => ids.join(', ');
137
125
  // What a card in the wallet still needs before its answers are right.
138
126
  export function setupNotes(catalog, wallet, held, today) {
139
127
  const card = catalog.cards.find((c) => c.id === held.id);
@@ -147,27 +135,27 @@ export function setupNotes(catalog, wallet, held, today) {
147
135
  (staleChoice === null || choice.period === 'until_changed');
148
136
  if (current)
149
137
  continue;
150
- const options = list(Object.keys(choice.options));
138
+ const options = idList(Object.keys(choice.options));
151
139
  notes.push({
152
140
  kind: 'pick',
153
141
  text: choice.default && picked.has(choice.id)
154
- ? `${name}'s ${choice.id} pick is assumed to be ${list(choice.default)}, the default. Set it if a different one was picked (${String(choice.pick)} of: ${options}).`
142
+ ? `${name}'s ${choice.id} pick is assumed to be ${idList(choice.default)}, the default. Set it if a different one was picked (${String(choice.pick)} of: ${options}).`
155
143
  : `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
156
144
  });
157
145
  }
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)));
146
+ // With one tier of a group saved, the others aren't missing.
147
+ const missing = membershipsUsed(card).filter((m) => !wallet.memberships.some((held) => held === m || sameGroup(catalog, held, m)));
160
148
  if (missing.length > 0) {
161
149
  notes.push({
162
150
  kind: 'membership',
163
- text: `Some of ${name}'s rates need a membership: ${list(missing)}. Add any that apply to the wallet's memberships.`,
151
+ text: `Some of ${name}'s rates need ${listed(missing.map((m) => `${membershipName(catalog, m)} (${m})`), 'or')}; they're counted without it.`,
164
152
  });
165
153
  }
166
154
  const toActivate = activationRules(card, today).filter((id) => !isActivated(card, held, id));
167
155
  if (toActivate.length > 0) {
168
156
  notes.push({
169
157
  kind: 'activation',
170
- text: `${name} has rates that apply only once activated with the issuer: ${list(toActivate)}. Mark the ones already activated.`,
158
+ text: `${name} has rates that apply only once activated with the issuer: ${idList(toActivate)}. Mark the ones already activated.`,
171
159
  });
172
160
  }
173
161
  if (needsAnniversary(card) && held.anniversary === undefined) {