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
package/dist/cli/args.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { isValuationProfile, } from "../engine/profiles.js";
3
3
  import { knownMemberships } from "../engine/setup.js";
4
+ import { COUNTRY_CODE, countryProblem } from "../schema/common.js";
4
5
  // Shared by every subcommand, so each flag means the same thing everywhere.
5
6
  // A string result is the error to show.
6
7
  const LOOKS_LIKE_AMOUNT = /^\$?[\d,]*\.?\d+$/;
@@ -109,12 +110,8 @@ export function parseCliArgs(argv) {
109
110
  ? values.valuation
110
111
  : null;
111
112
  const country = values.country ?? 'US';
112
- if (!/^[A-Za-z]{2}$/.test(country)) {
113
- return {
114
- kind: 'error',
115
- message: `"${country}" isn't a country code. Use two letters, like US or FR.`,
116
- };
117
- }
113
+ if (!COUNTRY_CODE.test(country))
114
+ return { kind: 'error', message: countryProblem(country) };
118
115
  return {
119
116
  kind: 'run',
120
117
  options: {
@@ -6,6 +6,7 @@ import { isValuationProfile, profileText, VALUATION_PROFILES, withValuationProfi
6
6
  import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, typicalSpend, TYPICAL_SPEND_NOTE, } from "../engine/simulate.js";
7
7
  import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../format/explore.js";
8
8
  import { BOTH_CHANNELS, cardList, channelOf, dateProblem, membershipProblem, splitAmount, valuationProblem, } from "./args.js";
9
+ import { COUNTRY_CODE, countryProblem } from "../schema/common.js";
9
10
  import { cardsToUse, RUNTIME_ERROR, savedSettings, USAGE_ERROR, } from "./run.js";
10
11
  const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or points and miles cards only.
11
12
  --no-fee Cards with no annual fee.
@@ -30,6 +31,8 @@ Options:
30
31
  ${FILTER_OPTIONS}
31
32
  --online, --in-store How you're paying. Default: inferred from the merchant.
32
33
  --date <YYYY-MM-DD> Purchase date. Default: today.
34
+ --country <code> Where you're paying. Abroad, cards rank after their
35
+ foreign transaction fees. Default: US.
33
36
  --all List every card, not just the top 10.
34
37
  --json Print the full result as JSON.
35
38
  -h, --help Show this help.
@@ -104,6 +107,7 @@ export async function runBest(argv, io) {
104
107
  online: { type: 'boolean' },
105
108
  'in-store': { type: 'boolean' },
106
109
  date: { type: 'string' },
110
+ country: { type: 'string' },
107
111
  },
108
112
  });
109
113
  }
@@ -135,6 +139,11 @@ export async function runBest(argv, io) {
135
139
  io.stderr(`${dated}\n`);
136
140
  return USAGE_ERROR;
137
141
  }
142
+ const country = values.country ?? 'US';
143
+ if (!COUNTRY_CODE.test(country)) {
144
+ io.stderr(`${countryProblem(country)}\n`);
145
+ return USAGE_ERROR;
146
+ }
138
147
  const catalog = await loadCatalog();
139
148
  const settings = settingsFrom(values, catalog, await savedSettings(io));
140
149
  if (typeof settings === 'string') {
@@ -147,6 +156,7 @@ export async function runBest(argv, io) {
147
156
  amount,
148
157
  channel: channelOf(values),
149
158
  date: values.date ?? io.today,
159
+ country: country.toUpperCase(),
150
160
  }, settings);
151
161
  io.stdout(values.json
152
162
  ? `${JSON.stringify(result, null, 2)}\n`
@@ -41,10 +41,10 @@ const LedgerSchema = z.strictObject({
41
41
  // sha256 of card, date, amount and description. Not reversible.
42
42
  recorded: z.array(z.string()),
43
43
  });
44
- // Caps and welcome bonuses are recorded separately, so a purchase imported
45
- // for its cap still counts toward a bonus added to the wallet later.
44
+ // Caps, welcome bonuses and spend milestones are recorded separately, so a
45
+ // purchase imported for its cap still counts toward a bonus added later.
46
46
  const fingerprint = ({ cardId, line }, kind = 'cap') => createHash('sha256')
47
- .update(`${kind === 'bonus' ? 'bonus|' : ''}${cardId}|${line.date}|${String(line.amount)}|${line.description}`)
47
+ .update(`${kind === 'cap' ? '' : `${kind}|`}${cardId}|${line.date}|${String(line.amount)}|${line.description}`)
48
48
  .digest('hex');
49
49
  // "statement.csv:chase-sapphire-preferred" names the card inline.
50
50
  function splitTarget(arg) {
@@ -132,6 +132,9 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
132
132
  for (const b of a.bonuses) {
133
133
  out.push(`Welcome bonus on ${labelOf(b.cardId)}: ${dollarsAndCents(b.before)} → ${dollarsAndCents(b.after)} of ${dollarsAndCents(b.spend)}${b.deadline ? ` by ${b.deadline}` : ''}.`);
134
134
  }
135
+ for (const m of a.milestones) {
136
+ out.push(`Spend bonus on ${labelOf(m.cardId)}: ${dollarsAndCents(m.before)} → ${dollarsAndCents(m.after)} of ${dollarsAndCents(m.spend)} for ${m.period}${m.before < m.spend && m.after >= m.spend ? ', reached' : ''}.`);
137
+ }
135
138
  return `${out.join('\n')}\n`;
136
139
  }
137
140
  export async function runImport(argv, io) {
@@ -229,6 +232,12 @@ export async function runImport(argv, io) {
229
232
  ], values.valuation);
230
233
  io.stdout(formatAnalysis(analysis, labelOf, valuation, categoryName));
231
234
  const bonusEntries = analysis.bonuses.flatMap((b) => b.entries);
235
+ // A purchase counts toward each of its card's milestones; record it once.
236
+ const milestoneEntries = [
237
+ ...new Map(analysis.milestones
238
+ .flatMap((m) => m.entries)
239
+ .map((e) => [fingerprint(e, 'milestone'), e])).values(),
240
+ ];
232
241
  const toRecord = [
233
242
  ...(analysis.bookings.length > 0
234
243
  ? [`${String(analysis.bookings.length)} purchase(s) against your caps`]
@@ -236,6 +245,9 @@ export async function runImport(argv, io) {
236
245
  ...(bonusEntries.length > 0
237
246
  ? [`${String(bonusEntries.length)} toward welcome bonuses`]
238
247
  : []),
248
+ ...(milestoneEntries.length > 0
249
+ ? [`${String(milestoneEntries.length)} toward spend bonuses`]
250
+ : []),
239
251
  ];
240
252
  if (!values.apply || toRecord.length === 0) {
241
253
  io.stdout(values.apply || toRecord.length === 0
@@ -243,11 +255,13 @@ export async function runImport(argv, io) {
243
255
  : `Nothing was written. Run again with --apply to record ${toRecord.join(' and ')}.\n`);
244
256
  return 0;
245
257
  }
246
- await writeProgress(walletFile, analysis.bookings.map((e) => e.booking), analysis.bonuses);
258
+ await writeProgress(walletFile, analysis.bookings.map((e) => e.booking), analysis.bonuses, analysis.milestones);
247
259
  for (const entry of analysis.bookings)
248
260
  recorded.add(fingerprint(entry));
249
261
  for (const entry of bonusEntries)
250
262
  recorded.add(fingerprint(entry, 'bonus'));
263
+ for (const entry of milestoneEntries)
264
+ recorded.add(fingerprint(entry, 'milestone'));
251
265
  await writeFileAtomic(ledgerFile, `${JSON.stringify({ version: 1, recorded: [...recorded] }, null, 2)}\n`);
252
266
  io.stdout(`Recorded ${toRecord.join(' and ')} in ${displayPath(walletFile)}.\n`);
253
267
  return 0;
package/dist/cli/run.js CHANGED
@@ -65,7 +65,8 @@ Options:
65
65
  --all List every card, not just the top three.
66
66
  --json Print the full recommendation as JSON.
67
67
  --date <YYYY-MM-DD> Purchase date. Default: today.
68
- --country <code> Where you're paying. Default: US.
68
+ --country <code> Where you're paying. Abroad, cards rank after their
69
+ foreign transaction fees. Default: US.
69
70
  --cards <ids> Answer for these cards instead of your wallet, comma
70
71
  separated: --cards amex-gold,chase-prime-visa.
71
72
  --member <name> A membership you have, in place of the saved ones.
@@ -237,6 +238,7 @@ export async function run(argv, io) {
237
238
  amount: purchase.amount,
238
239
  channel: purchase.channel,
239
240
  date: purchase.date,
241
+ country: purchase.country,
240
242
  }, {
241
243
  filters: ALL_PERSONAL_CARDS,
242
244
  memberships: options.members.length > 0 ? options.members : saved.memberships,
@@ -5,6 +5,7 @@ import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
5
5
  import { isValuationProfile, profileText, VALUATION_PROFILES, } from "../engine/profiles.js";
6
6
  import { membershipName } from "../engine/memberships.js";
7
7
  import { knownMemberships, searchCards } from "../engine/setup.js";
8
+ import { currencyChanges } from "../engine/wallet-change.js";
8
9
  import { formatCardInfo, formatNotes } from "../format/cards.js";
9
10
  import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
10
11
  const CARD_OPTIONS = `Options:
@@ -195,13 +196,16 @@ export async function runWalletCommand(command, argv, io) {
195
196
  return usage(`"${arg}" isn't <currency>=<cents>, like UR=1.5.`);
196
197
  changes[id] = cents;
197
198
  }
199
+ const given = currencyChanges(catalog, changes);
200
+ if (!given.ok)
201
+ return usage(given.error);
198
202
  if (Object.keys(changes).length === 0) {
199
203
  const held = await heldWallet(io);
200
204
  const set = Object.entries(held?.valuations ?? {});
201
205
  io.stdout(`${set.length > 0 ? `Yours: ${set.map(([id, v]) => `${id}=${typeof v === 'number' ? String(v) : JSON.stringify(v)}`).join(' ')}` : "Yours: earnrate's defaults"}\nCurrencies: ${catalog.currencies.map((c) => c.id).join(', ')}\n`);
202
206
  return 0;
203
207
  }
204
- return report(await store.setValuations(changes), `Point values updated in ${where}: ${Object.entries(changes)
208
+ return report(await store.setValuations(given.changes), `Point values updated in ${where}: ${Object.entries(given.changes)
205
209
  .map(([id, c]) => `${id} ${c === null ? 'back to the default' : `${String(c)}¢`}`)
206
210
  .join(', ')}.`, null, io);
207
211
  }
@@ -87,8 +87,8 @@ export class FileWalletStore {
87
87
  return failed(`${purchase.cardId} isn't in the wallet.`);
88
88
  const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
89
89
  try {
90
- if (booked.cap || booked.bonus)
91
- await writeProgress(this.where, booked.cap ? [booked.cap] : [], booked.bonus ? [booked.bonus] : []);
90
+ if (booked.cap || booked.bonus || booked.milestones.length > 0)
91
+ await writeProgress(this.where, booked.cap ? [booked.cap] : [], booked.bonus ? [booked.bonus] : [], booked.milestones);
92
92
  }
93
93
  catch (e) {
94
94
  return failed(errorText(e));
@@ -86,12 +86,23 @@ function cardProblems(card, catalog, merchantIds) {
86
86
  ['rule', card.rules.map((r) => r.id)],
87
87
  ['cap', card.caps.map((c) => c.id)],
88
88
  ['choice', card.choices.map((c) => c.id)],
89
+ ['milestone', card.milestones.map((m) => m.id)],
89
90
  ]) {
90
91
  for (const id of duplicates(ids))
91
92
  problems.push(`${where}: duplicate ${kind} id '${id}'`);
92
93
  }
94
+ // Evidence names rules and milestones alike, so their ids can't overlap.
95
+ for (const m of card.milestones)
96
+ if (card.rules.some((r) => r.id === m.id))
97
+ problems.push(`${where}: milestone '${m.id}' has a rule's id`);
98
+ if (card.foreign_transaction_fee === null &&
99
+ !card.todo.some((t) => /foreign transaction fee/i.test(t)))
100
+ problems.push(`${where}: foreign_transaction_fee is null without a todo saying why`);
93
101
  const ruleIds = new Set([
94
102
  'base_rate',
103
+ ...(card.foreign_transaction_fee !== null
104
+ ? ['foreign_transaction_fee']
105
+ : []),
95
106
  ...(card.pools_into ? ['pools_into'] : []),
96
107
  ...(card.annual_fee !== undefined ? ['annual_fee'] : []),
97
108
  ...(card.first_year_fee !== undefined ? ['first_year_fee'] : []),
@@ -99,6 +110,7 @@ function cardProblems(card, catalog, merchantIds) {
99
110
  ...(card.welcome_offer?.not_with ? ['not_with'] : []),
100
111
  ...(card.requires_membership ? ['requires_membership'] : []),
101
112
  ...card.rules.map((r) => r.id),
113
+ ...card.milestones.map((m) => m.id),
102
114
  ]);
103
115
  const sourcePages = new Set(card.source_urls.map((u) => u.split('#')[0]));
104
116
  const backed = new Set(card.evidence.map((e) => e.rule));
@@ -248,6 +260,11 @@ export function walletProblems(wallet, catalog) {
248
260
  if (!capKeys.has(key))
249
261
  problems.push(`${where} cap_usage: '${key}' is not a cap on this card`);
250
262
  }
263
+ const milestoneIds = new Set(card.milestones.map((m) => m.id));
264
+ for (const key of Object.keys(held.milestone_usage ?? {})) {
265
+ if (!milestoneIds.has(key))
266
+ problems.push(`${where} milestone_usage: '${key}' is not a spend milestone on this card`);
267
+ }
251
268
  if (held.chosen) {
252
269
  for (const [choiceId, picks] of Object.entries(held.chosen.picks)) {
253
270
  const choice = card.choices.find((c) => c.id === choiceId);
@@ -86,7 +86,7 @@ export class JsonWalletStore {
86
86
  if (!wallet.cards.some((c) => c.id === purchase.cardId))
87
87
  return failed(`${purchase.cardId} isn't in the wallet.`);
88
88
  const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
89
- if (booked.cap || booked.bonus)
89
+ if (booked.cap || booked.bonus || booked.milestones.length > 0)
90
90
  await this.backend.write(JSON.stringify(booked.wallet));
91
91
  return { ok: true, ...booked };
92
92
  });
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { writeFileAtomic } from "./atomic-write.js";
3
3
  import { isDeepStrictEqual } from 'node:util';
4
4
  import { isMap, isSeq, parseDocument, stringify } from 'yaml';
5
- import { applyBonusBooking, applyBooking, } from "../engine/record.js";
5
+ import { applyBonusBooking, applyBooking, applyMilestoneBooking, } from "../engine/record.js";
6
6
  import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
7
7
  const NEW_WALLET = `# Your earnrate wallet: the cards you hold, by id. Never card numbers.
8
8
  # Format: https://github.com/cwasley/earnrate#your-wallet
@@ -47,6 +47,14 @@ function fieldLines(field, f, indent) {
47
47
  .join('');
48
48
  return caps === '' ? '' : `${indent}cap_usage:\n${caps}`;
49
49
  }
50
+ case 'milestone_usage': {
51
+ if (f.milestone_usage === undefined)
52
+ return '';
53
+ const entries = Object.entries(f.milestone_usage)
54
+ .map(([key, u]) => `${indent} ${key}: { period: ${scalar(u.period)}, spent: ${String(u.spent)} }\n`)
55
+ .join('');
56
+ return entries === '' ? '' : `${indent}milestone_usage:\n${entries}`;
57
+ }
50
58
  case 'bonus': {
51
59
  if (!f.bonus)
52
60
  return '';
@@ -81,6 +89,7 @@ const FIELDS = [
81
89
  'chosen',
82
90
  'activated',
83
91
  'cap_usage',
92
+ 'milestone_usage',
84
93
  'bonus',
85
94
  ];
86
95
  function cardsSeq(text) {
@@ -270,31 +279,30 @@ function setValuationText(text, id, cents) {
270
279
  const indent = ' '.repeat(column(text, range(first?.key)[0]));
271
280
  return insertLines(text, afterLine(text, range(last?.value ?? last?.key)[1] - 1), `${indent}${line}\n`);
272
281
  }
273
- function flowUsage(booking) {
274
- const period = /^\d+$/.test(booking.period)
275
- ? `"${booking.period}"`
276
- : booking.period;
277
- return `{ period: ${period}, spent: ${String(booking.after)} }`;
282
+ function flowUsage(period, spent) {
283
+ const key = /^\d+$/.test(period) ? `"${period}"` : period;
284
+ return `{ period: ${key}, spent: ${String(spent)} }`;
278
285
  }
279
- function capUsageText(text, booking) {
280
- const card = findCard(text, booking.cardId);
281
- const value = flowUsage(booking);
282
- const usage = card.get('cap_usage', true);
286
+ // Sets one entry of a card's cap_usage or milestone_usage, adding the map
287
+ // when the card has none.
288
+ function usageText(text, cardId, field, key, value) {
289
+ const card = findCard(text, cardId);
290
+ const usage = card.get(field, true);
283
291
  if (isMap(usage) && !usage.flow && usage.items.length > 0) {
284
- const existing = usage.items.find((p) => keyOf(p) === booking.capKey);
292
+ const existing = usage.items.find((p) => keyOf(p) === key);
285
293
  if (existing) {
286
294
  const [start, end] = range(existing.value);
287
295
  return text.slice(0, start) + value + text.slice(end);
288
296
  }
289
297
  const indent = ' '.repeat(column(text, range(usage.items[0]?.key)[0]));
290
298
  const at = afterLine(text, range(usage)[1] - 1);
291
- return insertLines(text, at, `${indent}${booking.capKey}: ${value}\n`);
299
+ return insertLines(text, at, `${indent}${key}: ${value}\n`);
292
300
  }
293
301
  if (usage !== undefined)
294
- throw new Error(`card '${booking.cardId}' has a cap_usage that isn't a block map; ${BY_HAND}`);
302
+ throw new Error(`card '${cardId}' has a ${field} that isn't a block map; ${BY_HAND}`);
295
303
  const indent = ' '.repeat(column(text, range(card.items[0]?.key)[0]));
296
304
  const at = afterLine(text, range(card)[1] - 1);
297
- return insertLines(text, at, `${indent}cap_usage:\n${indent} ${booking.capKey}: ${value}\n`);
305
+ return insertLines(text, at, `${indent}${field}:\n${indent} ${key}: ${value}\n`);
298
306
  }
299
307
  function bonusSpentText(text, cardId, spent) {
300
308
  const bonus = findCard(text, cardId).get('bonus', true);
@@ -314,24 +322,30 @@ function bonusSpentText(text, cardId, spent) {
314
322
  const at = afterLine(text, range(bonus)[1] - 1);
315
323
  return insertLines(text, at, `${indent}spent: ${value}\n`);
316
324
  }
317
- // Writes spending toward caps and welcome bonuses in one checked write.
318
- export async function writeProgress(file, caps, bonuses) {
325
+ // Writes spending toward caps, welcome bonuses and spend milestones in one
326
+ // checked write.
327
+ export async function writeProgress(file, caps, bonuses, milestones = []) {
319
328
  const { text, wallet } = await readWalletText(file);
320
329
  if (text === null || wallet === null)
321
330
  throw new Error(`No wallet at ${file}.`);
322
331
  let after = text;
323
332
  let expected = wallet;
324
333
  for (const b of caps) {
325
- after = capUsageText(after, b);
334
+ after = usageText(after, b.cardId, 'cap_usage', b.capKey, flowUsage(b.period, b.after));
326
335
  expected = applyBooking(expected, b);
327
336
  }
337
+ for (const m of milestones) {
338
+ after = usageText(after, m.cardId, 'milestone_usage', m.milestoneId, flowUsage(m.period, m.after));
339
+ expected = applyMilestoneBooking(expected, m);
340
+ }
328
341
  for (const b of bonuses) {
329
342
  after = bonusSpentText(after, b.cardId, b.after);
330
343
  expected = applyBonusBooking(expected, b);
331
344
  }
332
345
  const byHand = [
333
- ...caps.map((b) => `${b.cardId} cap_usage ${b.capKey}: ${flowUsage(b)}`),
346
+ ...caps.map((b) => `${b.cardId} cap_usage ${b.capKey}: ${flowUsage(b.period, b.after)}`),
334
347
  ...bonuses.map((b) => `${b.cardId} bonus spent: ${String(b.after)}`),
348
+ ...milestones.map((m) => `${m.cardId} milestone_usage ${m.milestoneId}: ${flowUsage(m.period, m.after)}`),
335
349
  ].join('; ');
336
350
  await commitWalletText(file, after, expected, `add it by hand: ${byHand}`);
337
351
  }
@@ -3,7 +3,7 @@ import { exploreCards } from "./explore.js";
3
3
  import { bonusStatus } from "./bonus.js";
4
4
  import { EPSILON, isClose } from "./compare.js";
5
5
  import { periodKey } from "./periods.js";
6
- import { recommend } from "./recommend.js";
6
+ import { ABROAD, recommend } from "./recommend.js";
7
7
  import { checklist } from "./checklist.js";
8
8
  import { setupNotes } from "./setup.js";
9
9
  import { capitalize, feeText, listed, money, shortDate } from "./text.js";
@@ -16,7 +16,7 @@ export const EVERYDAY_PURCHASES = [
16
16
  { label: 'Amazon', merchant: 'amazon' },
17
17
  { label: 'Costco', merchant: 'costco' },
18
18
  { label: 'Travel', category: 'travel' },
19
- { label: 'Streaming', category: 'streaming' },
19
+ { label: 'Streaming (Netflix)', merchant: 'netflix' },
20
20
  { label: 'Drugstores', category: 'drugstore' },
21
21
  { label: 'Everything else', category: 'other' },
22
22
  ];
@@ -50,6 +50,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
50
50
  note: top?.warnings[0] ?? null,
51
51
  };
52
52
  });
53
+ rows.push(abroadRow(catalog, wallet, date));
53
54
  const bonuses = wallet.cards.flatMap((held) => {
54
55
  const status = bonusStatus(catalog, wallet, held, date);
55
56
  if (status?.state !== 'open')
@@ -106,6 +107,31 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
106
107
  notes,
107
108
  };
108
109
  }
110
+ const ABROAD_LABEL = 'Abroad';
111
+ // Everyday spending outside the U.S., after each card's foreign transaction
112
+ // fee.
113
+ function abroadRow(catalog, wallet, date) {
114
+ const merchant = categoryResolution(catalog, 'other', null);
115
+ const rec = recommend(catalog, wallet, {
116
+ merchant: merchant.input,
117
+ amount: null,
118
+ channel: null,
119
+ date,
120
+ country: ABROAD,
121
+ }, merchant);
122
+ const top = rec.decision ? rec.ranked[0] : undefined;
123
+ const allCharge = rec.ranked.length > 0 && rec.ranked.every((r) => r.foreignFee !== null);
124
+ return {
125
+ label: ABROAD_LABEL,
126
+ cardId: top?.cardId ?? null,
127
+ cardName: top ? (top.checkoutLabel ?? top.cardName) : null,
128
+ effectiveReturnPct: top?.effectiveReturnPct ?? null,
129
+ matchedRule: top?.matchedRule ?? null,
130
+ note: allCharge
131
+ ? `Every card here charges a foreign transaction fee abroad; this one costs the least. ${top?.warnings[0] ?? ''}`.trim()
132
+ : (top?.warnings[0] ?? null),
133
+ };
134
+ }
109
135
  // The best card earnrate knows for each everyday kind of purchase, for
110
136
  // someone who hasn't named any cards. Each row's note says what the card
111
137
  // costs or needs.
@@ -139,6 +165,24 @@ export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCH
139
165
  : null,
140
166
  };
141
167
  });
168
+ const abroad = exploreCards(catalog, {
169
+ merchant: null,
170
+ category: 'other',
171
+ amount: null,
172
+ channel: null,
173
+ date,
174
+ country: ABROAD,
175
+ }, options).ranked[0];
176
+ rows.push({
177
+ label: ABROAD_LABEL,
178
+ cardId: abroad?.cardId ?? null,
179
+ cardName: abroad?.cardName ?? null,
180
+ effectiveReturnPct: abroad?.effectiveReturnPct ?? null,
181
+ matchedRule: abroad?.matchedRule ?? null,
182
+ note: abroad
183
+ ? [`${capitalize(feeText(abroad))}.`, ...abroad.conditions].join(' ')
184
+ : null,
185
+ });
142
186
  const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
143
187
  return {
144
188
  scope: 'all cards',
@@ -1,5 +1,6 @@
1
- import { daysBetween, periodEnd, periodKey } from "./periods.js";
1
+ import { currentPeriodEnd, daysBetween } from "./periods.js";
2
2
  import { CAP_PERIODS, choiceName, listed, money, shortDate, wholeDollars, } from "./text.js";
3
+ import { milestonePays } from "./milestones.js";
3
4
  import { optionName } from "./rule-text.js";
4
5
  import { activationsDue, walletSummary, } from "./wallet-summary.js";
5
6
  // What the wallet's cards need this period, soonest first: bonuses to
@@ -33,24 +34,23 @@ shortName) {
33
34
  c.period !== 'until_changed');
34
35
  const [first] = unpicked;
35
36
  if (first) {
36
- const period = first.period === 'until_changed'
37
+ const due = first.period === 'until_changed'
37
38
  ? null
38
- : periodKey(first.period, today, null);
39
+ : currentPeriodEnd(first.period, today, null);
39
40
  items.push({
40
41
  kind: 'pick',
41
42
  cardId: card.id,
42
43
  text: `Pick ${name}'s ${listed(unpicked.map((c) => choiceName(c.id)))} categories for this ${first.period.replace('_', ' ')}; until then they earn the base rate.`,
43
- due: period === null ? null : periodEnd(period),
44
+ due,
44
45
  });
45
46
  }
46
47
  else {
47
48
  // Picks made for this period: near its end, a reminder to make the
48
49
  // next period's (Cash+ asks each quarter).
49
50
  const ending = card.choices.find((c) => c.period !== 'until_changed' && !c.default && current);
50
- const period = ending === undefined || ending.period === 'until_changed'
51
+ const end = ending === undefined || ending.period === 'until_changed'
51
52
  ? null
52
- : periodKey(ending.period, today, null);
53
- const end = period === null ? null : periodEnd(period);
53
+ : currentPeriodEnd(ending.period, today, null);
54
54
  if (ending && end !== null && daysBetween(today, end) <= 14)
55
55
  items.push({
56
56
  kind: 'pick',
@@ -72,7 +72,7 @@ shortName) {
72
72
  // Only caps nearly used up: the rest aren't something to do.
73
73
  if (cap.remaining === null || cap.remaining > cap.amount * 0.25)
74
74
  continue;
75
- const resets = cap.currentPeriod === null ? null : periodEnd(cap.currentPeriod);
75
+ const resets = cap.until;
76
76
  items.push({
77
77
  kind: 'cap',
78
78
  cardId: card.id,
@@ -82,6 +82,17 @@ shortName) {
82
82
  due: resets,
83
83
  });
84
84
  }
85
+ for (const m of s.milestones) {
86
+ // Like caps: only once at least 75% of the way there.
87
+ if (!m.tracked || m.spent < m.spend * 0.75 || m.spent >= m.spend)
88
+ continue;
89
+ items.push({
90
+ kind: 'milestone',
91
+ cardId: card.id,
92
+ text: `${name}: ${money(m.spent)} of ${wholeDollars(m.spend)} spent toward ${milestonePays(m)}; ${money(m.spend - m.spent)} to go${m.until === null ? '' : ` by ${shortDate(m.until)}`}.`,
93
+ due: m.until,
94
+ });
95
+ }
85
96
  const b = s.bonus;
86
97
  if (b?.state === 'open' && b.remaining !== null && b.deadline !== null) {
87
98
  const weeks = Math.max(1, (b.daysLeft ?? 7) / 7);
@@ -110,6 +110,8 @@ function applicability(card, rule, ctx, held, picked) {
110
110
  return 'no';
111
111
  if (rule.geography === 'us_only' && ctx.country !== 'US')
112
112
  return 'no';
113
+ if (rule.geography === 'foreign_only' && ctx.country === 'US')
114
+ return 'no';
113
115
  if (!inEffect(rule, ctx.date))
114
116
  return 'no';
115
117
  const membership = rule.requires?.membership;
@@ -2,7 +2,7 @@ import { categoryResolution, resolvePlace } from "../merchants/resolve.js";
2
2
  import { evaluateCard, purchaseContext } from "./evaluate.js";
3
3
  import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
4
4
  import { choicePeriod, periodKey } from "./periods.js";
5
- import { recommend } from "./recommend.js";
5
+ import { FOREIGN_ASSUMPTION, isForeign, recommend } from "./recommend.js";
6
6
  import { rotationGap } from "./rotation.js";
7
7
  import { membershipsUsed } from "./setup.js";
8
8
  import { optionName } from "./rule-text.js";
@@ -129,8 +129,10 @@ function membershipUpgrades(catalog, card, held, merchant, purchase, options, cu
129
129
  memberships: [...options.memberships, membership],
130
130
  });
131
131
  const ctx = purchaseContext(catalog, wallet, merchant, merchant.category, purchase);
132
+ const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
132
133
  const pct = evaluateCard(card, held, ctx).best.rate *
133
- cardValuation(catalog, wallet, card).centsPerUnit;
134
+ cardValuation(catalog, wallet, card).centsPerUnit -
135
+ fee;
134
136
  if (pct > current + EPSILON)
135
137
  upgrades.push({ membership, effectiveReturnPct: pct });
136
138
  }
@@ -160,7 +162,7 @@ export function exploreCards(catalog, query, options, resolved) {
160
162
  amount: query.amount,
161
163
  channel: query.channel,
162
164
  date: query.date,
163
- country: 'US',
165
+ country: query.country ?? 'US',
164
166
  };
165
167
  const ranked = [];
166
168
  const excluded = [];
@@ -212,11 +214,11 @@ export function exploreCards(catalog, query, options, resolved) {
212
214
  merchant,
213
215
  ranked,
214
216
  excluded,
215
- assumptions: exploreAssumptions(catalog, merchant, ranked, options),
217
+ assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase),
216
218
  warnings: exploreWarnings(catalog, merchant),
217
219
  };
218
220
  }
219
- function exploreAssumptions(catalog, merchant, ranked, options) {
221
+ function exploreAssumptions(catalog, merchant, ranked, options, purchase) {
220
222
  const assumptions = [];
221
223
  if (merchant.source === 'category') {
222
224
  assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
@@ -231,6 +233,8 @@ function exploreAssumptions(catalog, merchant, ranked, options) {
231
233
  const values = pointValuesText(catalog, ranked.slice(0, 10));
232
234
  if (values !== null)
233
235
  assumptions.push(values);
236
+ if (isForeign(purchase))
237
+ assumptions.push(FOREIGN_ASSUMPTION);
234
238
  return assumptions;
235
239
  }
236
240
  function exploreWarnings(catalog, merchant) {
@@ -0,0 +1,91 @@
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, centsPerPoint) {
49
+ const m = card.milestones.find((x) => x.id === hit.id);
50
+ if (!m)
51
+ return '';
52
+ const points = hit.points === null
53
+ ? null
54
+ : `${hit.points.toLocaleString('en-US')} bonus points, worth about ${money(roundCents((hit.points * centsPerPoint) / 100))}`;
55
+ const pays = points === null
56
+ ? listed(hit.rewards)
57
+ : hit.rewards.length === 0
58
+ ? points
59
+ : `${points}, plus ${listed(hit.rewards)}`;
60
+ return `This purchase reaches the ${wholeDollars(m.spend)} ${MILESTONE_PERIODS[m.period]} spend: ${pays} (${money(hit.before)} tracked on this card in ${hit.period} before it).`;
61
+ }
62
+ const THIS_PERIOD = {
63
+ calendar_year: 'this year',
64
+ cardmember_year: 'this cardmember year',
65
+ };
66
+ export function milestoneSummaries(card, held, today) {
67
+ return milestoneProgress(card, held, today).map(({ milestone: m, period, spent, tracked }) => {
68
+ const pays = milestonePays(m);
69
+ const text = period === null
70
+ ? `${wholeDollars(m.spend)} a cardmember year for ${pays}: give the card's anniversary to track it.`
71
+ : !tracked
72
+ ? `${wholeDollars(m.spend)} ${THIS_PERIOD[m.period]} for ${pays}; spending toward it isn't tracked yet.`
73
+ : spent >= m.spend
74
+ ? `${wholeDollars(m.spend)} reached ${THIS_PERIOD[m.period]}: ${pays}.`
75
+ : `${money(spent)} of ${wholeDollars(m.spend)} ${THIS_PERIOD[m.period]} for ${pays}.`;
76
+ return {
77
+ id: m.id,
78
+ spend: m.spend,
79
+ period: m.period,
80
+ currentPeriod: period,
81
+ until: period === null
82
+ ? null
83
+ : periodEnd(m.period, period, held.anniversary ?? null),
84
+ spent,
85
+ tracked,
86
+ points: m.points ?? null,
87
+ rewards: m.rewards ?? [],
88
+ text,
89
+ };
90
+ });
91
+ }