earnrate 0.2.7 → 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 (103) hide show
  1. package/README.md +31 -5
  2. package/data/CHANGELOG.md +142 -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 +16 -1
  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 +21 -9
  66. package/dist/cli/run.js +3 -1
  67. package/dist/cli/wallet.js +5 -1
  68. package/dist/data/atomic-write.js +13 -0
  69. package/dist/data/file-store.js +2 -2
  70. package/dist/data/integrity.js +17 -0
  71. package/dist/data/store.js +5 -2
  72. package/dist/data/wallet-edit.js +35 -24
  73. package/dist/engine/cheat-sheet.js +46 -2
  74. package/dist/engine/checklist.js +19 -8
  75. package/dist/engine/evaluate.js +2 -0
  76. package/dist/engine/explore.js +1 -1
  77. package/dist/engine/milestones.js +83 -0
  78. package/dist/engine/periods.js +29 -12
  79. package/dist/engine/recommend.js +43 -9
  80. package/dist/engine/record.js +39 -1
  81. package/dist/engine/rule-text.js +5 -1
  82. package/dist/engine/setup.js +14 -2
  83. package/dist/engine/simulate.js +25 -3
  84. package/dist/engine/text.js +11 -0
  85. package/dist/engine/wallet-change.js +70 -3
  86. package/dist/engine/wallet-summary.js +17 -5
  87. package/dist/format/cards.js +3 -1
  88. package/dist/format/wallet-yaml.js +5 -1
  89. package/dist/format/wallet.js +18 -4
  90. package/dist/import/analyze.js +42 -2
  91. package/dist/mcp/inputs.js +10 -0
  92. package/dist/mcp/schemas.js +47 -2
  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/merchants/cache.js +3 -5
  97. package/dist/merchants/fallback.js +8 -0
  98. package/dist/merchants/missed.js +5 -6
  99. package/dist/schema/card-number.js +31 -0
  100. package/dist/schema/card.js +22 -1
  101. package/dist/schema/common.js +4 -0
  102. package/dist/schema/wallet.js +12 -2
  103. package/package.json +5 -3
@@ -0,0 +1,83 @@
1
+ import { periodEnd, periodKey } from "./periods.js";
2
+ import { listed, MILESTONE_PERIODS, money, roundCents, wholeDollars, } from "./text.js";
3
+ export function milestoneProgress(card, held, date) {
4
+ return card.milestones.map((milestone) => {
5
+ const period = periodKey(milestone.period, date, held.anniversary ?? null);
6
+ const usage = held.milestone_usage?.[milestone.id];
7
+ return {
8
+ milestone,
9
+ period,
10
+ spent: usage !== undefined && usage.period === period ? usage.spent : 0,
11
+ tracked: usage !== undefined,
12
+ };
13
+ });
14
+ }
15
+ // The milestones this purchase reaches: tracked spending before it is under
16
+ // the total, and with it the total is met.
17
+ export function milestonesReached(card, held, date, amount) {
18
+ if (amount === null || amount <= 0)
19
+ return [];
20
+ return milestoneProgress(card, held, date).flatMap(({ milestone: m, period, spent, tracked }) => tracked &&
21
+ period !== null &&
22
+ spent < m.spend &&
23
+ m.spend <= roundCents(spent + amount)
24
+ ? [
25
+ {
26
+ id: m.id,
27
+ spend: m.spend,
28
+ period,
29
+ before: spent,
30
+ points: m.points ?? null,
31
+ rewards: m.rewards ?? [],
32
+ },
33
+ ]
34
+ : []);
35
+ }
36
+ export const milestonePoints = (hits) => hits.reduce((sum, h) => sum + (h.points ?? 0), 0);
37
+ // "10,000 bonus points and Gold Elite status"
38
+ export function milestonePays(m) {
39
+ return listed([
40
+ ...(m.points === undefined || m.points === null
41
+ ? []
42
+ : [`${m.points.toLocaleString('en-US')} bonus points`]),
43
+ ...(m.rewards ?? []),
44
+ ]);
45
+ }
46
+ // "the $15,000 calendar-year spend for 10,000 bonus points"
47
+ export const milestoneWhat = (m) => `the ${wholeDollars(m.spend)} ${MILESTONE_PERIODS[m.period]} spend for ${milestonePays(m)}`;
48
+ export function reachedText(card, hit) {
49
+ const m = card.milestones.find((x) => x.id === hit.id);
50
+ if (!m)
51
+ return '';
52
+ return `This purchase reaches ${milestoneWhat(m)} (${money(hit.before)} tracked on this card in ${hit.period} before it).`;
53
+ }
54
+ const THIS_PERIOD = {
55
+ calendar_year: 'this year',
56
+ cardmember_year: 'this cardmember year',
57
+ };
58
+ export function milestoneSummaries(card, held, today) {
59
+ return milestoneProgress(card, held, today).map(({ milestone: m, period, spent, tracked }) => {
60
+ const pays = milestonePays(m);
61
+ const text = period === null
62
+ ? `${wholeDollars(m.spend)} a cardmember year for ${pays}: give the card's anniversary to track it.`
63
+ : !tracked
64
+ ? `${wholeDollars(m.spend)} ${THIS_PERIOD[m.period]} for ${pays}; spending toward it isn't tracked yet.`
65
+ : spent >= m.spend
66
+ ? `${wholeDollars(m.spend)} reached ${THIS_PERIOD[m.period]}: ${pays}.`
67
+ : `${money(spent)} of ${wholeDollars(m.spend)} ${THIS_PERIOD[m.period]} for ${pays}.`;
68
+ return {
69
+ id: m.id,
70
+ spend: m.spend,
71
+ period: m.period,
72
+ currentPeriod: period,
73
+ until: period === null
74
+ ? null
75
+ : periodEnd(m.period, period, held.anniversary ?? null),
76
+ spent,
77
+ tracked,
78
+ points: m.points ?? null,
79
+ rewards: m.rewards ?? [],
80
+ text,
81
+ };
82
+ });
83
+ }
@@ -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,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
  : '';
@@ -146,6 +166,10 @@ function warningsFor(catalog, entry, purchase) {
146
166
  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.`);
147
167
  }
148
168
  for (const rule of evaluation.needsActivation) {
169
+ if (rule.activate_by !== undefined && rule.activate_by < purchase.date) {
170
+ warnings.push(`The bonus here (${earnText(catalog, rule.rate, valuation.currency)}) needed activating by ${shortDate(rule.activate_by)}.`);
171
+ continue;
172
+ }
149
173
  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)}`}.`);
150
174
  }
151
175
  return warnings;
@@ -161,6 +185,7 @@ function toRanked(catalog, wallet, entry, purchase) {
161
185
  currency: valuation.currency,
162
186
  centsPerUnit: valuation.centsPerUnit,
163
187
  effectiveReturnPct: entry.effectiveReturnPct,
188
+ foreignFee: entry.fee > 0 ? entry.fee : null,
164
189
  earned: purchase.amount === null
165
190
  ? null
166
191
  : Math.round(purchase.amount * entry.effectiveReturnPct) / 100,
@@ -204,16 +229,17 @@ function sensitivity(catalog, entries) {
204
229
  const o = other.valuation;
205
230
  if (w.currency === o.currency)
206
231
  continue;
207
- const wRate = winner.evaluation.best.rate;
208
- 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.
209
235
  if (w.range && wRate > 0) {
210
- const threshold = other.effectiveReturnPct / wRate;
236
+ const threshold = (other.effectiveReturnPct + winner.fee) / wRate;
211
237
  if (threshold < w.centsPerUnit && threshold >= w.range[0]) {
212
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)}¢).`);
213
239
  }
214
240
  }
215
241
  if (o.range && oRate > 0) {
216
- const threshold = winner.effectiveReturnPct / oRate;
242
+ const threshold = (winner.effectiveReturnPct + other.fee) / oRate;
217
243
  if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
218
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)}¢).`);
219
245
  }
@@ -247,6 +273,11 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
247
273
  const values = pointValuesText(catalog, entries.map((e) => e.valuation));
248
274
  if (values !== null)
249
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.`);
250
281
  return assumptions;
251
282
  }
252
283
  export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
@@ -255,6 +286,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
255
286
  const winner = entries[0];
256
287
  const warnings = [];
257
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.`);
258
292
  if (merchant.source === 'none') {
259
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.`);
260
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,12 +40,14 @@ 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
46
48
  .filter((r) => r.requires?.activation === true &&
47
49
  (r.valid_to === undefined || r.valid_to >= today) &&
50
+ (r.activate_by === undefined || r.activate_by >= today) &&
48
51
  (r.valid_from === undefined ||
49
52
  daysBetween(today, r.valid_from) <= ACTIVATION_LOOKAHEAD_DAYS))
50
53
  .map((r) => r.id);
@@ -73,6 +76,7 @@ export function cardInfo(catalog, card, wallet, today) {
73
76
  network: card.network,
74
77
  currency: card.currency,
75
78
  annualFee: card.annual_fee ?? null,
79
+ foreignTransactionFee: card.foreign_transaction_fee,
76
80
  business: card.business,
77
81
  onlyAt: card.only_at ?? null,
78
82
  held: wallet?.cards.some((c) => c.id === card.id) ?? false,
@@ -93,6 +97,14 @@ export function cardInfo(catalog, card, wallet, today) {
93
97
  },
94
98
  activation: activationRules(card, today),
95
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
+ })),
96
108
  };
97
109
  }
98
110
  // Every word of the query has to match a word of the card's id, name or
@@ -161,7 +173,7 @@ export function setupNotes(catalog, wallet, held, today) {
161
173
  if (needsAnniversary(card) && held.anniversary === undefined) {
162
174
  notes.push({
163
175
  kind: 'anniversary',
164
- 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.`,
165
177
  });
166
178
  }
167
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',
@@ -1,3 +1,4 @@
1
+ import { CARD_NUMBER_LABEL, labelHasCardNumber } from "../schema/card-number.js";
1
2
  import { WALLET_VERSION, } from "../schema/wallet.js";
2
3
  import { addMonths } from "./bonus.js";
3
4
  import { capByKey } from "./evaluate.js";
@@ -33,9 +34,8 @@ export function cardFields(card, held, input, today) {
33
34
  else if (input.checkoutLabel !== undefined) {
34
35
  if (input.checkoutLabel.trim() === '')
35
36
  problems.push('The checkout label is empty.');
36
- // earnrate never stores card numbers; a last four is fine.
37
- if (/\d{8,}/.test(input.checkoutLabel.replace(/[\s-]/g, '')))
38
- problems.push('The checkout label looks like it has a card number in it. earnrate never stores card numbers; use a name like "Amex Gold".');
37
+ if (labelHasCardNumber(input.checkoutLabel))
38
+ problems.push(CARD_NUMBER_LABEL);
39
39
  fields.checkout_label = input.checkoutLabel.trim();
40
40
  }
41
41
  if (input.anniversary !== undefined) {
@@ -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]' : ''}`,