earnrate 0.2.4 → 0.2.5

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 (62) hide show
  1. package/README.md +5 -5
  2. package/data/CHANGELOG.md +39 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-gold.yaml +4 -0
  5. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +5 -1
  6. package/data/cards/bank-of-america-atmos-rewards-business.yaml +5 -1
  7. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +5 -1
  8. package/data/cards/chase-freedom-flex.yaml +1 -1
  9. package/data/cards/chase-ihg-one-rewards-premier.yaml +4 -0
  10. package/data/cards/chase-ihg-one-rewards.yaml +7 -0
  11. package/data/cards/chase-united-club.yaml +1 -1
  12. package/data/cards/citi-costco-anywhere-visa-business.yaml +1 -7
  13. package/data/cards/citi-costco-anywhere-visa.yaml +1 -7
  14. package/data/cards/td-bank-nordstrom-credit-card.yaml +4 -0
  15. package/data/cards/td-bank-nordstrom-visa.yaml +4 -0
  16. package/data/categories.yaml +12 -0
  17. package/data/currencies.yaml +2 -0
  18. package/data/memberships.yaml +33 -0
  19. package/data/merchants/overrides.yaml +25 -0
  20. package/dist/cli/cheatsheet.js +2 -2
  21. package/dist/cli/explore.js +11 -10
  22. package/dist/cli/import.js +9 -9
  23. package/dist/cli/offers.js +2 -1
  24. package/dist/cli/run.js +17 -3
  25. package/dist/cli/wallet.js +42 -0
  26. package/dist/data/file-store.js +5 -2
  27. package/dist/data/integrity.js +18 -2
  28. package/dist/data/load.js +10 -2
  29. package/dist/data/store.js +8 -1
  30. package/dist/data/wallet-edit.js +75 -0
  31. package/dist/engine/cheat-sheet.js +14 -17
  32. package/dist/engine/compare.js +8 -0
  33. package/dist/engine/explore.js +10 -21
  34. package/dist/engine/memberships.js +41 -15
  35. package/dist/engine/offers.js +16 -4
  36. package/dist/engine/profiles.js +8 -0
  37. package/dist/engine/recommend.js +10 -23
  38. package/dist/engine/setup.js +14 -16
  39. package/dist/engine/simulate.js +5 -5
  40. package/dist/engine/text.js +36 -22
  41. package/dist/engine/valuation.js +1 -1
  42. package/dist/engine/wallet-change.js +17 -0
  43. package/dist/engine/wallet-summary.js +24 -6
  44. package/dist/format/card-text.js +15 -19
  45. package/dist/format/cards.js +3 -3
  46. package/dist/format/explore.js +14 -15
  47. package/dist/format/offers.js +10 -15
  48. package/dist/format/recommendation.js +5 -5
  49. package/dist/format/wallet.js +4 -1
  50. package/dist/mcp/context.js +31 -4
  51. package/dist/mcp/inputs.js +15 -6
  52. package/dist/mcp/schemas.js +8 -0
  53. package/dist/mcp/server.js +1 -0
  54. package/dist/mcp/tools/account.js +1 -1
  55. package/dist/mcp/tools/answers.js +28 -12
  56. package/dist/mcp/tools/wallet.js +58 -5
  57. package/dist/schema/card.js +4 -11
  58. package/dist/schema/category.js +4 -0
  59. package/dist/schema/currency.js +2 -0
  60. package/dist/schema/membership.js +15 -0
  61. package/dist/schema/merchant.js +4 -0
  62. package/package.json +1 -1
@@ -2,19 +2,20 @@ import { parseArgs } from 'node:util';
2
2
  import { CLI_NAME } from "../brand.js";
3
3
  import { loadCatalog, walletPath } from "../data/load.js";
4
4
  import { ALL_PERSONAL_CARDS, exploreCards, findCategory, } from "../engine/explore.js";
5
- import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
5
+ import { isValuationProfile, profileText, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
6
6
  import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, TYPICAL_MONTHLY_SPEND, 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 { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
9
+ import { cardsToUse, RUNTIME_ERROR, savedSettings, USAGE_ERROR, } from "./run.js";
10
10
  const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or points and miles cards only.
11
11
  --no-fee Cards with no annual fee.
12
12
  --issuer <id> One issuer's cards, e.g. chase or amex.
13
13
  --business Business cards instead of personal ones.
14
14
  --closed Include cards the issuer no longer takes applications for.
15
15
  --member <name> A membership you have, e.g. amazon-prime or costco. Repeatable.
16
- --valuation <profile> flat (points at 1¢, the default) or points-heavy
17
- (MR and UR at 1.7¢).`;
16
+ --valuation <profile> flat (${profileText('flat')}) or points-heavy
17
+ (${profileText('points-heavy')}), instead of your
18
+ saved point values.`;
18
19
  const BEST_HELP = `Usage: ${CLI_NAME} best <merchant or category> [amount] [options]
19
20
 
20
21
  Ranks every card in earnrate's catalog for one purchase, not just yours. Each
@@ -67,7 +68,7 @@ const FILTER_FLAGS = {
67
68
  json: { type: 'boolean' },
68
69
  help: { type: 'boolean', short: 'h' },
69
70
  };
70
- function settingsFrom(values, catalog) {
71
+ function settingsFrom(values, catalog, saved) {
71
72
  if (values.cash && values.points)
72
73
  return 'Pick --cash or --points, not both.';
73
74
  const problem = valuationProblem(values.valuation) ??
@@ -86,10 +87,10 @@ function settingsFrom(values, catalog) {
86
87
  issuer: values.issuer ?? null,
87
88
  includeClosed: values.closed ?? false,
88
89
  },
89
- memberships: values.member ?? [],
90
+ memberships: values.member ?? saved.memberships,
90
91
  valuations: values.valuation !== undefined && isValuationProfile(values.valuation)
91
92
  ? VALUATION_PROFILES[values.valuation]
92
- : {},
93
+ : saved.valuations,
93
94
  };
94
95
  }
95
96
  export async function runBest(argv, io) {
@@ -135,7 +136,7 @@ export async function runBest(argv, io) {
135
136
  return USAGE_ERROR;
136
137
  }
137
138
  const catalog = await loadCatalog();
138
- const settings = settingsFrom(values, catalog);
139
+ const settings = settingsFrom(values, catalog, await savedSettings(io));
139
140
  if (typeof settings === 'string') {
140
141
  io.stderr(`${settings}\n`);
141
142
  return USAGE_ERROR;
@@ -150,7 +151,7 @@ export async function runBest(argv, io) {
150
151
  }, settings);
151
152
  io.stdout(values.json
152
153
  ? `${JSON.stringify(result, null, 2)}\n`
153
- : formatExploration(result, values.all ?? false));
154
+ : formatExploration(result, catalog, values.all ?? false));
154
155
  return 0;
155
156
  }
156
157
  function parseSpend(catalog, args) {
@@ -195,7 +196,7 @@ export async function runSimulate(argv, io) {
195
196
  return 0;
196
197
  }
197
198
  const catalog = await loadCatalog();
198
- const settings = settingsFrom(values, catalog);
199
+ const settings = settingsFrom(values, catalog, await savedSettings(io));
199
200
  if (typeof settings === 'string') {
200
201
  io.stderr(`${settings}\n`);
201
202
  return USAGE_ERROR;
@@ -12,6 +12,7 @@ import { cardValuation } from "../engine/valuation.js";
12
12
  import { analyzeStatement, } from "../import/analyze.js";
13
13
  import { parseStatement } from "../import/statement.js";
14
14
  import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
15
+ import { dollarsAndCents } from "../engine/text.js";
15
16
  const IMPORT_HELP = `Usage: ${CLI_NAME} import <statement.csv> --card <card-id> [--apply]
16
17
  ${CLI_NAME} import <statement.csv>:<card-id> [<statement.csv>:<card-id> ...]
17
18
 
@@ -44,7 +45,6 @@ const LedgerSchema = z.strictObject({
44
45
  const fingerprint = ({ cardId, line }, kind = 'cap') => createHash('sha256')
45
46
  .update(`${kind === 'bonus' ? 'bonus|' : ''}${cardId}|${line.date}|${String(line.amount)}|${line.description}`)
46
47
  .digest('hex');
47
- const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
48
48
  // "statement.csv:chase-sapphire-preferred" names the card inline.
49
49
  function splitTarget(arg) {
50
50
  const m = /^(.+):([a-z0-9]+(?:-[a-z0-9]+)*)$/.exec(arg);
@@ -77,20 +77,20 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
77
77
  several
78
78
  ? `${String(a.byCard.length)} statements (${names.join(', ')}) · ${a.from} to ${a.to}`
79
79
  : `${names[0] ?? ''} statement · ${a.from} to ${a.to}`,
80
- `${String(a.purchases)} purchases, ${usd(a.spend)}${a.refunds > 0 ? ` (refunds and credits: ${usd(a.refunds)})` : ''}`,
80
+ `${String(a.purchases)} purchases, ${dollarsAndCents(a.spend)}${a.refunds > 0 ? ` (refunds and credits: ${dollarsAndCents(a.refunds)})` : ''}`,
81
81
  '',
82
82
  ];
83
83
  if (several) {
84
- out.push(`Earned on the cards you used: about ${usd(a.earned)}`);
84
+ out.push(`Earned on the cards you used: about ${dollarsAndCents(a.earned)}`);
85
85
  const width = Math.max(...names.map((n) => n.length));
86
86
  for (const t of a.byCard) {
87
- out.push(` ${labelOf(t.cardId).padEnd(width)} ${String(t.purchases).padStart(4)} × ${usd(t.spend).padStart(11)} ${usd(t.earned)}`);
87
+ out.push(` ${labelOf(t.cardId).padEnd(width)} ${String(t.purchases).padStart(4)} × ${dollarsAndCents(t.spend).padStart(11)} ${dollarsAndCents(t.earned)}`);
88
88
  }
89
89
  }
90
90
  else {
91
- out.push(`Earned on ${names[0] ?? ''}: about ${usd(a.earned)}`);
91
+ out.push(`Earned on ${names[0] ?? ''}: about ${dollarsAndCents(a.earned)}`);
92
92
  }
93
- out.push(`With earnrate's picks: about ${usd(a.best)} (+${usd(a.best - a.earned)})`, valuation);
93
+ out.push(`With earnrate's picks: about ${dollarsAndCents(a.best)} (+${dollarsAndCents(a.best - a.earned)})`, valuation);
94
94
  if (a.missed.length > 0) {
95
95
  out.push('', 'Where another card would have earned more:');
96
96
  const shown = a.missed.slice(0, 10);
@@ -99,7 +99,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
99
99
  const cardWidth = Math.max(...shown.map((m) => labelOf(m.usedCardId).length));
100
100
  for (const m of shown) {
101
101
  const used = several ? `${labelOf(m.usedCardId).padEnd(cardWidth)} ` : '';
102
- out.push(` ${name(m).slice(0, width).padEnd(width)} ${used}${String(m.count).padStart(3)} × ${usd(m.spend).padStart(10)} ${usd(m.earned)} → ${m.bestCard} ${usd(m.best)} (+${usd(m.best - m.earned)})`);
102
+ out.push(` ${name(m).slice(0, width).padEnd(width)} ${used}${String(m.count).padStart(3)} × ${dollarsAndCents(m.spend).padStart(10)} ${dollarsAndCents(m.earned)} → ${m.bestCard} ${dollarsAndCents(m.best)} (+${dollarsAndCents(m.best - m.earned)})`);
103
103
  }
104
104
  if (a.missed.length > 10)
105
105
  out.push(` …and ${String(a.missed.length - 10)} more merchants`);
@@ -117,7 +117,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
117
117
  const last = new Map();
118
118
  for (const { cardId, booking } of a.bookings)
119
119
  last.set(several ? `${labelOf(cardId)} ${booking.capKey}` : booking.capKey, booking.after);
120
- out.push(`Cap usage for the current period: ${[...last].map(([k, v]) => `${k} ${usd(v)}`).join(', ')}.`);
120
+ out.push(`Cap usage for the current period: ${[...last].map(([k, v]) => `${k} ${dollarsAndCents(v)}`).join(', ')}.`);
121
121
  }
122
122
  else if (a.alreadyImported > 0) {
123
123
  out.push(`Caps: nothing new to record. ${String(a.alreadyImported)} purchase(s) were recorded by an earlier import.`);
@@ -129,7 +129,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
129
129
  out.push('Caps: nothing new to record.');
130
130
  }
131
131
  for (const b of a.bonuses) {
132
- out.push(`Welcome bonus on ${labelOf(b.cardId)}: ${usd(b.before)} → ${usd(b.after)} of ${usd(b.spend)}${b.deadline ? ` by ${b.deadline}` : ''}.`);
132
+ out.push(`Welcome bonus on ${labelOf(b.cardId)}: ${dollarsAndCents(b.before)} → ${dollarsAndCents(b.after)} of ${dollarsAndCents(b.spend)}${b.deadline ? ` by ${b.deadline}` : ''}.`);
133
133
  }
134
134
  return `${out.join('\n')}\n`;
135
135
  }
@@ -9,7 +9,8 @@ import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
9
9
  const OFFERS_HELP = `Usage: ${CLI_NAME} offers [--cash | --points] [--no-fee] [--business] [options]
10
10
 
11
11
  Ranks the welcome offers on cards you don't have by first-year value: the
12
- bonus at your point values minus the first year's annual fee.
12
+ bonus at your point values minus the first year's annual fee and any
13
+ membership the card needs that you don't have.
13
14
 
14
15
  Options:
15
16
  --cash Cash back cards only.
package/dist/cli/run.js CHANGED
@@ -6,7 +6,7 @@ import { CLI_NAME, CONFIG_DIR, LLM_ENV_VAR, LLM_MAX_CALLS_ENV_VAR, LLM_MODEL_ENV
6
6
  import { walletProblems } from "../data/integrity.js";
7
7
  import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
8
8
  import { ALL_PERSONAL_CARDS, categoryResolution, exploreCards, findCategory, } from "../engine/explore.js";
9
- import { VALUATION_PROFILES, withValuationProfile } from "../engine/profiles.js";
9
+ import { profileText, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
10
10
  import { recommend } from "../engine/recommend.js";
11
11
  import { walletFromCards } from "../engine/transient.js";
12
12
  import { formatExploration } from "../format/explore.js";
@@ -43,6 +43,7 @@ Save your cards (optional):
43
43
  ${CLI_NAME} update <card-id> [--pick ...] Change picks, label or activations.
44
44
  ${CLI_NAME} remove <card-id> Remove a card.
45
45
  ${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime, costco).
46
+ ${CLI_NAME} values [UR=1.5 ...] Show or set what your points are worth.
46
47
 
47
48
  Other commands:
48
49
  ${CLI_NAME} cheatsheet The best card for each category this quarter.
@@ -53,7 +54,8 @@ Other commands:
53
54
 
54
55
  Options:
55
56
  --online, --in-store How you're paying. Default: inferred from the merchant.
56
- --valuation <profile> flat (points at 1¢) or points-heavy (MR and UR at 1.7¢).
57
+ --valuation <profile> flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}),
58
+ instead of your saved point values.
57
59
  Default: the values in your wallet.
58
60
  --all List every card, not just the top three.
59
61
  --json Print the full recommendation as JSON.
@@ -120,6 +122,18 @@ export async function cardsToUse(catalog, file, io, named) {
120
122
  return loaded;
121
123
  }
122
124
  export const NO_WALLET_HINT = `These are every card earnrate knows. For the best of your own cards, name them with --cards amex-gold,chase-prime-visa, or save them: ${CLI_NAME} add <card-id> (${CLI_NAME} cards finds ids).`;
125
+ // The point values and memberships saved in the wallet, which every answer
126
+ // uses unless --valuation or --member says otherwise. No wallet, or one that
127
+ // can't be read, has none.
128
+ export async function savedSettings(io) {
129
+ try {
130
+ const { valuations, memberships } = await loadWallet(walletPath(io.env));
131
+ return { valuations, memberships };
132
+ }
133
+ catch {
134
+ return { valuations: {}, memberships: [] };
135
+ }
136
+ }
123
137
  export async function readWallet(file, io) {
124
138
  try {
125
139
  return await loadWallet(file);
@@ -222,7 +236,7 @@ export async function run(argv, io) {
222
236
  io.stdout(`${JSON.stringify(explored, null, 2)}\n`);
223
237
  }
224
238
  else {
225
- io.stdout(`${formatExploration(explored, options.all)}\n${NO_WALLET_HINT}\n`);
239
+ io.stdout(`${formatExploration(explored, catalog, options.all)}\n${NO_WALLET_HINT}\n`);
226
240
  }
227
241
  for (const n of notices)
228
242
  io.stderr(`${n}\n`);
@@ -2,6 +2,7 @@ import { parseArgs } from 'node:util';
2
2
  import { CLI_NAME } from "../brand.js";
3
3
  import { FileWalletStore } from "../data/file-store.js";
4
4
  import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
5
+ import { isValuationProfile, profileText, VALUATION_PROFILES, } from "../engine/profiles.js";
5
6
  import { knownMemberships, searchCards } from "../engine/setup.js";
6
7
  import { formatCardInfo, formatNotes } from "../format/cards.js";
7
8
  import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
@@ -53,6 +54,20 @@ Removes a card from your wallet, with its tracked cap usage.
53
54
 
54
55
  With no arguments, shows your memberships and the ones earnrate knows. With
55
56
  names, replaces the list. --none clears it.
57
+ `,
58
+ values: `Usage: ${CLI_NAME} values [<currency>=<cents>...] [--profile <name>]
59
+
60
+ What your points are worth to you, in cents per point, saved in your wallet
61
+ file; every answer then uses them unless you pass --valuation. With no
62
+ arguments, shows them.
63
+
64
+ Examples:
65
+ ${CLI_NAME} values UR=1.5 MR=1.6
66
+ ${CLI_NAME} values UR=default back to earnrate's value
67
+ ${CLI_NAME} values --profile points-heavy
68
+
69
+ Options:
70
+ --profile <name> flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}).
56
71
  `,
57
72
  };
58
73
  const HINTS = {
@@ -118,6 +133,7 @@ export async function runWalletCommand(command, argv, io) {
118
133
  'bonus-by': { type: 'string' },
119
134
  'no-bonus': { type: 'boolean' },
120
135
  none: { type: 'boolean' },
136
+ profile: { type: 'string' },
121
137
  help: { type: 'boolean', short: 'h' },
122
138
  },
123
139
  });
@@ -160,6 +176,32 @@ export async function runWalletCommand(command, argv, io) {
160
176
  io.stdout(`${header}${result.cards.map(formatCardInfo).join('\n')}\n`);
161
177
  return 0;
162
178
  }
179
+ if (command === 'values') {
180
+ const changes = {};
181
+ if (values.profile !== undefined) {
182
+ if (!isValuationProfile(values.profile))
183
+ return usage(`Unknown profile "${values.profile}". Use flat or points-heavy.`);
184
+ Object.assign(changes, VALUATION_PROFILES[values.profile]);
185
+ }
186
+ for (const arg of positionals) {
187
+ const [id, value] = arg.split('=');
188
+ const cents = value === 'default' ? null : Number(value);
189
+ if (!id ||
190
+ value === undefined ||
191
+ (cents !== null && !Number.isFinite(cents)))
192
+ return usage(`"${arg}" isn't <currency>=<cents>, like UR=1.5.`);
193
+ changes[id] = cents;
194
+ }
195
+ if (Object.keys(changes).length === 0) {
196
+ const held = await heldWallet(io);
197
+ const set = Object.entries(held?.valuations ?? {});
198
+ 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`);
199
+ return 0;
200
+ }
201
+ return report(await store.setValuations(changes), `Point values updated in ${where}: ${Object.entries(changes)
202
+ .map(([id, c]) => `${id} ${c === null ? 'back to the default' : `${String(c)}¢`}`)
203
+ .join(', ')}.`, null, io);
204
+ }
163
205
  if (command === 'memberships') {
164
206
  if (positionals.length === 0 && !values.none) {
165
207
  const held = await heldWallet(io);
@@ -1,7 +1,7 @@
1
1
  import { bookPurchase } from "../engine/record.js";
2
- import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
2
+ import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
3
3
  import { loadWallet } from "./load.js";
4
- import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, updateCardText, writeProgress, } from "./wallet-edit.js";
4
+ import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, setValuationsText, updateCardText, writeProgress, } from "./wallet-edit.js";
5
5
  const failed = (error) => ({ ok: false, error });
6
6
  const errorText = (e) => (e instanceof Error ? e.message : String(e));
7
7
  const queues = new Map();
@@ -76,6 +76,9 @@ export class FileWalletStore {
76
76
  setMemberships(memberships) {
77
77
  return this.change(this.existing((w) => withMemberships(this.catalog, w, memberships)), (text, change) => setMembershipsText(text ?? '', change.wallet.memberships));
78
78
  }
79
+ setValuations(changes) {
80
+ return this.change(this.existing((w) => withValuations(this.catalog, w, changes)), (text) => setValuationsText(text ?? '', changes));
81
+ }
79
82
  record(purchase, merchant) {
80
83
  return withWalletLock(this.where, async () => {
81
84
  const wallet = await this.load();
@@ -60,8 +60,19 @@ function cardProblems(card, catalog, merchantIds) {
60
60
  for (const id of card.welcome_offer?.not_with ?? [])
61
61
  if (!cardIds.has(id))
62
62
  problems.push(`${where} welcome_offer.not_with: unknown card '${id}'`);
63
- for (const m of card.requires_membership?.members_shop_at ?? [])
64
- checkMerchant(m, 'requires_membership');
63
+ const memberships = new Set(catalog.memberships.map((m) => m.id));
64
+ for (const id of [
65
+ ...(card.requires_membership === undefined
66
+ ? []
67
+ : [card.requires_membership]),
68
+ ...card.rules.flatMap((r) => r.requires?.membership ?? []),
69
+ ])
70
+ if (!memberships.has(id))
71
+ problems.push(`${where}: unknown membership '${id}' (data/memberships.yaml)`);
72
+ if (card.requires_membership !== undefined &&
73
+ catalog.memberships.find((m) => m.id === card.requires_membership)
74
+ ?.annual_fee === undefined)
75
+ problems.push(`${where}: requires_membership '${card.requires_membership}' has no annual_fee`);
65
76
  if (card.pools_into) {
66
77
  if (!currencies.has(card.pools_into.currency)) {
67
78
  problems.push(`${where} pools_into: unknown currency '${card.pools_into.currency}'`);
@@ -167,6 +178,7 @@ export function catalogProblems(catalog) {
167
178
  ['category', catalog.categories.map((c) => c.id)],
168
179
  ['currency', catalog.currencies.map((c) => c.id)],
169
180
  ['card', catalog.cards.map((c) => c.id)],
181
+ ['membership', catalog.memberships.map((m) => m.id)],
170
182
  ]) {
171
183
  for (const id of duplicates(ids))
172
184
  problems.push(`duplicate ${kind} id '${id}'`);
@@ -187,6 +199,10 @@ export function catalogProblems(catalog) {
187
199
  problems.push(`merchant ${m.id}: booked_via '${via}' is not a merchant`);
188
200
  }
189
201
  }
202
+ for (const m of catalog.memberships)
203
+ for (const id of m.members_shop_at)
204
+ if (!merchantIds.has(id))
205
+ problems.push(`membership ${m.id}: members_shop_at '${id}' is not a merchant`);
190
206
  for (const card of catalog.cards) {
191
207
  problems.push(...cardProblems(card, catalog, merchantIds));
192
208
  }
package/dist/data/load.js CHANGED
@@ -8,6 +8,7 @@ import { CONFIG_DIR, WALLET_ENV_VAR } from "../brand.js";
8
8
  import { CardSchema } from "../schema/card.js";
9
9
  import { CategoriesFileSchema } from "../schema/category.js";
10
10
  import { CurrenciesFileSchema } from "../schema/currency.js";
11
+ import { MembershipsFileSchema } from "../schema/membership.js";
11
12
  import { MerchantsFileSchema } from "../schema/merchant.js";
12
13
  import { WalletSchema } from "../schema/wallet.js";
13
14
  export const DATA_DIR = fileURLToPath(new URL('../../data/', import.meta.url));
@@ -30,14 +31,21 @@ async function loadCards(dir) {
30
31
  }));
31
32
  }
32
33
  export async function loadCatalog(dir = DATA_DIR) {
33
- const [categories, currencies, cards, seed, overrides] = await Promise.all([
34
+ const [categories, currencies, memberships, cards, seed, overrides] = await Promise.all([
34
35
  readYaml(path.join(dir, 'categories.yaml'), CategoriesFileSchema),
35
36
  readYaml(path.join(dir, 'currencies.yaml'), CurrenciesFileSchema),
37
+ readYaml(path.join(dir, 'memberships.yaml'), MembershipsFileSchema),
36
38
  loadCards(path.join(dir, 'cards')),
37
39
  readYaml(path.join(dir, 'merchants', 'seed.yaml'), MerchantsFileSchema),
38
40
  readYaml(path.join(dir, 'merchants', 'overrides.yaml'), MerchantsFileSchema),
39
41
  ]);
40
- return { categories, currencies, cards, merchants: { seed, overrides } };
42
+ return {
43
+ categories,
44
+ currencies,
45
+ memberships,
46
+ cards,
47
+ merchants: { seed, overrides },
48
+ };
41
49
  }
42
50
  // MCP clients pass config values through without a shell, so "~" or
43
51
  // "${HOME}" can arrive as literal text; expand them here.
@@ -1,5 +1,5 @@
1
1
  import { bookPurchase } from "../engine/record.js";
2
- import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
2
+ import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
3
3
  import { WalletSchema } from "../schema/wallet.js";
4
4
  const failed = (error) => ({ ok: false, error });
5
5
  // A wallet stored as JSON text. Writes are serialized within this instance;
@@ -60,6 +60,9 @@ export class JsonWalletStore {
60
60
  setMemberships(memberships) {
61
61
  return this.change(this.needsWallet((w) => withMemberships(this.catalog, w, memberships)));
62
62
  }
63
+ setValuations(changes) {
64
+ return this.change(this.needsWallet((w) => withValuations(this.catalog, w, changes)));
65
+ }
63
66
  replace(wallet) {
64
67
  return this.change(() => ({ ok: true, wallet, fields: {}, notes: [] }));
65
68
  }
@@ -70,6 +73,10 @@ export class JsonWalletStore {
70
73
  disconnect() {
71
74
  return Promise.resolve();
72
75
  }
76
+ // The backend doesn't record when it began.
77
+ started() {
78
+ return Promise.resolve(null);
79
+ }
73
80
  record(purchase, merchant) {
74
81
  return this.serialized(async () => {
75
82
  const wallet = await this.load();
@@ -170,6 +170,81 @@ export function setMembershipsText(text, memberships) {
170
170
  const cards = root.items.find((p) => keyOf(p) === 'cards');
171
171
  return insertLines(text, lineStart(text, range(cards?.key)[0]), line);
172
172
  }
173
+ // Point values, one currency at a time so the rest of the file, comments
174
+ // included, stays as it was: a value is replaced in place, a new one goes
175
+ // after the last, a removed one loses its line. An inline map is rewritten.
176
+ export function setValuationsText(text, changes) {
177
+ let out = text;
178
+ for (const [id, cents] of Object.entries(changes))
179
+ out = setValuationText(out, id, cents);
180
+ return out;
181
+ }
182
+ // The start of the line at `at`, moved up past the comments right above it,
183
+ // so a comment stays with the line it describes.
184
+ function aboveComments(text, at) {
185
+ let start = lineStart(text, at);
186
+ while (start > 0) {
187
+ const previous = lineStart(text, start - 1);
188
+ if (!text.slice(previous, start).trimStart().startsWith('#'))
189
+ break;
190
+ start = previous;
191
+ }
192
+ return start;
193
+ }
194
+ function setValuationText(text, id, cents) {
195
+ const { doc } = cardsSeq(text);
196
+ const root = doc.contents;
197
+ if (!isMap(root))
198
+ throw new Error(`the wallet isn't a map; ${BY_HAND}`);
199
+ const pair = root.items.find((p) => keyOf(p) === 'valuations');
200
+ const map = pair?.value;
201
+ const line = cents === null ? '' : `${id}: ${String(cents)}`;
202
+ if (!pair || map === null || (isMap(map) && map.items.length === 0)) {
203
+ if (cents === null)
204
+ return text;
205
+ const block = `valuations:\n ${line}\n`;
206
+ if (pair) {
207
+ const start = range(pair.key)[0];
208
+ const end = afterLine(text, range(map ?? pair.key)[1] - 1);
209
+ return text.slice(0, start) + block + text.slice(end);
210
+ }
211
+ const before = root.items.find((p) => keyOf(p) === 'memberships' || keyOf(p) === 'cards');
212
+ return insertLines(text, aboveComments(text, range(before?.key)[0]), block);
213
+ }
214
+ if (!isMap(map))
215
+ throw new Error(`"valuations" isn't a map; ${BY_HAND}`);
216
+ if (map.flow) {
217
+ const values = Object.entries(map.toJSON()).filter(([k]) => k !== id);
218
+ if (cents !== null)
219
+ values.push([id, cents]);
220
+ const entries = values.map(([k, v]) => `${k}: ${stringify(v, { flow: true }).trimEnd()}`);
221
+ const start = range(pair.key)[0];
222
+ const end = afterLine(text, range(map)[1] - 1);
223
+ return (text.slice(0, start) +
224
+ (entries.length === 0 ? '' : `valuations: { ${entries.join(', ')} }\n`) +
225
+ text.slice(end));
226
+ }
227
+ const own = map.items.find((p) => keyOf(p) === id);
228
+ if (own) {
229
+ if (cents === null) {
230
+ if (map.items.length === 1) {
231
+ const start = lineStart(text, range(pair.key)[0]);
232
+ return (text.slice(0, start) + text.slice(afterLine(text, range(map)[1] - 1)));
233
+ }
234
+ const start = lineStart(text, range(own.key)[0]);
235
+ return (text.slice(0, start) +
236
+ text.slice(afterLine(text, range(own.value)[1] - 1)));
237
+ }
238
+ const [start, end] = range(own.value);
239
+ return text.slice(0, start) + String(cents) + text.slice(end);
240
+ }
241
+ if (cents === null)
242
+ return text;
243
+ const first = map.items[0];
244
+ const last = map.items.at(-1);
245
+ const indent = ' '.repeat(column(text, range(first?.key)[0]));
246
+ return insertLines(text, afterLine(text, range(last?.value ?? last?.key)[1] - 1), `${indent}${line}\n`);
247
+ }
173
248
  function flowUsage(booking) {
174
249
  const period = /^\d+$/.test(booking.period)
175
250
  ? `"${booking.period}"`
@@ -1,11 +1,14 @@
1
1
  import { resolveMerchantById } from "../merchants/resolve.js";
2
2
  import { categoryResolution, exploreCards, } from "./explore.js";
3
3
  import { bonusStatus } from "./bonus.js";
4
+ import { EPSILON, isClose } from "./compare.js";
4
5
  import { periodKey } from "./periods.js";
5
6
  import { recommend } from "./recommend.js";
6
7
  import { setupNotes } from "./setup.js";
7
- import { listed, money, shortDate } from "./text.js";
8
- const CHEAT_SHEET_ITEMS = [
8
+ import { feeText, listed, money, shortDate } from "./text.js";
9
+ // The everyday kinds of purchase: the cheat sheet's rows and the site's
10
+ // quick picks.
11
+ export const EVERYDAY_PURCHASES = [
9
12
  { label: 'Groceries', category: 'grocery.supermarket' },
10
13
  { label: 'Dining', category: 'dining' },
11
14
  { label: 'Gas', category: 'gas' },
@@ -18,7 +21,7 @@ const CHEAT_SHEET_ITEMS = [
18
21
  ];
19
22
  // The best card in the wallet for each everyday kind of purchase on `date`,
20
23
  // with caps, picks and activations as the wallet has them.
21
- export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
24
+ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
22
25
  const ranked = [];
23
26
  const rows = items.map((item) => {
24
27
  const merchant = 'merchant' in item
@@ -53,8 +56,8 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
53
56
  const name = held.checkout_label ??
54
57
  catalog.cards.find((c) => c.id === held.id)?.name ??
55
58
  held.id;
56
- // Rows where the bonus card matches the row's card, or comes within a
57
- // point and three quarters of its rate (as single answers mention it).
59
+ // Rows where the bonus card matches the row's card, or is close to it
60
+ // (as single answers mention it).
58
61
  const matches = [];
59
62
  const near = [];
60
63
  for (const { label, cards } of ranked) {
@@ -62,9 +65,9 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
62
65
  const own = cards.find((c) => c.cardId === held.id)?.effectiveReturnPct;
63
66
  if (best === undefined || own === undefined)
64
67
  continue;
65
- if (best - own < 1e-9)
68
+ if (best - own < EPSILON)
66
69
  matches.push(label.toLowerCase());
67
- else if (best - own <= 1 && own >= best * 0.75)
70
+ else if (isClose(own, best))
68
71
  near.push(label.toLowerCase());
69
72
  }
70
73
  const where = [
@@ -97,14 +100,12 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
97
100
  // The best card earnrate knows for each everyday kind of purchase, for
98
101
  // someone who hasn't named any cards. Each row's note says what the card
99
102
  // costs or needs.
100
- export function catalogCheatSheet(catalog, date, options, items = CHEAT_SHEET_ITEMS) {
103
+ const capitalized = (t) => t.charAt(0).toUpperCase() + t.slice(1);
104
+ export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCHASES) {
101
105
  const rows = items.map((item) => {
102
106
  const top = exploreCards(catalog, 'merchant' in item
103
107
  ? {
104
- merchant: catalog.merchants.seed.find((m) => m.id === item.merchant)
105
- ?.name ??
106
- catalog.merchants.overrides.find((m) => m.id === item.merchant)
107
- ?.name ??
108
+ merchant: resolveMerchantById(catalog, item.merchant, null)?.resolvedName ??
108
109
  item.label,
109
110
  category: null,
110
111
  amount: null,
@@ -118,11 +119,7 @@ export function catalogCheatSheet(catalog, date, options, items = CHEAT_SHEET_IT
118
119
  channel: null,
119
120
  date,
120
121
  }, options).ranked[0];
121
- const fee = top?.annualFee === null || top?.annualFee === undefined
122
- ? null
123
- : top.annualFee === 0
124
- ? 'No annual fee.'
125
- : `$${String(top.annualFee)} annual fee.`;
122
+ const fee = top ? `${capitalized(feeText(top))}.` : null;
126
123
  return {
127
124
  label: item.label,
128
125
  cardId: top?.cardId ?? null,
@@ -0,0 +1,8 @@
1
+ // Returns closer than this are equal: floating-point noise, not a difference.
2
+ export const EPSILON = 1e-9;
3
+ // A card close to the best one: within a percentage point, and at least
4
+ // three quarters of its rate. Answers and cheat sheets mention a welcome
5
+ // bonus card when it's this close.
6
+ export function isClose(own, best) {
7
+ return best - own <= 1 && own >= best * 0.75;
8
+ }
@@ -1,13 +1,14 @@
1
1
  import { resolveMerchant } from "../merchants/resolve.js";
2
2
  import { evaluateCard, purchaseContext } from "./evaluate.js";
3
- import { membershipNeeded, paidMemberships } from "./memberships.js";
3
+ import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
4
4
  import { choicePeriod, periodKey } from "./periods.js";
5
5
  import { recommend } from "./recommend.js";
6
6
  import { rotationGap } from "./rotation.js";
7
7
  import { membershipsUsed } from "./setup.js";
8
- import { categoryPhrase, choiceName, codesAs, earnText, membershipName, money, pointValuesText, words, } from "./text.js";
8
+ import { optionName } from "./wallet-summary.js";
9
+ import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, money, PICK_PERIODS, pointValuesText, } from "./text.js";
9
10
  import { cardValuation } from "./valuation.js";
10
- const EPSILON = 1e-9;
11
+ import { EPSILON } from "./compare.js";
11
12
  export const ALL_PERSONAL_CARDS = {
12
13
  kind: 'any',
13
14
  business: false,
@@ -48,18 +49,6 @@ export function idealHolding(card, date, picks = {}) {
48
49
  : {}),
49
50
  };
50
51
  }
51
- const PICK_PERIODS = {
52
- month: 'set each month',
53
- quarter: 'picked each quarter',
54
- calendar_year: 'picked each year',
55
- until_changed: 'until you change it',
56
- };
57
- const CAP_PERIODS = {
58
- month: 'a month',
59
- quarter: 'a quarter',
60
- calendar_year: 'a calendar year',
61
- cardmember_year: 'a cardmember year',
62
- };
63
52
  const plainWords = (s) => s
64
53
  .toLowerCase()
65
54
  .replace(/[^a-z0-9]+/g, ' ')
@@ -148,17 +137,17 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
148
137
  : null,
149
138
  };
150
139
  }
151
- function conditionsText(catalog, r) {
140
+ function conditionsText(catalog, card, r) {
152
141
  const conditions = [];
153
142
  if (r.pick) {
154
- conditions.push(`Needs ${r.pick.options.map(words).join(' or ')} as its ${choiceName(r.pick.choice)} pick (${PICK_PERIODS[r.pick.period]}).`);
143
+ conditions.push(`Needs ${r.pick.options.map((o) => optionName(catalog, card, r.pick?.choice ?? '', o)).join(' or ')} as its ${choiceName(r.pick.choice)} pick (${r.pick.period === 'until_changed' ? '' : 'picked '}${PICK_PERIODS[r.pick.period]}).`);
155
144
  }
156
145
  if (r.activation !== null)
157
146
  conditions.push('Needs its bonus activated with the issuer (free).');
158
147
  if (r.membership !== null)
159
- conditions.push(`Needs ${membershipName(r.membership)}.`);
148
+ conditions.push(`With your ${membershipName(catalog, r.membership)}.`);
160
149
  if (r.cap) {
161
- conditions.push(`Up to ${money(r.cap.amount)} ${CAP_PERIODS[r.cap.period] ?? ''} at this rate, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}.`);
150
+ conditions.push(`Up to ${money(r.cap.amount)} ${CAP_PERIODS[r.cap.period]} at this rate, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}.`);
162
151
  }
163
152
  return conditions;
164
153
  }
@@ -243,8 +232,8 @@ export function exploreCards(catalog, query, options, resolved) {
243
232
  picks,
244
233
  requirements,
245
234
  conditions: [
246
- ...conditionsText(catalog, requirements),
247
- ...membershipNeeded(card, paid),
235
+ ...conditionsText(catalog, card, requirements),
236
+ ...membershipNeeded(catalog, card, paid),
248
237
  ],
249
238
  evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
250
239
  withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),