earnrate 0.2.8 → 0.2.10

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 (99) hide show
  1. package/README.md +31 -5
  2. package/data/CHANGELOG.md +145 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-business-plus.yaml +4 -0
  5. package/data/cards/amex-blue-cash-everyday.yaml +4 -0
  6. package/data/cards/amex-blue-cash-preferred.yaml +38 -3
  7. package/data/cards/amex-gold.yaml +4 -0
  8. package/data/cards/amex-platinum.yaml +4 -0
  9. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +4 -0
  10. package/data/cards/bank-of-america-atmos-rewards-business.yaml +4 -0
  11. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +24 -1
  12. package/data/cards/bank-of-america-customized-cash-rewards.yaml +4 -0
  13. package/data/cards/bilt-obsidian.yaml +4 -0
  14. package/data/cards/capital-one-kohls-card.yaml +2 -0
  15. package/data/cards/capital-one-kohls-rewards-visa.yaml +2 -0
  16. package/data/cards/capital-one-savor.yaml +4 -0
  17. package/data/cards/capital-one-venture-x.yaml +4 -0
  18. package/data/cards/capital-one-venture.yaml +4 -0
  19. package/data/cards/chase-aeroplan.yaml +5 -0
  20. package/data/cards/chase-freedom-flex.yaml +4 -0
  21. package/data/cards/chase-freedom-unlimited.yaml +4 -0
  22. package/data/cards/chase-ihg-one-rewards-business.yaml +29 -1
  23. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +37 -2
  24. package/data/cards/chase-ihg-one-rewards-premier.yaml +29 -1
  25. package/data/cards/chase-ihg-one-rewards.yaml +57 -7
  26. package/data/cards/chase-ink-business-cash.yaml +4 -0
  27. package/data/cards/chase-ink-business-preferred.yaml +4 -0
  28. package/data/cards/chase-prime-visa.yaml +4 -0
  29. package/data/cards/chase-sapphire-preferred.yaml +4 -0
  30. package/data/cards/chase-sapphire-reserve.yaml +4 -0
  31. package/data/cards/chase-southwest-performance-business.yaml +4 -0
  32. package/data/cards/chase-southwest-plus.yaml +4 -0
  33. package/data/cards/chase-southwest-premier-business.yaml +4 -0
  34. package/data/cards/chase-southwest-premier.yaml +4 -0
  35. package/data/cards/chase-southwest-priority.yaml +4 -0
  36. package/data/cards/chase-united-business.yaml +5 -0
  37. package/data/cards/chase-united-club-business.yaml +5 -0
  38. package/data/cards/chase-united-club.yaml +5 -0
  39. package/data/cards/chase-united-explorer.yaml +5 -0
  40. package/data/cards/chase-united-gateway.yaml +5 -0
  41. package/data/cards/chase-united-quest.yaml +30 -2
  42. package/data/cards/citi-aadvantage-business.yaml +4 -0
  43. package/data/cards/citi-aadvantage-executive.yaml +4 -0
  44. package/data/cards/citi-aadvantage-globe.yaml +4 -0
  45. package/data/cards/citi-aadvantage-mileup.yaml +4 -0
  46. package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
  47. package/data/cards/citi-costco-anywhere-visa-business.yaml +4 -0
  48. package/data/cards/citi-costco-anywhere-visa.yaml +4 -0
  49. package/data/cards/citi-custom-cash.yaml +2 -0
  50. package/data/cards/citi-double-cash.yaml +4 -0
  51. package/data/cards/citi-my-best-buy-visa.yaml +4 -0
  52. package/data/cards/citi-strata-elite.yaml +4 -0
  53. package/data/cards/citi-strata-premier.yaml +4 -0
  54. package/data/cards/sofi-unlimited-2.yaml +4 -0
  55. package/data/cards/td-bank-nordstrom-credit-card.yaml +2 -0
  56. package/data/cards/td-bank-nordstrom-visa.yaml +2 -0
  57. package/data/cards/td-bank-target-circle-card.yaml +2 -0
  58. package/data/cards/usbank-cash-plus.yaml +5 -0
  59. package/data/cards/wells-fargo-active-cash.yaml +4 -0
  60. package/data/cards/wells-fargo-autograph.yaml +4 -0
  61. package/data/merchants/overrides.yaml +290 -0
  62. package/data/merchants/seed.yaml +1049 -2
  63. package/dist/cli/args.js +3 -6
  64. package/dist/cli/explore.js +10 -0
  65. package/dist/cli/import.js +18 -4
  66. package/dist/cli/run.js +3 -1
  67. package/dist/cli/wallet.js +5 -1
  68. package/dist/data/file-store.js +2 -2
  69. package/dist/data/integrity.js +17 -0
  70. package/dist/data/store.js +1 -1
  71. package/dist/data/wallet-edit.js +32 -18
  72. package/dist/engine/cheat-sheet.js +46 -2
  73. package/dist/engine/checklist.js +19 -8
  74. package/dist/engine/evaluate.js +2 -0
  75. package/dist/engine/explore.js +9 -5
  76. package/dist/engine/milestones.js +91 -0
  77. package/dist/engine/periods.js +29 -12
  78. package/dist/engine/recommend.js +68 -14
  79. package/dist/engine/record.js +39 -1
  80. package/dist/engine/rule-text.js +17 -5
  81. package/dist/engine/setup.js +13 -2
  82. package/dist/engine/simulate.js +25 -3
  83. package/dist/engine/text.js +11 -0
  84. package/dist/engine/wallet-change.js +67 -0
  85. package/dist/engine/wallet-summary.js +17 -5
  86. package/dist/format/cards.js +3 -1
  87. package/dist/format/recommendation.js +8 -2
  88. package/dist/format/wallet.js +22 -4
  89. package/dist/import/analyze.js +42 -2
  90. package/dist/mcp/inputs.js +10 -0
  91. package/dist/mcp/schemas.js +50 -2
  92. package/dist/mcp/server.js +17 -1
  93. package/dist/mcp/tools/account.js +1 -1
  94. package/dist/mcp/tools/answers.js +8 -4
  95. package/dist/mcp/tools/wallet.js +18 -10
  96. package/dist/schema/card.js +22 -1
  97. package/dist/schema/common.js +4 -0
  98. package/dist/schema/wallet.js +2 -0
  99. package/package.json +5 -3
@@ -42,17 +42,34 @@ export function choicePeriod(card) {
42
42
  // limit on that side.
43
43
  export const inEffect = (window, date) => (window.valid_from === undefined || window.valid_from <= date) &&
44
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);
45
+ const utcDay = (year, month, day) => new Date(Date.UTC(year, month - 1, day)).toISOString().slice(0, 10);
46
+ // The last day of the period a key from periodKey names. A calendar year and
47
+ // a cardmember year share the key shape ("2026"), so the kind is needed: a
48
+ // cardmember year ends the day before the next anniversary. null for a
49
+ // cardmember year without an anniversary, or a key that doesn't fit the kind.
50
+ export function periodEnd(period, key, anniversary) {
51
+ switch (period) {
52
+ case 'month': {
53
+ const m = /^(\d{4})-(\d{2})$/.exec(key);
54
+ return m ? utcDay(Number(m[1]), Number(m[2]) + 1, 0) : null;
55
+ }
56
+ case 'quarter': {
57
+ const q = /^(\d{4})-Q([1-4])$/.exec(key);
58
+ return q ? utcDay(Number(q[1]), Number(q[2]) * 3 + 1, 0) : null;
59
+ }
60
+ case 'calendar_year':
61
+ return /^\d{4}$/.test(key) ? `${key}-12-31` : null;
62
+ case 'cardmember_year': {
63
+ const a = anniversary === null ? null : /^(\d{2})-(\d{2})$/.exec(anniversary);
64
+ if (!a || !/^\d{4}$/.test(key))
65
+ return null;
66
+ // Date.UTC rolls over; a parsed "2027-02-29" would be invalid.
67
+ return utcDay(Number(key) + 1, Number(a[1]), Number(a[2]) - 1);
68
+ }
56
69
  }
57
- return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
70
+ }
71
+ // The last day of the period `date` falls in.
72
+ export function currentPeriodEnd(period, date, anniversary) {
73
+ const key = periodKey(period, date, anniversary);
74
+ return key === null ? null : periodEnd(period, key, anniversary);
58
75
  }
@@ -2,13 +2,20 @@ import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchant
2
2
  import { bonusStatus } from "./bonus.js";
3
3
  import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
4
4
  import { membershipName } from "./memberships.js";
5
+ import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
5
6
  import { daysBetween } from "./periods.js";
6
7
  import { ruleWhat } from "./rule-text.js";
7
8
  import { rotationGap } from "./rotation.js";
8
9
  import { EPSILON, isClose } from "./compare.js";
9
- import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
10
+ import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, listed, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
10
11
  import { cardValuation } from "./valuation.js";
11
12
  export const STALE_AFTER_DAYS = 180;
13
+ // The engine only tells the U.S. from everywhere else.
14
+ export const FOREIGN_ASSUMPTION = 'Treats this as a purchase outside the U.S.: each card is ranked after its foreign transaction fee.';
15
+ export const isForeign = (purchase) => purchase.country !== 'US';
16
+ // Any purchase outside the U.S., when no country is named: ZZ is a code ISO
17
+ // leaves unassigned, and nothing prints it.
18
+ export const ABROAD = 'ZZ';
12
19
  // The warning when no card works; the CLI shows it as the answer instead.
13
20
  export const noCardText = (merchant) => `None of your cards work at ${merchant}.`;
14
21
  const NEAR_CAP_SHARE = 0.2;
@@ -66,14 +73,23 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
66
73
  }
67
74
  const valuation = cardValuation(catalog, wallet, card);
68
75
  const evaluation = evaluateCard(card, held, ctx);
69
- const rate = evaluation.best.rate;
76
+ const milestones = milestonesReached(card, held, purchase.date, purchase.amount);
77
+ const bonusPoints = milestonePoints(milestones);
78
+ const perDollar = evaluation.best.rate +
79
+ (bonusPoints > 0 && purchase.amount ? bonusPoints / purchase.amount : 0);
80
+ // An unknown fee isn't guessed: it's warned about instead.
81
+ const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
70
82
  entries.push({
71
83
  card,
72
84
  held,
73
85
  valuation,
74
86
  evaluation,
75
- effectiveReturnPct: rate * valuation.centsPerUnit,
76
- upside: rate * (valuation.range?.[1] ?? valuation.centsPerUnit),
87
+ milestones,
88
+ perDollar,
89
+ fee,
90
+ effectiveReturnPct: perDollar * valuation.centsPerUnit - fee,
91
+ rateReturnPct: evaluation.best.rate * valuation.centsPerUnit - fee,
92
+ upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) - fee,
77
93
  });
78
94
  }
79
95
  entries.sort(compareEntries);
@@ -96,6 +112,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
96
112
  const over = amount - cap.remaining;
97
113
  lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, best.afterCap ?? cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
98
114
  }
115
+ for (const hit of entry.milestones)
116
+ lines.push(reachedText(card, hit, valuation.centsPerUnit));
99
117
  if (valuation.pooledFrom !== null) {
100
118
  const partners = card.pools_into?.when_wallet_has ?? [];
101
119
  const heldPartners = wallet.cards
@@ -106,7 +124,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
106
124
  lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
107
125
  }
108
126
  if (currency !== 'cash') {
109
- lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.effectiveReturnPct)} back.`);
127
+ lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.evaluation.best.rate * valuation.centsPerUnit)} back.`);
110
128
  }
111
129
  return lines;
112
130
  }
@@ -114,6 +132,10 @@ function warningsFor(catalog, entry, purchase) {
114
132
  const { card, valuation, evaluation } = entry;
115
133
  const { best, nominal } = evaluation;
116
134
  const warnings = [];
135
+ if (entry.fee > 0)
136
+ warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.effectiveReturnPct)}.`);
137
+ if (isForeign(purchase) && card.foreign_transaction_fee === null)
138
+ warnings.push("Its foreign transaction fee isn't on file, so none is subtracted. Check the issuer's terms before using it abroad.");
117
139
  const capLabel = (e) => e.cap
118
140
  ? `${money(e.cap.cap.amount)} ${CAP_ADJECTIVES[e.cap.cap.period]} cap`
119
141
  : '';
@@ -164,10 +186,19 @@ function toRanked(catalog, wallet, entry, purchase) {
164
186
  rate: evaluation.best.rate,
165
187
  currency: valuation.currency,
166
188
  centsPerUnit: valuation.centsPerUnit,
167
- effectiveReturnPct: entry.effectiveReturnPct,
189
+ effectiveReturnPct: entry.rateReturnPct,
190
+ foreignFee: entry.fee > 0 ? entry.fee : null,
168
191
  earned: purchase.amount === null
169
192
  ? null
170
- : Math.round(purchase.amount * entry.effectiveReturnPct) / 100,
193
+ : Math.round(purchase.amount * entry.rateReturnPct) / 100,
194
+ milestoneBonus: entry.milestones.length === 0
195
+ ? null
196
+ : {
197
+ points: milestonePoints(entry.milestones),
198
+ dollars: roundCents((milestonePoints(entry.milestones) *
199
+ entry.valuation.centsPerUnit) /
200
+ 100),
201
+ },
171
202
  capped: evaluation.best.cap !== null,
172
203
  reasoning: reasoningFor(catalog, wallet, entry, purchase.amount),
173
204
  warnings: warningsFor(catalog, entry, purchase),
@@ -194,6 +225,8 @@ function tieNote(entries) {
194
225
  // Finds the closest point inside a currency's value range where the winner
195
226
  // stops winning, varying one currency at a time. Returns are linear in the
196
227
  // cents-per-point value, so each pair of cards flips at most once.
228
+ // "Chase Ultimate Rewards points", but not "IHG One Rewards points points".
229
+ const pointsName = (label) => /\b(points|miles)$/i.test(label) ? label : `${label} points`;
197
230
  function sensitivity(catalog, entries) {
198
231
  const [winner, ...rest] = entries;
199
232
  if (!winner)
@@ -208,18 +241,19 @@ function sensitivity(catalog, entries) {
208
241
  const o = other.valuation;
209
242
  if (w.currency === o.currency)
210
243
  continue;
211
- const wRate = winner.evaluation.best.rate;
212
- const oRate = other.evaluation.best.rate;
244
+ const wRate = winner.perDollar;
245
+ const oRate = other.perDollar;
246
+ // Net return is rate × value − fee, so each flip point adds the fee back.
213
247
  if (w.range && wRate > 0) {
214
- const threshold = other.effectiveReturnPct / wRate;
248
+ const threshold = (other.effectiveReturnPct + winner.fee) / wRate;
215
249
  if (threshold < w.centsPerUnit && threshold >= w.range[0]) {
216
- consider((w.centsPerUnit - threshold) / w.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, w.currency)} points are worth less than ${roundDown(threshold)}¢ (you value them at ${num(w.centsPerUnit)}¢).`);
250
+ consider((w.centsPerUnit - threshold) / w.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, w.currency))} are worth less than ${roundDown(threshold)}¢ (you value them at ${num(w.centsPerUnit)}¢).`);
217
251
  }
218
252
  }
219
253
  if (o.range && oRate > 0) {
220
- const threshold = winner.effectiveReturnPct / oRate;
254
+ const threshold = (winner.effectiveReturnPct + other.fee) / oRate;
221
255
  if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
222
- 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)}¢).`);
256
+ consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, o.currency))} are worth ${worthAbove(threshold)} (you value them at ${num(o.centsPerUnit)}¢).`);
223
257
  }
224
258
  }
225
259
  }
@@ -234,7 +268,19 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
234
268
  assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
235
269
  }
236
270
  else if (merchant.category === 'other') {
237
- assumptions.push(`Assumes ${merchant.resolvedName} falls outside the usual bonus categories.`);
271
+ // A card whose rule names this store by name (Audible on Amex's
272
+ // streaming list) earns its bonus there anyway.
273
+ const naming = entries.flatMap((e) => {
274
+ const rule = e.evaluation.best.rule;
275
+ return rule &&
276
+ merchant.merchantId !== null &&
277
+ (rule.match.merchants ?? []).includes(merchant.merchantId)
278
+ ? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
279
+ : [];
280
+ });
281
+ assumptions.push(naming.length === 0
282
+ ? `Assumes ${merchant.resolvedName} falls outside the usual bonus categories.`
283
+ : `Assumes ${merchant.resolvedName} falls outside the usual bonus categories, except on cards that name it: ${listed(naming)}.`);
238
284
  }
239
285
  else {
240
286
  assumptions.push(codesAs(catalog, merchant));
@@ -251,6 +297,11 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
251
297
  const values = pointValuesText(catalog, entries.map((e) => e.valuation));
252
298
  if (values !== null)
253
299
  assumptions.push(values);
300
+ if (isForeign(purchase))
301
+ assumptions.push(FOREIGN_ASSUMPTION);
302
+ for (const e of entries)
303
+ for (const hit of e.milestones)
304
+ assumptions.push(`Counts the ${money(hit.before)} tracked on ${displayName(e)} in ${hit.period} toward its ${wholeDollars(hit.spend)} spend bonus; spending earnrate wasn't told about isn't counted.`);
254
305
  return assumptions;
255
306
  }
256
307
  export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
@@ -259,6 +310,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
259
310
  const winner = entries[0];
260
311
  const warnings = [];
261
312
  const notes = [];
313
+ const charging = entries.filter((e) => e.fee > 0);
314
+ if (charging.length > 0 && entries.length > 1)
315
+ notes.push(`${listed(charging.map((e) => `${displayName(e)} (${pct(e.fee)})`))} ${charging.length === 1 ? 'charges a foreign transaction fee' : 'charge foreign transaction fees'} here, already subtracted in the ranking.`);
262
316
  if (merchant.source === 'none') {
263
317
  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.`);
264
318
  }
@@ -1,6 +1,7 @@
1
1
  import { resolveMerchant } from "../merchants/resolve.js";
2
2
  import { bonusStatus, countsTowardBonus } from "./bonus.js";
3
3
  import { evaluateCard, purchaseContext } from "./evaluate.js";
4
+ import { milestoneProgress } from "./milestones.js";
4
5
  import { roundCents } from "./text.js";
5
6
  // Books the spend against the cap of the highest-rate rule that matches, even
6
7
  // when that cap is already used up, so usage stays accurate across a reset.
@@ -75,11 +76,48 @@ export function applyBonusBooking(wallet, b) {
75
76
  : c),
76
77
  };
77
78
  }
79
+ // Every purchase on a card counts toward each of its spend milestones. None
80
+ // for a cardmember-year milestone on a card with no anniversary.
81
+ export function milestoneBookings(catalog, wallet, purchase) {
82
+ const held = wallet.cards.find((c) => c.id === purchase.cardId);
83
+ const card = catalog.cards.find((c) => c.id === purchase.cardId);
84
+ if (!held || !card)
85
+ return [];
86
+ return milestoneProgress(card, held, purchase.date).flatMap((p) => p.period === null
87
+ ? []
88
+ : [
89
+ {
90
+ cardId: card.id,
91
+ milestoneId: p.milestone.id,
92
+ period: p.period,
93
+ spend: p.milestone.spend,
94
+ before: p.spent,
95
+ after: roundCents(Math.max(0, p.spent + purchase.amount)),
96
+ },
97
+ ]);
98
+ }
99
+ export function applyMilestoneBooking(wallet, b) {
100
+ return {
101
+ ...wallet,
102
+ cards: wallet.cards.map((c) => c.id === b.cardId
103
+ ? {
104
+ ...c,
105
+ milestone_usage: {
106
+ ...c.milestone_usage,
107
+ [b.milestoneId]: { period: b.period, spent: b.after },
108
+ },
109
+ }
110
+ : c),
111
+ };
112
+ }
78
113
  export function bookPurchase(catalog, wallet, purchase, merchant) {
79
114
  const cap = capBooking(catalog, wallet, purchase, merchant);
80
115
  const bonus = bonusBooking(catalog, wallet, purchase);
116
+ const milestones = milestoneBookings(catalog, wallet, purchase);
81
117
  let next = cap ? applyBooking(wallet, cap) : wallet;
82
118
  if (bonus)
83
119
  next = applyBonusBooking(next, bonus);
84
- return { wallet: next, cap, bonus };
120
+ for (const m of milestones)
121
+ next = applyMilestoneBooking(next, m);
122
+ return { wallet: next, cap, bonus, milestones };
85
123
  }
@@ -26,14 +26,26 @@ export function optionName(catalog, card, choiceId, option) {
26
26
  // What a rule covers, as a list of phrases.
27
27
  export function ruleParts(catalog, rule) {
28
28
  if (rule.match.any)
29
- return ['everything else'];
29
+ return [
30
+ rule.geography === 'foreign_only'
31
+ ? 'purchases outside the U.S.'
32
+ : 'everything else',
33
+ ];
30
34
  if (rule.match.choice !== undefined)
31
35
  return [`your ${choiceName(rule.match.choice)} picks`];
32
36
  const merchants = rule.match.merchants ?? [];
33
- // A long list of one kind of store reads as "select streaming services".
34
- const kinds = new Set(merchants.map((m) => findMerchant(catalog, m)?.category));
35
- const [kind] = kinds;
36
- const merchantParts = merchants.length > 4 && kinds.size === 1 && kind !== undefined
37
+ // A long list of mostly one kind of store reads as "select streaming
38
+ // services": Amex's streaming list also names Audible, which isn't one.
39
+ const counts = new Map();
40
+ for (const m of merchants) {
41
+ const category = findMerchant(catalog, m)?.category;
42
+ if (category)
43
+ counts.set(category, (counts.get(category) ?? 0) + 1);
44
+ }
45
+ const [kind, count = 0] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
46
+ const merchantParts = merchants.length > 4 &&
47
+ kind !== undefined &&
48
+ count >= merchants.length * 0.75
37
49
  ? [`select ${categoryPhrase(catalog, kind)}`]
38
50
  : merchants.map((m) => merchantName(catalog, m));
39
51
  const parts = [
@@ -1,5 +1,6 @@
1
1
  import { isActivated, pickedCategories } from "./evaluate.js";
2
2
  import { membership, membershipName, sameGroup } from "./memberships.js";
3
+ import { milestoneWhat } from "./milestones.js";
3
4
  import { daysBetween } from "./periods.js";
4
5
  import { rotationGap } from "./rotation.js";
5
6
  import { idList, listed } from "./text.js";
@@ -39,7 +40,8 @@ function matcher(catalog, card) {
39
40
  }
40
41
  export function needsAnniversary(card) {
41
42
  return (card.caps.some((c) => c.period === 'cardmember_year') ||
42
- card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year'));
43
+ card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year') ||
44
+ card.milestones.some((m) => m.period === 'cardmember_year'));
43
45
  }
44
46
  export function activationRules(card, today) {
45
47
  return card.rules
@@ -74,6 +76,7 @@ export function cardInfo(catalog, card, wallet, today) {
74
76
  network: card.network,
75
77
  currency: card.currency,
76
78
  annualFee: card.annual_fee ?? null,
79
+ foreignTransactionFee: card.foreign_transaction_fee,
77
80
  business: card.business,
78
81
  onlyAt: card.only_at ?? null,
79
82
  held: wallet?.cards.some((c) => c.id === card.id) ?? false,
@@ -94,6 +97,14 @@ export function cardInfo(catalog, card, wallet, today) {
94
97
  },
95
98
  activation: activationRules(card, today),
96
99
  needsAnniversary: needsAnniversary(card),
100
+ milestones: card.milestones.map((m) => ({
101
+ id: m.id,
102
+ spend: m.spend,
103
+ period: m.period,
104
+ points: m.points ?? null,
105
+ rewards: m.rewards ?? [],
106
+ text: milestoneWhat(m).replace(/^the /, ''),
107
+ })),
97
108
  };
98
109
  }
99
110
  // Every word of the query has to match a word of the card's id, name or
@@ -162,7 +173,7 @@ export function setupNotes(catalog, wallet, held, today) {
162
173
  if (needsAnniversary(card) && held.anniversary === undefined) {
163
174
  notes.push({
164
175
  kind: 'anniversary',
165
- text: `${name} has a cap that resets on the card's anniversary. Add the anniversary (MM-DD) so it can be tracked.`,
176
+ text: `${name} has a ${card.milestones.some((m) => m.period === 'cardmember_year') ? 'spend bonus' : 'cap'} that resets on the card's anniversary. Add the anniversary (MM-DD) so it can be tracked.`,
166
177
  });
167
178
  }
168
179
  const gap = rotationGap(card, today);
@@ -4,8 +4,9 @@ import { idealHolding, matchesFilters, } from "./explore.js";
4
4
  import { membershipCosts, membershipNote, paidMemberships, } from "./memberships.js";
5
5
  import { choicePeriod, periodKey } from "./periods.js";
6
6
  import { applyBooking, capBooking } from "./record.js";
7
+ import { milestoneWhat } from "./milestones.js";
7
8
  import { rotationGap } from "./rotation.js";
8
- import { money, roundCents } from "./text.js";
9
+ import { money, roundCents, wholeDollars } from "./text.js";
9
10
  import { cardValuation } from "./valuation.js";
10
11
  import { EPSILON, HALF_CENT } from "./compare.js";
11
12
  const MONTHS = 12;
@@ -122,7 +123,27 @@ held = []) {
122
123
  cards: [...r.byCard].sort((a, b) => b[1] - a[1]).map(([id]) => id),
123
124
  };
124
125
  });
125
- const rewards = roundCents(lineResults.reduce((sum, l) => sum + l.earned, 0));
126
+ // A year of these amounts is taken as every year's: a calendar-year or
127
+ // cardmember-year total is the same twelve months' spending.
128
+ const milestones = [];
129
+ const milestoneNotes = [];
130
+ for (const { card, cents } of cards) {
131
+ const spent = results.reduce((sum, r) => sum + (r.byCard.get(card.id) ?? 0), 0);
132
+ for (const m of card.milestones) {
133
+ if (spent + EPSILON < m.spend)
134
+ continue;
135
+ const value = roundCents(((m.points ?? 0) * cents) / 100);
136
+ milestones.push({
137
+ cardId: card.id,
138
+ milestoneId: m.id,
139
+ points: m.points ?? null,
140
+ value,
141
+ });
142
+ milestoneNotes.push(`${card.name}: ${wholeDollars(spent)} a year on it reaches ${milestoneWhat(m)}${m.points === undefined ? '' : ` (about ${money(value)}, counted in its rewards)`}, assuming the same spending each year.`);
143
+ }
144
+ }
145
+ const rewards = roundCents(lineResults.reduce((sum, l) => sum + l.earned, 0) +
146
+ milestones.reduce((sum, m) => sum + m.value, 0));
126
147
  const annualFees = cards.reduce((sum, c) => sum + (c.card.annual_fee ?? 0), 0);
127
148
  const paid = paidMemberships(catalog, {
128
149
  memberships: wallet.memberships,
@@ -131,7 +152,7 @@ held = []) {
131
152
  });
132
153
  const memberships = membershipCosts(catalog, cards.map((c) => c.card), paid);
133
154
  const membershipFees = memberships.reduce((sum, m) => sum + m.annualFee, 0);
134
- const notes = [];
155
+ const notes = [...milestoneNotes];
135
156
  for (const m of memberships)
136
157
  notes.push(membershipNote(catalog, m));
137
158
  const dates = monthDates(start);
@@ -160,6 +181,7 @@ held = []) {
160
181
  membershipFees,
161
182
  net: roundCents(rewards - annualFees - membershipFees),
162
183
  lines: lineResults,
184
+ milestones,
163
185
  notes,
164
186
  };
165
187
  }
@@ -32,6 +32,12 @@ export const wholeDollars = (n) => money(Math.round(n));
32
32
  // Whole dollars stay whole ($2,800); anything else shows cents ($0.40). A
33
33
  // loss reads −$618.
34
34
  export const money = (n) => `${n < 0 ? '−' : ''}$${Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
35
+ // "No foreign transaction fee", "3% foreign transaction fee"
36
+ export const foreignFeeText = (fee) => fee === null
37
+ ? 'Foreign transaction fee not on file'
38
+ : fee === 0
39
+ ? 'No foreign transaction fee'
40
+ : `${pct(fee)} foreign transaction fee`;
35
41
  export function feeText(fees) {
36
42
  const { annualFee, firstYearFee } = fees;
37
43
  if (annualFee === null)
@@ -122,6 +128,11 @@ export const CAP_ADJECTIVES = {
122
128
  calendar_year: 'calendar-year',
123
129
  cardmember_year: 'cardmember-year',
124
130
  };
131
+ // "the $15,000 calendar-year spend", for a spend milestone.
132
+ export const MILESTONE_PERIODS = {
133
+ calendar_year: 'calendar-year',
134
+ cardmember_year: 'cardmember-year',
135
+ };
125
136
  export const PICK_PERIODS = {
126
137
  month: 'each month',
127
138
  quarter: 'each quarter',
@@ -116,6 +116,28 @@ export function cardFields(card, held, input, today) {
116
116
  }
117
117
  fields.cap_usage = usage;
118
118
  }
119
+ if (input.milestoneSpent !== undefined) {
120
+ const usage = { ...held?.milestone_usage };
121
+ const anniversary = fields.anniversary ?? held?.anniversary ?? null;
122
+ for (const [id, spent] of Object.entries(input.milestoneSpent)) {
123
+ const milestone = card.milestones.find((m) => m.id === id);
124
+ if (!milestone) {
125
+ problems.push(card.milestones.length > 0
126
+ ? `${card.id} has no spend milestone '${id}'. Its milestones are: ${idList(card.milestones.map((m) => m.id))}.`
127
+ : `${card.id} has no spend milestones.`);
128
+ continue;
129
+ }
130
+ if (!(spent >= 0))
131
+ problems.push('Spending toward a milestone must be $0 or more.');
132
+ const period = periodKey(milestone.period, today, anniversary);
133
+ if (period === null) {
134
+ problems.push(`The '${id}' milestone resets on the card's anniversary; give the anniversary (MM-DD) too.`);
135
+ continue;
136
+ }
137
+ usage[id] = { period, spent };
138
+ }
139
+ fields.milestone_usage = usage;
140
+ }
119
141
  if (input.bonus === null)
120
142
  fields.bonus = null;
121
143
  else if (input.bonus !== undefined) {
@@ -173,6 +195,9 @@ const withFields = ({ bonus, checkout_label, ...held }, f) => ({
173
195
  ...(f.chosen === undefined ? {} : { chosen: f.chosen }),
174
196
  ...(f.activated === undefined ? {} : { activated: f.activated }),
175
197
  ...(f.cap_usage === undefined ? {} : { cap_usage: f.cap_usage }),
198
+ ...(f.milestone_usage === undefined
199
+ ? {}
200
+ : { milestone_usage: f.milestone_usage }),
176
201
  ...(f.bonus ? { bonus: f.bonus } : {}),
177
202
  });
178
203
  // `wallet` is null when there isn't one yet; adding a card starts it.
@@ -266,3 +291,45 @@ export function withValuations(catalog, wallet, changes) {
266
291
  }
267
292
  // Higher than any program's points are worth, even transferred.
268
293
  const MAX_CENTS = 5;
294
+ // Point values keyed the way people say them ("ur", "Ultimate Rewards",
295
+ // "Chase points"), turned into currency ids. A key that could mean more
296
+ // than one currency is refused rather than guessed.
297
+ export function currencyChanges(catalog, changes) {
298
+ const out = {};
299
+ for (const [key, cents] of Object.entries(changes)) {
300
+ const matches = currenciesFor(catalog, key);
301
+ const only = matches[0];
302
+ if (matches.length > 1)
303
+ return failed(`'${key}' could be ${matches.map((c) => `${c.id} (${c.name})`).join(' or ')}; give the currency id.`);
304
+ if (only === undefined)
305
+ return failed(`Unknown currency '${key}'. Currencies: ${catalog.currencies.map((c) => `${c.id} (${c.name})`).join(', ')}.`);
306
+ if (only.id in out)
307
+ return failed(`More than one value was given for ${only.name}.`);
308
+ out[only.id] = cents;
309
+ }
310
+ return { ok: true, changes: out };
311
+ }
312
+ const IGNORED_WORDS = new Set(['points', 'point', 'pts', 'miles', 'mile']);
313
+ const words = (text) => text
314
+ .toLowerCase()
315
+ .split(/[^a-z0-9]+/)
316
+ .filter((w) => w !== '' && !IGNORED_WORDS.has(w));
317
+ const sameWords = (a, b) => a.length === b.length && a.every((w, i) => w === b[i]);
318
+ function currenciesFor(catalog, key) {
319
+ const exact = catalog.currencies.find((c) => c.id === key);
320
+ if (exact)
321
+ return [exact];
322
+ const k = words(key);
323
+ if (k.length === 0)
324
+ return [];
325
+ const byId = catalog.currencies.filter((c) => sameWords(words(c.id), k));
326
+ if (byId.length > 0)
327
+ return byId;
328
+ const byName = catalog.currencies.filter((c) => sameWords(words(c.name), k));
329
+ if (byName.length > 0)
330
+ return byName;
331
+ return catalog.currencies.filter((c) => {
332
+ const name = words(c.name);
333
+ return name.some((_, i) => sameWords(name.slice(i, i + k.length), k));
334
+ });
335
+ }
@@ -1,8 +1,9 @@
1
1
  import { bonusStatus } from "./bonus.js";
2
2
  import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
3
3
  import { membershipName } from "./memberships.js";
4
+ import { milestoneSummaries } from "./milestones.js";
4
5
  import { optionName, ruleParts, ruleWhat } from "./rule-text.js";
5
- import { daysBetween, inEffect, periodEnd } from "./periods.js";
6
+ import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
6
7
  import { STALE_AFTER_DAYS } from "./recommend.js";
7
8
  import { rotationGap } from "./rotation.js";
8
9
  import { activationRules } from "./setup.js";
@@ -77,7 +78,9 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
77
78
  const after = `${limit}${activate}`;
78
79
  return `${head} ${listed(whats)}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
79
80
  }),
80
- ...(shown.some((r) => r.match.any && r.rate > card.base_rate)
81
+ ...(shown.some((r) => r.match.any &&
82
+ r.geography !== 'foreign_only' &&
83
+ r.rate > card.base_rate)
81
84
  ? []
82
85
  : [`${earnText(catalog, card.base_rate, currency)} on everything else`]),
83
86
  ];
@@ -114,6 +117,13 @@ function toActivate(catalog, card, held, today) {
114
117
  return `${a.what}${when === '' ? '' : ` (${when})`}`;
115
118
  });
116
119
  }
120
+ // Picks that carry over have no end date.
121
+ function picksUntil(card, key) {
122
+ if (card.choices.every((c) => c.period === 'until_changed'))
123
+ return null;
124
+ const period = choicePeriod(card);
125
+ return period === null ? null : periodEnd(period, key, null);
126
+ }
117
127
  function capSummaries(catalog, card, held, today) {
118
128
  const caps = new Map();
119
129
  for (const shared of card.caps)
@@ -143,6 +153,9 @@ function capSummaries(catalog, card, held, today) {
143
153
  amount: cap.amount,
144
154
  period: cap.period,
145
155
  currentPeriod,
156
+ until: currentPeriod === null
157
+ ? null
158
+ : periodEnd(cap.period, currentPeriod, held.anniversary ?? null),
146
159
  spent,
147
160
  remaining: currentPeriod === null ? null : Math.max(0, cap.amount - spent),
148
161
  };
@@ -180,14 +193,13 @@ export function walletSummary(catalog, wallet, today) {
180
193
  dataIsStale: daysBetween(card.last_verified, today) > STALE_AFTER_DAYS,
181
194
  rotationGap: rotationGap(card, today),
182
195
  caps: capSummaries(catalog, card, held, today),
196
+ milestones: milestoneSummaries(card, held, today),
183
197
  chosen: held.chosen
184
198
  ? {
185
199
  period: held.chosen.period,
186
200
  current: pickedCategories(card, held, today).staleChoice === null,
187
201
  // Picks that carry over have no end date.
188
- until: card.choices.every((c) => c.period === 'until_changed')
189
- ? null
190
- : periodEnd(held.chosen.period),
202
+ until: picksUntil(card, held.chosen.period),
191
203
  picks: held.chosen.picks,
192
204
  pickNames: Object.fromEntries(Object.entries(held.chosen.picks).map(([choice, options]) => [
193
205
  choiceName(choice),
@@ -1,4 +1,4 @@
1
- import { money, NETWORK_NAMES } from "../engine/text.js";
1
+ import { foreignFeeText, money, NETWORK_NAMES } from "../engine/text.js";
2
2
  export function formatCardInfo(card) {
3
3
  const fee = card.annualFee === null
4
4
  ? ''
@@ -19,7 +19,9 @@ export function formatCardInfo(card) {
19
19
  ? [`activate with the issuer: ${card.activation.join(', ')}`]
20
20
  : []),
21
21
  ...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
22
+ ...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
22
23
  ...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
24
+ foreignFeeText(card.foreignTransactionFee).toLowerCase(),
23
25
  ];
24
26
  return [
25
27
  `${card.id}: ${card.name} (${NETWORK_NAMES[card.network] ?? card.network}${fee})${card.held ? ' [in wallet]' : ''}`,
@@ -30,7 +30,10 @@ function cardLines(cards, style, bonusPick) {
30
30
  const pctWidth = Math.max(...cards.map((c) => pct(c.effectiveReturnPct).length));
31
31
  const lines = [];
32
32
  cards.forEach((c) => {
33
- const earned = c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`;
33
+ const earned = (c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`) +
34
+ (c.milestoneBonus === null
35
+ ? ''
36
+ : ` + ${money(c.milestoneBonus.dollars)} spend bonus`);
34
37
  const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
35
38
  lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
36
39
  for (const r of c.reasoning)
@@ -53,9 +56,12 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
53
56
  const noCard = noCardText(rec.merchant.resolvedName);
54
57
  if (rec.decision && top) {
55
58
  const earned = top.earned === null ? '' : `, about ${dollarsAndCents(top.earned)}`;
59
+ const milestone = top.milestoneBonus === null
60
+ ? ''
61
+ : `, plus a spend bonus worth about ${money(top.milestoneBonus.dollars)}`;
56
62
  const confidence = rec.decision.confidence;
57
63
  const why = rec.bonus ? ' for its welcome bonus' : '';
58
- lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${capitalize(confidence)} confidence.`);
64
+ lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}${milestone}.`)} ${capitalize(confidence)} confidence.`);
59
65
  if (rec.bonus && rec.notes[0])
60
66
  lines.push(rec.notes[0]);
61
67
  }