earnrate 0.2.8 → 0.2.9

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 (97) hide show
  1. package/README.md +31 -5
  2. package/data/CHANGELOG.md +133 -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 +36 -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 +29 -1
  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 +284 -0
  62. package/data/merchants/seed.yaml +1055 -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 +1 -1
  76. package/dist/engine/milestones.js +83 -0
  77. package/dist/engine/periods.js +29 -12
  78. package/dist/engine/recommend.js +39 -9
  79. package/dist/engine/record.js +39 -1
  80. package/dist/engine/rule-text.js +5 -1
  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/wallet.js +18 -4
  88. package/dist/import/analyze.js +42 -2
  89. package/dist/mcp/inputs.js +10 -0
  90. package/dist/mcp/schemas.js +47 -2
  91. package/dist/mcp/tools/account.js +1 -1
  92. package/dist/mcp/tools/answers.js +8 -4
  93. package/dist/mcp/tools/wallet.js +18 -10
  94. package/dist/schema/card.js +22 -1
  95. package/dist/schema/common.js +4 -0
  96. package/dist/schema/wallet.js +2 -0
  97. package/package.json +5 -3
@@ -2,13 +2,19 @@ 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 isForeign = (purchase) => purchase.country !== 'US';
15
+ // Any purchase outside the U.S., when no country is named: ZZ is a code ISO
16
+ // leaves unassigned, and nothing prints it.
17
+ export const ABROAD = 'ZZ';
12
18
  // The warning when no card works; the CLI shows it as the answer instead.
13
19
  export const noCardText = (merchant) => `None of your cards work at ${merchant}.`;
14
20
  const NEAR_CAP_SHARE = 0.2;
@@ -66,14 +72,22 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
66
72
  }
67
73
  const valuation = cardValuation(catalog, wallet, card);
68
74
  const evaluation = evaluateCard(card, held, ctx);
69
- const rate = evaluation.best.rate;
75
+ const milestones = milestonesReached(card, held, purchase.date, purchase.amount);
76
+ const bonusPoints = milestonePoints(milestones);
77
+ const perDollar = evaluation.best.rate +
78
+ (bonusPoints > 0 && purchase.amount ? bonusPoints / purchase.amount : 0);
79
+ // An unknown fee isn't guessed: it's warned about instead.
80
+ const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
70
81
  entries.push({
71
82
  card,
72
83
  held,
73
84
  valuation,
74
85
  evaluation,
75
- effectiveReturnPct: rate * valuation.centsPerUnit,
76
- upside: rate * (valuation.range?.[1] ?? valuation.centsPerUnit),
86
+ milestones,
87
+ perDollar,
88
+ fee,
89
+ effectiveReturnPct: perDollar * valuation.centsPerUnit - fee,
90
+ upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) - fee,
77
91
  });
78
92
  }
79
93
  entries.sort(compareEntries);
@@ -96,6 +110,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
96
110
  const over = amount - cap.remaining;
97
111
  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
112
  }
113
+ for (const hit of entry.milestones)
114
+ lines.push(reachedText(card, hit));
99
115
  if (valuation.pooledFrom !== null) {
100
116
  const partners = card.pools_into?.when_wallet_has ?? [];
101
117
  const heldPartners = wallet.cards
@@ -106,7 +122,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
106
122
  lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
107
123
  }
108
124
  if (currency !== 'cash') {
109
- lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.effectiveReturnPct)} back.`);
125
+ lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.perDollar * valuation.centsPerUnit)} back.`);
110
126
  }
111
127
  return lines;
112
128
  }
@@ -114,6 +130,10 @@ function warningsFor(catalog, entry, purchase) {
114
130
  const { card, valuation, evaluation } = entry;
115
131
  const { best, nominal } = evaluation;
116
132
  const warnings = [];
133
+ if (entry.fee > 0)
134
+ warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.effectiveReturnPct)}.`);
135
+ if (isForeign(purchase) && card.foreign_transaction_fee === null)
136
+ warnings.push("Its foreign transaction fee isn't on file, so none is subtracted. Check the issuer's terms before using it abroad.");
117
137
  const capLabel = (e) => e.cap
118
138
  ? `${money(e.cap.cap.amount)} ${CAP_ADJECTIVES[e.cap.cap.period]} cap`
119
139
  : '';
@@ -165,6 +185,7 @@ function toRanked(catalog, wallet, entry, purchase) {
165
185
  currency: valuation.currency,
166
186
  centsPerUnit: valuation.centsPerUnit,
167
187
  effectiveReturnPct: entry.effectiveReturnPct,
188
+ foreignFee: entry.fee > 0 ? entry.fee : null,
168
189
  earned: purchase.amount === null
169
190
  ? null
170
191
  : Math.round(purchase.amount * entry.effectiveReturnPct) / 100,
@@ -208,16 +229,17 @@ function sensitivity(catalog, entries) {
208
229
  const o = other.valuation;
209
230
  if (w.currency === o.currency)
210
231
  continue;
211
- const wRate = winner.evaluation.best.rate;
212
- const oRate = other.evaluation.best.rate;
232
+ const wRate = winner.perDollar;
233
+ const oRate = other.perDollar;
234
+ // Net return is rate × value − fee, so each flip point adds the fee back.
213
235
  if (w.range && wRate > 0) {
214
- const threshold = other.effectiveReturnPct / wRate;
236
+ const threshold = (other.effectiveReturnPct + winner.fee) / wRate;
215
237
  if (threshold < w.centsPerUnit && threshold >= w.range[0]) {
216
238
  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)}¢).`);
217
239
  }
218
240
  }
219
241
  if (o.range && oRate > 0) {
220
- const threshold = winner.effectiveReturnPct / oRate;
242
+ const threshold = (winner.effectiveReturnPct + other.fee) / oRate;
221
243
  if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
222
244
  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)}¢).`);
223
245
  }
@@ -251,6 +273,11 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
251
273
  const values = pointValuesText(catalog, entries.map((e) => e.valuation));
252
274
  if (values !== null)
253
275
  assumptions.push(values);
276
+ if (isForeign(purchase))
277
+ assumptions.push('Treats this as a purchase outside the U.S.: each card is ranked after its foreign transaction fee.');
278
+ for (const e of entries)
279
+ for (const hit of e.milestones)
280
+ 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
281
  return assumptions;
255
282
  }
256
283
  export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
@@ -259,6 +286,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
259
286
  const winner = entries[0];
260
287
  const warnings = [];
261
288
  const notes = [];
289
+ const charging = entries.filter((e) => e.fee > 0);
290
+ if (charging.length > 0 && entries.length > 1)
291
+ 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
292
  if (merchant.source === 'none') {
263
293
  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
294
  }
@@ -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,7 +26,11 @@ 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 ?? [];
@@ -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]' : ''}`,
@@ -1,4 +1,4 @@
1
- import { money, NETWORK_NAMES, shortDate } from "../engine/text.js";
1
+ import { CAP_PERIODS, money, NETWORK_NAMES, shortDate } from "../engine/text.js";
2
2
  // "To do now", above the card details; nothing when all's done.
3
3
  export function formatChecklist(items) {
4
4
  return items.length === 0
@@ -34,8 +34,11 @@ export function formatWalletSummary(summary) {
34
34
  if (c.rotationGap !== null)
35
35
  lines.push(` Rotating categories for ${c.rotationGap} aren't in earnrate yet.`);
36
36
  for (const cap of c.caps)
37
- if (cap.remaining !== null)
38
- lines.push(` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''} (cap key ${cap.key}).`);
37
+ lines.push(cap.remaining === null
38
+ ? ` Cap on ${cap.label}: ${money(cap.amount)} ${CAP_PERIODS[cap.period]}; give the card's anniversary to track it (cap key ${cap.key}).`
39
+ : ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''}${cap.until === null ? '' : `, through ${shortDate(cap.until)}`} (cap key ${cap.key}).`);
40
+ for (const m of c.milestones)
41
+ lines.push(` Spend bonus (${m.id}): ${m.text}`);
39
42
  if (c.anniversary !== null)
40
43
  lines.push(` Account anniversary: ${c.anniversary}.`);
41
44
  const b = c.bonus;
@@ -53,7 +56,7 @@ export function formatWalletSummary(summary) {
53
56
  : "Point values: earnrate's defaults.";
54
57
  return [...cards, memberships, values].join('\n');
55
58
  }
56
- export function formatBooking(amount, cardId, cap, bonus) {
59
+ export function formatBooking(amount, cardId, cap, bonus, milestones = []) {
57
60
  const messages = [];
58
61
  if (cap) {
59
62
  const left = Math.max(0, cap.capAmount - cap.after);
@@ -65,5 +68,16 @@ export function formatBooking(amount, cardId, cap, bonus) {
65
68
  ? `Welcome bonus spend met on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)}.`
66
69
  : `Welcome bonus on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
67
70
  }
71
+ // Every milestone counts the same purchases, so one line covers them.
72
+ const first = milestones[0];
73
+ if (first) {
74
+ const reached = milestones.filter((m) => m.before < m.spend && m.after >= m.spend);
75
+ const next = milestones.find((m) => m.after < m.spend);
76
+ messages.push([
77
+ `Spend on ${cardId} this year: ${money(first.after)}.`,
78
+ ...reached.map((m) => `That reaches its ${money(m.spend)} spend bonus.`),
79
+ ...(next ? [`${money(next.spend - next.after)} to its next one.`] : []),
80
+ ].join(' '));
81
+ }
68
82
  return messages.join(' ');
69
83
  }
@@ -1,6 +1,7 @@
1
1
  import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
2
2
  import { HALF_CENT } from "../engine/compare.js";
3
3
  import { capByKey, categoryMatches } from "../engine/evaluate.js";
4
+ import { milestoneProgress } from "../engine/milestones.js";
4
5
  import { periodKey } from "../engine/periods.js";
5
6
  import { recommend } from "../engine/recommend.js";
6
7
  import { applyBooking, capBooking } from "../engine/record.js";
@@ -41,8 +42,14 @@ function capPeriodKey(catalog, wallet, booking, date) {
41
42
  // up partway through the year is accounted for on both sides.
42
43
  export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorded = () => false) {
43
44
  const sorted = [...entries].sort((a, b) => a.line.date.localeCompare(b.line.date));
44
- let actual = wallet;
45
- let simulated = wallet;
45
+ // The earned-versus-best replay leaves spend milestones out: they pay once
46
+ // on the card's total, whichever card is best for each purchase.
47
+ const replay = {
48
+ ...wallet,
49
+ cards: wallet.cards.map(({ milestone_usage: _usage, ...c }) => c),
50
+ };
51
+ let actual = replay;
52
+ let simulated = replay;
46
53
  let current = wallet;
47
54
  const byCard = new Map();
48
55
  const missed = new Map();
@@ -69,8 +76,40 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
69
76
  entries: [],
70
77
  });
71
78
  }
79
+ const milestones = [];
80
+ for (const held of wallet.cards) {
81
+ const card = catalog.cards.find((c) => c.id === held.id);
82
+ if (!card)
83
+ continue;
84
+ for (const p of milestoneProgress(card, held, today))
85
+ if (p.period !== null)
86
+ milestones.push({
87
+ cardId: held.id,
88
+ milestoneId: p.milestone.id,
89
+ period: p.period,
90
+ spend: p.milestone.spend,
91
+ before: p.spent,
92
+ after: p.spent,
93
+ entries: [],
94
+ });
95
+ }
72
96
  for (const entry of sorted) {
73
97
  const { cardId, line } = entry;
98
+ if (!alreadyRecorded(entry, 'milestone')) {
99
+ const held = wallet.cards.find((c) => c.id === cardId);
100
+ for (const m of milestones) {
101
+ if (m.cardId !== cardId)
102
+ continue;
103
+ const card = catalog.cards.find((c) => c.id === cardId);
104
+ const milestone = card?.milestones.find((x) => x.id === m.milestoneId);
105
+ if (!milestone ||
106
+ periodKey(milestone.period, line.date, held?.anniversary ?? null) !==
107
+ m.period)
108
+ continue;
109
+ m.after = roundCents(Math.max(0, m.after + line.amount));
110
+ m.entries.push(entry);
111
+ }
112
+ }
74
113
  const progress = bonuses.get(cardId);
75
114
  if (progress &&
76
115
  countsTowardBonus(progress, line.date) &&
@@ -202,5 +241,6 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
202
241
  outsideCurrentPeriod,
203
242
  alreadyImported,
204
243
  bonuses: [...bonuses.values()].filter((b) => b.entries.length > 0),
244
+ milestones: milestones.filter((m) => m.entries.length > 0),
205
245
  };
206
246
  }
@@ -3,6 +3,7 @@ import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
3
3
  import { profileText, VALUATION_PROFILES } from "../engine/profiles.js";
4
4
  import { knownMemberships } from "../engine/setup.js";
5
5
  import { IsoDate } from "../schema/common.js";
6
+ import { COUNTRY_CODE } from "../schema/common.js";
6
7
  export const Merchant = z
7
8
  .string()
8
9
  .min(1)
@@ -12,6 +13,11 @@ export const ChannelInput = z
12
13
  .optional()
13
14
  .describe('How the purchase is made. Omit if unknown; the answer says what it assumed.');
14
15
  export const DateInput = IsoDate.optional().describe('Purchase date, YYYY-MM-DD. Defaults to today.');
16
+ export const CountryInput = z
17
+ .string()
18
+ .regex(COUNTRY_CODE, 'Use a two-letter country code, like US or FR.')
19
+ .optional()
20
+ .describe('Where the purchase is made, as a two-letter country code (FR, GB, JP). Pass it when the user is paying abroad or in another currency: abroad, cards are ranked after their foreign transaction fees. Default US.');
15
21
  export const CardId = z
16
22
  .string()
17
23
  .min(1)
@@ -66,6 +72,10 @@ const cardFields = {
66
72
  .record(z.string(), z.number().nonnegative())
67
73
  .optional()
68
74
  .describe('Dollars already spent this period on a capped bonus, for a card the user starts tracking partway through: cap key (as list_wallet shows it) → dollars, e.g. {"five-percent-quarterly": 400}. Ask; recorded purchases add to it from then on.'),
75
+ milestoneSpent: z
76
+ .record(z.string(), z.number().nonnegative())
77
+ .optional()
78
+ .describe('Dollars already spent on the card this year, for its spend milestones (bonuses after a yearly spending total, as search_cards lists them): milestone id → dollars, e.g. {"spend-15k": 9000}. Every purchase on the card counts, so give each milestone the same total. Ask; recorded purchases add to it from then on.'),
69
79
  bonus: z
70
80
  .object({
71
81
  opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),