earnrate 0.1.1 → 0.1.2

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.
package/README.md CHANGED
@@ -9,23 +9,26 @@ I wanted a ghost card: one card that always charges the best of my cards. The ca
9
9
 
10
10
  **earnrate is the best-card decision layer for people and AI agents.** Give it a merchant, and it tells you which card in your wallet earns the most there, and why. It's stateful (it knows your caps and chosen categories), valuation-aware, and transparent about its assumptions. It never sees a card number.
11
11
 
12
- - Runs on your machine. No account, no telemetry, no affiliate links.
12
+ - Runs on your machine. No account, no telemetry, no affiliate links. See the [privacy page](https://earnrate.dev/privacy).
13
13
  - US cards and US merchants, for now.
14
14
 
15
15
  > **Status:** early, and it works: the engine, CLI, optional Claude merchant lookup and MCP server. See [the benchmark](docs/benchmark.md) for how it compares to an existing card-picking MCP server.
16
16
 
17
17
  ## Try it
18
18
 
19
- Needs Node 24 or later.
19
+ Needs Node 22 or later.
20
20
 
21
21
  ```sh
22
22
  npm install -g earnrate
23
- mkdir -p ~/.earnrate
24
- curl -o ~/.earnrate/wallet.yaml https://raw.githubusercontent.com/cwasley/earnrate/main/wallet.example.yaml
23
+ earnrate cards sapphire # find your cards' ids
24
+ earnrate add chase-sapphire-preferred
25
+ earnrate add amex-gold
25
26
  earnrate whole foods 80
26
27
  ```
27
28
 
28
- Then edit `~/.earnrate/wallet.yaml` to list your own cards (see [Your wallet](#your-wallet)).
29
+ Adding a card tells you anything else it needs to earn correctly, such as the categories you picked on Cash+, Amazon Prime for Prime Visa's 5%, or which rotating categories you've activated. `earnrate update` sets those. Using an AI assistant instead? [Add the MCP server](#use-it-from-an-ai-agent-mcp) and tell it which cards you have.
30
+
31
+ With the [example wallet](wallet.example.yaml), it prints:
29
32
 
30
33
  ```
31
34
  Whole Foods Market · $80
@@ -50,7 +53,7 @@ Options: `--online` or `--in-store`, `--valuation flat|points-heavy`, `--all`, `
50
53
 
51
54
  ### Your wallet
52
55
 
53
- The wallet is a YAML file listing your cards by ID, which is each card's file name in [`data/cards/`](data/cards). Only the cards are required:
56
+ The wallet is a YAML file (`~/.earnrate/wallet.yaml`) listing your cards by ID, which is each card's file name in [`data/cards/`](data/cards). `earnrate add`, `update`, `remove` and `memberships` write it for you and keep your comments, or you can edit it by hand. Only the cards are required:
54
57
 
55
58
  ```yaml
56
59
  valuations:
@@ -139,13 +142,16 @@ Spotted a wrong rate, or want a card added? [Open an issue](https://github.com/c
139
142
 
140
143
  ## Use it from an AI agent (MCP)
141
144
 
142
- `earnrate mcp` runs earnrate as a local MCP server over stdio, with five tools:
145
+ `earnrate mcp` runs earnrate as a local MCP server over stdio, with ten tools:
143
146
 
144
147
  - `best_card_for_purchase`: which card to use, with reasons
145
148
  - `resolve_merchant`: how a merchant was identified
146
149
  - `list_wallet`: your cards, caps left this period, chosen categories and welcome-bonus progress
147
150
  - `compare_welcome_offers`: the best public sign-up offers on cards you don't have
148
151
  - `record_purchase`: books a purchase against its cap and any open welcome bonus in your wallet file, keeping your comments
152
+ - `search_cards`, `add_card`, `update_card`, `remove_card` and `set_memberships`: set up the wallet in conversation
153
+
154
+ There's no wallet to write first: tell the assistant which cards you have, and it finds them, adds them, and asks about anything a card needs, like this quarter's Cash+ picks.
149
155
 
150
156
  For a merchant earnrate doesn't know, the calling model can pass a `category` (e.g. `grocery.supermarket`). It's used only for unknown merchants and is labeled in the answer. The command line takes the same thing as `--category`.
151
157
 
package/data/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  Every change to card rules, merchants, categories or currencies under `data/`. Bump `data/VERSION` (`YYYY.MM.DD`, plus `.N` for a second release on the same day) and add a section here in the same commit. Each row cites the issuer page the new value came from.
4
4
 
5
+ ## 2026.10.30.1
6
+
7
+ Freedom Flex's quarterly cap is marked `rotating`, so a quarter whose categories aren't in the data yet gets a warning instead of quietly ranking Flex at its everyday rates. Capital One's issuer id is now spelled the same on every card. No rates changed.
8
+
9
+ | Card | Field | Old → new | Source |
10
+ | ------------------------------------------------------------- | --------------------------- | ------------------------ | ------ |
11
+ | chase-freedom-flex | caps.five-percent-quarterly | adds `rotating` | n/a |
12
+ | capital-one-venture-x, capital-one-venture, capital-one-savor | issuer | capitalone → capital-one | n/a |
13
+
5
14
  ## 2026.10.28.2
6
15
 
7
16
  Bank of America Customized Cash Rewards, reviewed by Collin (`verified_by: collin`). Choice options can now be "any online purchase except these categories", and a choice period can be `until_changed` for picks that carry over.
package/data/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.10.28.2
1
+ 2026.10.30.1
@@ -1,6 +1,6 @@
1
1
  id: capital-one-savor
2
2
  name: Capital One Savor Rewards Credit Card
3
- issuer: capitalone
3
+ issuer: capital-one
4
4
  # The card page never names the network in visible text; its embedded
5
5
  # product data says MasterCard.
6
6
  network: mastercard
@@ -1,6 +1,6 @@
1
1
  id: capital-one-venture-x
2
2
  name: Capital One Venture X Rewards Credit Card
3
- issuer: capitalone
3
+ issuer: capital-one
4
4
  network: visa # Visa Infinite, per the card page's footnotes
5
5
  currency: C1
6
6
  last_verified: 2026-10-26
@@ -1,6 +1,6 @@
1
1
  id: capital-one-venture
2
2
  name: Capital One Venture Rewards Credit Card
3
- issuer: capitalone
3
+ issuer: capital-one
4
4
  # The card page never names the network in visible text. Its embedded product
5
5
  # data says MasterCard; older Venture accounts may be on Visa.
6
6
  network: mastercard
@@ -31,6 +31,7 @@ caps:
31
31
  amount: 1500
32
32
  period: quarter
33
33
  then_rate: 1
34
+ rotating: true
34
35
  rules:
35
36
  - id: chase-travel
36
37
  match: { merchants: [chase-travel] }
@@ -0,0 +1,34 @@
1
+ const NETWORKS = {
2
+ visa: 'Visa',
3
+ mastercard: 'Mastercard',
4
+ amex: 'Amex',
5
+ discover: 'Discover',
6
+ store: 'store card',
7
+ };
8
+ export function formatCardInfo(card) {
9
+ const fee = card.annualFee === null
10
+ ? ''
11
+ : card.annualFee === 0
12
+ ? ', no annual fee'
13
+ : `, $${String(card.annualFee)} a year`;
14
+ const needs = [
15
+ ...card.choices.map((c) => `${c.id}: pick ${String(c.pick)} of ${c.options.join(', ')}${c.default ? ` (default ${c.default.join(', ')})` : ''}`),
16
+ ...(card.memberships.length > 0
17
+ ? [`rates that need a membership: ${card.memberships.join(', ')}`]
18
+ : []),
19
+ ...(card.activation.length > 0
20
+ ? [`activate with the issuer: ${card.activation.join(', ')}`]
21
+ : []),
22
+ ...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
23
+ ...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
24
+ ];
25
+ return [
26
+ `${card.id}: ${card.name} (${NETWORKS[card.network]}${fee})${card.held ? ' [in wallet]' : ''}`,
27
+ ...needs.map((n) => ` ${n}`),
28
+ ].join('\n');
29
+ }
30
+ export function formatNotes(notes) {
31
+ return notes.length === 0
32
+ ? 'Nothing else to set up for it.'
33
+ : `Still to set:\n${notes.map((n) => `- ${n.text}`).join('\n')}`;
34
+ }
package/dist/cli/run.js CHANGED
@@ -14,6 +14,7 @@ import { packageVersion } from "../version.js";
14
14
  import { parseCliArgs } from "./args.js";
15
15
  import { runImport } from "./import.js";
16
16
  import { runOffers } from "./offers.js";
17
+ import { runWalletCommand, WALLET_HELP } from "./wallet.js";
17
18
  import { formatRecommendation, plain } from "./format.js";
18
19
  export const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
19
20
 
@@ -24,11 +25,18 @@ Examples:
24
25
  ${CLI_NAME} costco --in-store
25
26
  ${CLI_NAME} delta.com 450 --valuation points-heavy
26
27
 
28
+ Set up your wallet:
29
+ ${CLI_NAME} cards [search words] Find cards and their ids.
30
+ ${CLI_NAME} add <card-id> [--pick ...] Add a card you hold.
31
+ ${CLI_NAME} update <card-id> [--pick ...] Change picks, label or activations.
32
+ ${CLI_NAME} remove <card-id> Remove a card.
33
+ ${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime).
34
+
27
35
  Other commands:
28
36
  ${CLI_NAME} import <statement.csv> --card <id> Replay a statement against your wallet.
29
37
  ${CLI_NAME} offers [--cash | --points] Rank welcome offers on cards you don't have.
30
38
  ${CLI_NAME} mcp Run the MCP server over stdio, for AI agents.
31
- Add --help to import or offers for its options.
39
+ Add --help to any command for its options.
32
40
 
33
41
  Options:
34
42
  --online, --in-store How you're paying. Default: inferred from the merchant.
@@ -66,7 +74,7 @@ export async function readWallet(file, io) {
66
74
  }
67
75
  catch (e) {
68
76
  if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
69
- io.stderr(`No wallet at ${displayPath(file)}. Copy the example there and edit it:\n mkdir -p ${displayPath(path.dirname(file))} && cp ${displayPath(EXAMPLE_WALLET)} ${displayPath(file)}\nOr set ${WALLET_ENV_VAR} to your wallet's path.\n`);
77
+ io.stderr(`No wallet at ${displayPath(file)} yet. Add the cards you hold:\n ${CLI_NAME} cards sapphire find a card's id\n ${CLI_NAME} add chase-sapphire-preferred\nOr start from the example: mkdir -p ${displayPath(path.dirname(file))} && cp ${displayPath(EXAMPLE_WALLET)} ${displayPath(file)}\nOr set ${WALLET_ENV_VAR} to your wallet's path.\n`);
70
78
  }
71
79
  else {
72
80
  io.stderr(`Couldn't read your wallet:\n${e instanceof Error ? e.message : String(e)}\n`);
@@ -79,6 +87,10 @@ export async function run(argv, io) {
79
87
  return runImport(argv.slice(1), io);
80
88
  if (argv[0] === 'offers')
81
89
  return runOffers(argv.slice(1), io);
90
+ const command = argv[0];
91
+ if (command !== undefined && Object.hasOwn(WALLET_HELP, command)) {
92
+ return runWalletCommand(command, argv.slice(1), io);
93
+ }
82
94
  if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) {
83
95
  io.stdout(`${CLI_NAME} ${await packageVersion()}\n`);
84
96
  return 0;
@@ -0,0 +1,194 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { CLI_NAME } from "../brand.js";
3
+ import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
4
+ import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
5
+ import { knownMemberships, searchCards } from "../engine/setup.js";
6
+ import { formatCardInfo, formatNotes } from "./cards.js";
7
+ import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
8
+ const CARD_OPTIONS = `Options:
9
+ --label <text> How the card appears at checkout, e.g. "Amex Gold".
10
+ --pick <choice>=<options> Bonus categories you picked for this period, e.g.
11
+ --pick five-percent=fast-food,movie-theaters.
12
+ Repeat for each choice.
13
+ --anniversary <MM-DD> Account anniversary, for caps that reset on it.
14
+ --activated <rules> Rules you've activated with the issuer, comma
15
+ separated. Replaces the current list.
16
+ -h, --help Show this help.`;
17
+ export const WALLET_HELP = {
18
+ cards: `Usage: ${CLI_NAME} cards [search words]
19
+
20
+ Finds cards in earnrate's catalog by name or issuer, with the id to add them
21
+ by and anything they need set up. With no words, lists every card.
22
+
23
+ Examples:
24
+ ${CLI_NAME} cards sapphire
25
+ ${CLI_NAME} cards bofa
26
+ `,
27
+ add: `Usage: ${CLI_NAME} add <card-id> [options]
28
+
29
+ Adds a card you hold to your wallet, creating the wallet if needed. Find the
30
+ id with \`${CLI_NAME} cards\`.
31
+
32
+ ${CARD_OPTIONS}
33
+ `,
34
+ update: `Usage: ${CLI_NAME} update <card-id> [options]
35
+
36
+ Changes a card in your wallet: picks for this period, label, anniversary or
37
+ activations. Picks for the current period are merged with ones already set.
38
+
39
+ ${CARD_OPTIONS}
40
+ `,
41
+ remove: `Usage: ${CLI_NAME} remove <card-id>
42
+
43
+ Removes a card from your wallet, with its tracked cap usage.
44
+ `,
45
+ memberships: `Usage: ${CLI_NAME} memberships [<membership>...] [--none]
46
+
47
+ With no arguments, shows your memberships and the ones earnrate knows. With
48
+ names, replaces the list. --none clears it.
49
+ `,
50
+ };
51
+ const HINTS = {
52
+ pick: (id) => `${CLI_NAME} update ${id} --pick <choice>=<option>,...`,
53
+ membership: () => `${CLI_NAME} memberships <membership>...`,
54
+ activation: (id) => `${CLI_NAME} update ${id} --activated <rule>,...`,
55
+ anniversary: (id) => `${CLI_NAME} update ${id} --anniversary MM-DD`,
56
+ };
57
+ function parsePicks(values) {
58
+ const picks = {};
59
+ for (const v of values) {
60
+ const [choice, options] = v.split('=', 2);
61
+ const ids = (options ?? '')
62
+ .split(',')
63
+ .map((o) => o.trim())
64
+ .filter((o) => o !== '');
65
+ if (!choice || ids.length === 0) {
66
+ return `"--pick ${v}" should look like --pick five-percent=fast-food,movie-theaters.`;
67
+ }
68
+ picks[choice] = ids;
69
+ }
70
+ return picks;
71
+ }
72
+ async function heldWallet(io) {
73
+ try {
74
+ return await loadWallet(walletPath(io.env));
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ function report(result, message, cardId, io) {
81
+ if (!result.ok) {
82
+ io.stderr(`${result.error}\n`);
83
+ return RUNTIME_ERROR;
84
+ }
85
+ const lines = [message];
86
+ if (cardId !== null) {
87
+ lines.push(formatNotes(result.notes));
88
+ const hints = [...new Set(result.notes.map((n) => n.kind))]
89
+ .map((kind) => HINTS[kind]?.(cardId))
90
+ .filter((h) => h !== undefined);
91
+ if (hints.length > 0)
92
+ lines.push(`Set them with:\n${hints.map((h) => ` ${h}`).join('\n')}`);
93
+ }
94
+ io.stdout(`${lines.join('\n')}\n`);
95
+ return 0;
96
+ }
97
+ export async function runWalletCommand(command, argv, io) {
98
+ let parsed;
99
+ try {
100
+ parsed = parseArgs({
101
+ args: argv,
102
+ allowPositionals: true,
103
+ options: {
104
+ label: { type: 'string' },
105
+ pick: { type: 'string', multiple: true },
106
+ anniversary: { type: 'string' },
107
+ activated: { type: 'string' },
108
+ none: { type: 'boolean' },
109
+ help: { type: 'boolean', short: 'h' },
110
+ },
111
+ });
112
+ }
113
+ catch (e) {
114
+ io.stderr(`${e instanceof Error ? e.message : String(e)}\nRun ${CLI_NAME} ${command} --help for usage.\n`);
115
+ return USAGE_ERROR;
116
+ }
117
+ const { values, positionals } = parsed;
118
+ if (values.help) {
119
+ io.stdout(WALLET_HELP[command]);
120
+ return 0;
121
+ }
122
+ const usage = (message) => {
123
+ io.stderr(`${message}\nRun ${CLI_NAME} ${command} --help for usage.\n`);
124
+ return USAGE_ERROR;
125
+ };
126
+ const cardOptionsGiven = values.label !== undefined ||
127
+ values.pick !== undefined ||
128
+ values.anniversary !== undefined ||
129
+ values.activated !== undefined;
130
+ if (command !== 'add' && command !== 'update' && cardOptionsGiven) {
131
+ return usage(`${CLI_NAME} ${command} doesn't take card options.`);
132
+ }
133
+ if (command !== 'memberships' && values.none) {
134
+ return usage('--none is only for memberships.');
135
+ }
136
+ const catalog = await loadCatalog();
137
+ const file = walletPath(io.env);
138
+ const where = displayPath(file);
139
+ if (command === 'cards') {
140
+ const query = positionals.join(' ');
141
+ const result = searchCards(catalog, query, await heldWallet(io), io.today);
142
+ if (result.cards.length === 0) {
143
+ io.stderr(`No card matches "${query}". Run ${CLI_NAME} cards with no words to list all ${String(catalog.cards.length)}.\n`);
144
+ return RUNTIME_ERROR;
145
+ }
146
+ const header = result.exact ? '' : 'No card matches every word. Closest:\n';
147
+ io.stdout(`${header}${result.cards.map(formatCardInfo).join('\n')}\n`);
148
+ return 0;
149
+ }
150
+ if (command === 'memberships') {
151
+ if (positionals.length === 0 && !values.none) {
152
+ const held = await heldWallet(io);
153
+ io.stdout(`Yours: ${held && held.memberships.length > 0 ? held.memberships.join(', ') : 'none'}\nKnown: ${knownMemberships(catalog).join(', ')}\n`);
154
+ return 0;
155
+ }
156
+ if (positionals.length > 0 && values.none) {
157
+ return usage('Give memberships or --none, not both.');
158
+ }
159
+ const list = values.none ? [] : positionals;
160
+ return report(await setMemberships(file, catalog, list), list.length > 0
161
+ ? `Memberships set to ${list.join(', ')} in ${where}.`
162
+ : `Memberships cleared in ${where}.`, null, io);
163
+ }
164
+ if (positionals.length !== 1) {
165
+ return usage('Give one card id.');
166
+ }
167
+ const id = positionals[0] ?? '';
168
+ const name = catalog.cards.find((c) => c.id === id)?.name ?? id;
169
+ if (command === 'remove') {
170
+ return report(await removeCard(file, catalog, id), `Removed ${name} from ${where}.`, null, io);
171
+ }
172
+ const input = { id };
173
+ if (values.label !== undefined)
174
+ input.checkoutLabel = values.label;
175
+ if (values.anniversary !== undefined)
176
+ input.anniversary = values.anniversary;
177
+ if (values.activated !== undefined) {
178
+ input.activated = values.activated
179
+ .split(',')
180
+ .map((r) => r.trim())
181
+ .filter((r) => r !== '');
182
+ }
183
+ if (values.pick !== undefined) {
184
+ const picks = parsePicks(values.pick);
185
+ if (typeof picks === 'string')
186
+ return usage(picks);
187
+ input.picks = picks;
188
+ }
189
+ if (command === 'add') {
190
+ const result = await addCard(file, catalog, input, io.today);
191
+ return report(result, `Added ${name} to ${where}${result.ok && result.created ? ' (a new wallet)' : ''}.`, id, io);
192
+ }
193
+ return report(await updateCard(file, catalog, input, io.today), `Updated ${name} in ${where}.`, id, io);
194
+ }
@@ -136,6 +136,20 @@ function cardProblems(card, catalog, merchantIds) {
136
136
  problems.push(`${where} ${context}: unknown cap '${rule.cap}'`);
137
137
  }
138
138
  }
139
+ for (const cap of card.caps.filter((c) => c.rotating)) {
140
+ const rules = card.rules.filter((r) => r.cap === cap.id);
141
+ if (rules.length === 0) {
142
+ problems.push(`${where} cap ${cap.id}: rotating, but no rule uses it`);
143
+ }
144
+ for (const r of rules) {
145
+ if (r.valid_from === undefined || r.valid_to === undefined) {
146
+ problems.push(`${where} rule ${r.id}: rules on rotating cap '${cap.id}' need valid_from and valid_to`);
147
+ }
148
+ }
149
+ if (cap.period === 'cardmember_year') {
150
+ problems.push(`${where} cap ${cap.id}: a rotating cap can't use cardmember years`);
151
+ }
152
+ }
139
153
  return problems;
140
154
  }
141
155
  export function catalogProblems(catalog) {
@@ -0,0 +1,162 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
4
+ import { isMap, isSeq, parseDocument, stringify } from 'yaml';
5
+ import { WalletSchema } from "../schema/wallet.js";
6
+ const NEW_WALLET = `# Your earnrate wallet: the cards you hold, by id. Never card numbers.
7
+ # Format: https://github.com/cwasley/earnrate#your-wallet
8
+
9
+ `;
10
+ const BY_HAND = 'edit the wallet by hand';
11
+ const lineStart = (text, at) => text.lastIndexOf('\n', at - 1) + 1;
12
+ function afterLine(text, at) {
13
+ const end = text.indexOf('\n', at);
14
+ return end === -1 ? text.length : end + 1;
15
+ }
16
+ function insertLines(text, at, lines) {
17
+ const head = text.slice(0, at);
18
+ const gap = head.length > 0 && !head.endsWith('\n') ? '\n' : '';
19
+ return `${head}${gap}${lines}${text.slice(at)}`;
20
+ }
21
+ function range(node) {
22
+ const r = node?.range;
23
+ if (!r)
24
+ throw new Error(`a wallet value has no source position; ${BY_HAND}`);
25
+ return r;
26
+ }
27
+ const keyOf = (pair) => pair.key?.value;
28
+ const scalar = (value) => stringify(value).trimEnd();
29
+ const flowList = (ids) => `[${ids.join(', ')}]`;
30
+ function fieldLines(field, f, indent) {
31
+ switch (field) {
32
+ case 'checkout_label':
33
+ case 'anniversary': {
34
+ const value = f[field];
35
+ return value === undefined ? '' : `${indent}${field}: ${scalar(value)}\n`;
36
+ }
37
+ case 'activated':
38
+ return f.activated === undefined
39
+ ? ''
40
+ : `${indent}activated: ${flowList(f.activated)}\n`;
41
+ case 'chosen': {
42
+ if (f.chosen === undefined)
43
+ return '';
44
+ const picks = Object.entries(f.chosen.picks)
45
+ .map(([choice, ids]) => `${indent} ${choice}: ${flowList(ids)}\n`)
46
+ .join('');
47
+ return `${indent}chosen:\n${indent} period: ${scalar(f.chosen.period)}\n${indent} picks:\n${picks}`;
48
+ }
49
+ }
50
+ }
51
+ const FIELDS = ['checkout_label', 'anniversary', 'chosen', 'activated'];
52
+ function cardsSeq(text) {
53
+ const doc = parseDocument(text);
54
+ if (doc.errors.length > 0)
55
+ throw new Error(doc.errors[0]?.message);
56
+ const cards = doc.get('cards', true);
57
+ if (!isSeq(cards) || cards.flow) {
58
+ throw new Error(`"cards" isn't a block list; ${BY_HAND}`);
59
+ }
60
+ return { doc, cards };
61
+ }
62
+ function findCard(text, cardId) {
63
+ const { cards } = cardsSeq(text);
64
+ const card = cards.items.find((item) => isMap(item) && item.get('id') === cardId);
65
+ if (!isMap(card))
66
+ throw new Error(`no card '${cardId}' in the wallet`);
67
+ if (card.flow)
68
+ throw new Error(`card '${cardId}' is written inline; ${BY_HAND}`);
69
+ return card;
70
+ }
71
+ const column = (text, at) => at - lineStart(text, at);
72
+ // Appends a card after the last one, matching the list's indentation.
73
+ export function addCardText(text, entry) {
74
+ if (text === null || text.trim() === '') {
75
+ return `${NEW_WALLET}cards:\n${cardLines(entry, ' ')}`;
76
+ }
77
+ const { cards } = cardsSeq(text);
78
+ const first = cards.items[0];
79
+ if (!isMap(first))
80
+ throw new Error(`"cards" is empty; ${BY_HAND}`);
81
+ const keyColumn = column(text, range(first)[0]);
82
+ if (keyColumn < 2 || text[range(first)[0] - 2] !== '-') {
83
+ throw new Error(`can't tell how the cards are indented; ${BY_HAND}`);
84
+ }
85
+ const at = afterLine(text, range(cards)[1] - 1);
86
+ return insertLines(text, at, cardLines(entry, ' '.repeat(keyColumn - 2)));
87
+ }
88
+ function cardLines(entry, dashIndent) {
89
+ const indent = `${dashIndent} `;
90
+ return `${dashIndent}- id: ${entry.id}\n${FIELDS.map((f) => fieldLines(f, entry, indent)).join('')}`;
91
+ }
92
+ export function removeCardText(text, cardId) {
93
+ const card = findCard(text, cardId);
94
+ const start = lineStart(text, range(card)[0]);
95
+ const end = afterLine(text, range(card)[1] - 1);
96
+ return text.slice(0, start) + text.slice(end);
97
+ }
98
+ // Replaces each given field in place, or adds it after the card's last line.
99
+ export function updateCardText(text, cardId, fields) {
100
+ let out = text;
101
+ for (const field of FIELDS) {
102
+ if (fields[field] === undefined)
103
+ continue;
104
+ const card = findCard(out, cardId);
105
+ const indent = ' '.repeat(column(out, range(card)[0]));
106
+ const lines = fieldLines(field, fields, indent);
107
+ const pair = card.items.find((p) => keyOf(p) === field);
108
+ if (pair) {
109
+ const start = range(pair.key)[0];
110
+ const end = afterLine(out, range(pair.value)[1] - 1);
111
+ out = out.slice(0, start) + lines.trimStart() + out.slice(end);
112
+ }
113
+ else {
114
+ out = insertLines(out, afterLine(out, range(card)[1] - 1), lines);
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+ export function setMembershipsText(text, memberships) {
120
+ const { doc } = cardsSeq(text);
121
+ const root = doc.contents;
122
+ if (!isMap(root))
123
+ throw new Error(`the wallet isn't a map; ${BY_HAND}`);
124
+ const line = `memberships: ${flowList(memberships)}\n`;
125
+ const pair = root.items.find((p) => keyOf(p) === 'memberships');
126
+ if (pair) {
127
+ const start = range(pair.key)[0];
128
+ const end = afterLine(text, (pair.value === null ? range(pair.key) : range(pair.value))[1] - 1);
129
+ return text.slice(0, start) + line + text.slice(end);
130
+ }
131
+ const cards = root.items.find((p) => keyOf(p) === 'cards');
132
+ return insertLines(text, lineStart(text, range(cards?.key)[0]), line);
133
+ }
134
+ async function readText(file) {
135
+ try {
136
+ return await readFile(file, 'utf8');
137
+ }
138
+ catch (e) {
139
+ if (e instanceof Error && 'code' in e && e.code === 'ENOENT')
140
+ return null;
141
+ throw e;
142
+ }
143
+ }
144
+ export async function readWalletText(file) {
145
+ const text = await readText(file);
146
+ return {
147
+ text,
148
+ wallet: text === null ? null : WalletSchema.parse(parseDocument(text).toJS()),
149
+ };
150
+ }
151
+ // Writes `after` only if it parses to exactly `expected`, so a splice that
152
+ // went wrong can never change anything it wasn't meant to.
153
+ export async function commitWalletText(file, after, expected) {
154
+ const parsed = WalletSchema.safeParse(parseDocument(after).toJS());
155
+ if (!parsed.success || !isDeepStrictEqual(parsed.data, expected)) {
156
+ throw new Error(`couldn't update ${file} safely; ${BY_HAND}`);
157
+ }
158
+ await mkdir(path.dirname(file), { recursive: true });
159
+ const tmp = `${file}.${String(process.pid)}.tmp`;
160
+ await writeFile(tmp, after);
161
+ await rename(tmp, file);
162
+ }
@@ -0,0 +1,202 @@
1
+ import { periodKey } from "../engine/periods.js";
2
+ import { knownMemberships, searchCards, setupNotes, } from "../engine/setup.js";
3
+ import { WalletCardSchema, WalletSchema, } from "../schema/wallet.js";
4
+ import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, updateCardText, } from "./wallet-edit.js";
5
+ const failed = (error) => ({ ok: false, error });
6
+ const list = (ids) => ids.join(', ');
7
+ function findCatalogCard(catalog, id, today) {
8
+ const card = catalog.cards.find((c) => c.id === id);
9
+ if (card)
10
+ return card;
11
+ const near = searchCards(catalog, id, null, today).cards.map((c) => c.id);
12
+ return near.length > 0
13
+ ? `No card '${id}' in earnrate. Closest: ${list(near)}.`
14
+ : `No card '${id}' in earnrate. Search the catalog to find its id.`;
15
+ }
16
+ function isAnniversary(text) {
17
+ const m = /^(\d{2})-(\d{2})$/.exec(text);
18
+ if (!m)
19
+ return false;
20
+ const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
21
+ return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
22
+ }
23
+ // The period key picks are filed under: the card's choice period, or for
24
+ // picks that carry over, the current year (it's never compared).
25
+ function choicePeriod(card, today) {
26
+ const periods = [
27
+ ...new Set(card.choices.map((c) => c.period).filter((p) => p !== 'until_changed')),
28
+ ];
29
+ if (periods.length > 1)
30
+ return null;
31
+ return periodKey(periods[0] ?? 'calendar_year', today, null);
32
+ }
33
+ function fieldsFor(card, held, input, today) {
34
+ const problems = [];
35
+ const fields = {};
36
+ if (input.checkoutLabel !== undefined) {
37
+ if (input.checkoutLabel.trim() === '')
38
+ problems.push('The checkout label is empty.');
39
+ // earnrate never stores card numbers; a last four is fine.
40
+ if (/\d{8,}/.test(input.checkoutLabel.replace(/[\s-]/g, '')))
41
+ 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".');
42
+ fields.checkout_label = input.checkoutLabel.trim();
43
+ }
44
+ if (input.anniversary !== undefined) {
45
+ if (!isAnniversary(input.anniversary)) {
46
+ problems.push(`"${input.anniversary}" isn't an anniversary. Use MM-DD, like 03-15.`);
47
+ }
48
+ fields.anniversary = input.anniversary;
49
+ }
50
+ if (input.activated !== undefined) {
51
+ const needing = card.rules
52
+ .filter((r) => r.requires?.activation)
53
+ .map((r) => r.id);
54
+ for (const id of input.activated) {
55
+ if (!needing.includes(id)) {
56
+ problems.push(needing.length > 0
57
+ ? `'${id}' isn't a ${card.id} rule that needs activating. Those are: ${list(needing)}.`
58
+ : `${card.id} has no rules that need activating.`);
59
+ }
60
+ }
61
+ fields.activated = [...new Set(input.activated)];
62
+ }
63
+ if (input.picks !== undefined) {
64
+ for (const [choiceId, picks] of Object.entries(input.picks)) {
65
+ const choice = card.choices.find((c) => c.id === choiceId);
66
+ if (!choice) {
67
+ problems.push(card.choices.length > 0
68
+ ? `${card.id} has no '${choiceId}' pick. Its picks are: ${list(card.choices.map((c) => c.id))}.`
69
+ : `${card.id} has no categories to pick.`);
70
+ continue;
71
+ }
72
+ const options = Object.keys(choice.options);
73
+ for (const p of picks) {
74
+ if (!options.includes(p)) {
75
+ problems.push(`'${p}' isn't a ${choiceId} option for ${card.id}. Options: ${list(options)}.`);
76
+ }
77
+ }
78
+ if (picks.length === 0 || picks.length > choice.pick) {
79
+ problems.push(`${choiceId} takes ${choice.pick === 1 ? '1 pick' : `1 to ${String(choice.pick)} picks`}, not ${String(picks.length)}.`);
80
+ }
81
+ }
82
+ const period = choicePeriod(card, today);
83
+ if (period === null) {
84
+ problems.push(`${card.id} has picks on different schedules; edit the wallet by hand.`);
85
+ }
86
+ else {
87
+ const kept = held?.chosen?.period === period ? held.chosen.picks : {};
88
+ fields.chosen = {
89
+ period,
90
+ picks: { ...kept, ...input.picks },
91
+ };
92
+ }
93
+ }
94
+ return problems.length > 0 ? problems.join('\n') : fields;
95
+ }
96
+ const applyFields = (held, f) => WalletCardSchema.parse({
97
+ ...held,
98
+ ...(f.checkout_label === undefined
99
+ ? {}
100
+ : { checkout_label: f.checkout_label }),
101
+ ...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
102
+ ...(f.chosen === undefined ? {} : { chosen: f.chosen }),
103
+ ...(f.activated === undefined ? {} : { activated: f.activated }),
104
+ });
105
+ async function attempt(task) {
106
+ try {
107
+ return await task();
108
+ }
109
+ catch (e) {
110
+ return failed(e instanceof Error ? e.message : String(e));
111
+ }
112
+ }
113
+ const noWallet = (file) => `No wallet at ${file} yet. Add a card first to create it.`;
114
+ export function addCard(file, catalog, input, today) {
115
+ return attempt(async () => {
116
+ const card = findCatalogCard(catalog, input.id, today);
117
+ if (typeof card === 'string')
118
+ return failed(card);
119
+ const { text, wallet } = await readWalletText(file);
120
+ if (wallet?.cards.some((c) => c.id === card.id)) {
121
+ return failed(`${card.id} is already in the wallet. Update it instead.`);
122
+ }
123
+ const fields = fieldsFor(card, null, input, today);
124
+ if (typeof fields === 'string')
125
+ return failed(fields);
126
+ const held = applyFields(WalletCardSchema.parse({ id: card.id }), fields);
127
+ const expected = wallet
128
+ ? { ...wallet, cards: [...wallet.cards, held] }
129
+ : WalletSchema.parse({ cards: [held] });
130
+ await commitWalletText(file, addCardText(text, { id: card.id, ...fields }), expected);
131
+ return {
132
+ ok: true,
133
+ wallet: expected,
134
+ created: text === null,
135
+ notes: setupNotes(catalog, expected, held, today),
136
+ };
137
+ });
138
+ }
139
+ export function updateCard(file, catalog, input, today) {
140
+ return attempt(async () => {
141
+ const { text, wallet } = await readWalletText(file);
142
+ if (text === null || wallet === null)
143
+ return failed(noWallet(file));
144
+ const held = wallet.cards.find((c) => c.id === input.id);
145
+ const card = catalog.cards.find((c) => c.id === input.id);
146
+ if (!held || !card) {
147
+ return failed(`${input.id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
148
+ }
149
+ const fields = fieldsFor(card, held, input, today);
150
+ if (typeof fields === 'string')
151
+ return failed(fields);
152
+ if (Object.keys(fields).length === 0)
153
+ return failed('Nothing to change.');
154
+ const updated = applyFields(held, fields);
155
+ const expected = {
156
+ ...wallet,
157
+ cards: wallet.cards.map((c) => (c.id === held.id ? updated : c)),
158
+ };
159
+ await commitWalletText(file, updateCardText(text, held.id, fields), expected);
160
+ return {
161
+ ok: true,
162
+ wallet: expected,
163
+ created: false,
164
+ notes: setupNotes(catalog, expected, updated, today),
165
+ };
166
+ });
167
+ }
168
+ export function removeCard(file, _catalog, id) {
169
+ return attempt(async () => {
170
+ const { text, wallet } = await readWalletText(file);
171
+ if (text === null || wallet === null)
172
+ return failed(noWallet(file));
173
+ if (!wallet.cards.some((c) => c.id === id)) {
174
+ return failed(`${id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
175
+ }
176
+ if (wallet.cards.length === 1) {
177
+ return failed(`${id} is the last card in the wallet, and a wallet needs one. Add the new card first, then remove this one.`);
178
+ }
179
+ const expected = {
180
+ ...wallet,
181
+ cards: wallet.cards.filter((c) => c.id !== id),
182
+ };
183
+ await commitWalletText(file, removeCardText(text, id), expected);
184
+ return { ok: true, wallet: expected, created: false, notes: [] };
185
+ });
186
+ }
187
+ export function setMemberships(file, catalog, memberships) {
188
+ return attempt(async () => {
189
+ const known = knownMemberships(catalog);
190
+ const unknown = memberships.filter((m) => !known.includes(m));
191
+ if (unknown.length > 0) {
192
+ return failed(`No card rule uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
193
+ }
194
+ const { text, wallet } = await readWalletText(file);
195
+ if (text === null || wallet === null)
196
+ return failed(noWallet(file));
197
+ const unique = [...new Set(memberships)];
198
+ const expected = { ...wallet, memberships: unique };
199
+ await commitWalletText(file, setMembershipsText(text, unique), expected);
200
+ return { ok: true, wallet: expected, created: false, notes: [] };
201
+ });
202
+ }
@@ -2,6 +2,7 @@ import { resolveMerchant, resolveMerchantById } from "../merchants/resolve.js";
2
2
  import { evaluateCard, purchaseContext, } from "./evaluate.js";
3
3
  import { bonusStatus } from "./bonus.js";
4
4
  import { daysBetween } from "./periods.js";
5
+ import { rotationGap } from "./rotation.js";
5
6
  import { cardValuation } from "./valuation.js";
6
7
  const EPSILON = 1e-9;
7
8
  export const STALE_AFTER_DAYS = 180;
@@ -327,6 +328,14 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
327
328
  : '';
328
329
  warnings.push(`Not every business takes ${NETWORK_NAMES[winner.card.network] ?? winner.card.network}.${fallback}`);
329
330
  }
331
+ for (const held of wallet.cards) {
332
+ const card = catalog.cards.find((c) => c.id === held.id);
333
+ const gap = card ? rotationGap(card, purchase.date) : null;
334
+ if (card && gap !== null) {
335
+ const name = held.checkout_label ?? card.name;
336
+ warnings.push(`${name}'s rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them. If this purchase is in that period's categories, ${name} may earn more.`);
337
+ }
338
+ }
330
339
  const tie = tieNote(entries);
331
340
  if (tie)
332
341
  notes.push(tie);
@@ -0,0 +1,16 @@
1
+ import { periodKey } from "./periods.js";
2
+ // The period of the card's rotating categories that `date` falls in, when no
3
+ // rule in the data covers it yet; null when it's covered or the card doesn't
4
+ // rotate.
5
+ export function rotationGap(card, date) {
6
+ for (const cap of card.caps) {
7
+ if (!cap.rotating || cap.period === 'cardmember_year')
8
+ continue;
9
+ const covered = card.rules.some((r) => r.cap === cap.id &&
10
+ (r.valid_from ?? '') <= date &&
11
+ date <= (r.valid_to ?? ''));
12
+ if (!covered)
13
+ return periodKey(cap.period, date, null);
14
+ }
15
+ return null;
16
+ }
@@ -0,0 +1,168 @@
1
+ import { pickedCategories } from "./evaluate.js";
2
+ import { daysBetween } from "./periods.js";
3
+ import { rotationGap } from "./rotation.js";
4
+ // Rules that start within this many days are worth activating now.
5
+ const ACTIVATION_LOOKAHEAD_DAYS = 45;
6
+ const ISSUER_NICKNAMES = {
7
+ amex: 'american express',
8
+ 'bank-of-america': 'bofa boa bankofamerica',
9
+ 'capital-one': 'capitalone',
10
+ citi: 'citibank',
11
+ usbank: 'us bank',
12
+ 'wells-fargo': 'wf wellsfargo',
13
+ 'td-bank': 'td',
14
+ column: 'bilt',
15
+ };
16
+ const words = (s) => s
17
+ .toLowerCase()
18
+ .replace(/\+/g, ' plus ')
19
+ .replace(/&/g, ' and ')
20
+ .split(/[^a-z0-9]+/)
21
+ .filter((w) => w.length > 0);
22
+ function editDistance(a, b) {
23
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
24
+ for (let i = 1; i <= a.length; i++) {
25
+ let diagonal = row[0] ?? 0;
26
+ row[0] = i;
27
+ for (let j = 1; j <= b.length; j++) {
28
+ const above = row[j] ?? 0;
29
+ row[j] = Math.min(above + 1, (row[j - 1] ?? 0) + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
30
+ diagonal = above;
31
+ }
32
+ }
33
+ return row[b.length] ?? 0;
34
+ }
35
+ function matcher(card) {
36
+ const haystack = [
37
+ ...words(card.id),
38
+ ...words(card.name),
39
+ ...words(card.issuer),
40
+ ...words(ISSUER_NICKNAMES[card.issuer] ?? ''),
41
+ ];
42
+ const compact = haystack.join('');
43
+ return (token) => haystack.some((w) => w.startsWith(token)) ||
44
+ (token.length >= 4 && compact.includes(token)) ||
45
+ (token.length >= 5 &&
46
+ haystack.some((w) => editDistance(w, token) <= (token.length >= 8 ? 2 : 1)));
47
+ }
48
+ function needsAnniversary(card) {
49
+ return (card.caps.some((c) => c.period === 'cardmember_year') ||
50
+ card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year'));
51
+ }
52
+ function activationRules(card, today) {
53
+ return card.rules
54
+ .filter((r) => r.requires?.activation === true &&
55
+ (r.valid_to === undefined || r.valid_to >= today) &&
56
+ (r.valid_from === undefined ||
57
+ daysBetween(today, r.valid_from) <= ACTIVATION_LOOKAHEAD_DAYS))
58
+ .map((r) => r.id);
59
+ }
60
+ export function membershipsUsed(card) {
61
+ return [
62
+ ...new Set(card.rules.flatMap((r) => r.requires?.membership === undefined ? [] : [r.requires.membership])),
63
+ ];
64
+ }
65
+ export function knownMemberships(catalog) {
66
+ return [...new Set(catalog.cards.flatMap(membershipsUsed))].sort();
67
+ }
68
+ export function cardInfo(card, wallet, today) {
69
+ return {
70
+ id: card.id,
71
+ name: card.name,
72
+ issuer: card.issuer,
73
+ network: card.network,
74
+ currency: card.currency,
75
+ annualFee: card.annual_fee ?? null,
76
+ business: card.business,
77
+ onlyAt: card.only_at ?? null,
78
+ held: wallet?.cards.some((c) => c.id === card.id) ?? false,
79
+ choices: card.choices.map((c) => ({
80
+ id: c.id,
81
+ pick: c.pick,
82
+ period: c.period,
83
+ options: Object.keys(c.options),
84
+ default: c.default ?? null,
85
+ })),
86
+ memberships: membershipsUsed(card),
87
+ activation: activationRules(card, today),
88
+ needsAnniversary: needsAnniversary(card),
89
+ };
90
+ }
91
+ // Every word of the query has to match a word of the card's id, name or
92
+ // issuer (as a prefix, or with a typo or two in a long word). With no full
93
+ // match, the cards matching the most words come back instead.
94
+ export function searchCards(catalog, query, wallet, today) {
95
+ const tokens = words(query);
96
+ const scored = catalog.cards.map((card) => {
97
+ const matches = matcher(card);
98
+ return { card, score: tokens.filter((t) => matches(t)).length };
99
+ });
100
+ const byShortestId = (a, b) => words(a.card.id).length - words(b.card.id).length ||
101
+ a.card.id.localeCompare(b.card.id);
102
+ const full = scored.filter((s) => s.score === tokens.length);
103
+ const best = Math.max(0, ...scored.map((s) => s.score));
104
+ const exact = full.length > 0;
105
+ const chosen = exact
106
+ ? full.sort(byShortestId)
107
+ : best === 0
108
+ ? []
109
+ : scored
110
+ .filter((s) => s.score === best)
111
+ .sort(byShortestId)
112
+ .slice(0, 5);
113
+ return {
114
+ cards: chosen.map((s) => cardInfo(s.card, wallet, today)),
115
+ exact,
116
+ };
117
+ }
118
+ const list = (ids) => ids.join(', ');
119
+ // What a card in the wallet still needs before its answers are right.
120
+ export function setupNotes(catalog, wallet, held, today) {
121
+ const card = catalog.cards.find((c) => c.id === held.id);
122
+ if (!card)
123
+ return [];
124
+ const notes = [];
125
+ const name = held.checkout_label ?? card.name;
126
+ const { picked, staleChoice } = pickedCategories(card, held, today);
127
+ for (const choice of card.choices) {
128
+ const current = held.chosen?.picks[choice.id] !== undefined &&
129
+ (staleChoice === null || choice.period === 'until_changed');
130
+ if (current)
131
+ continue;
132
+ const options = list(Object.keys(choice.options));
133
+ notes.push({
134
+ kind: 'pick',
135
+ text: choice.default && picked.has(choice.id)
136
+ ? `${name}'s ${choice.id} pick is assumed to be ${list(choice.default)}, the default. Set it if a different one was picked (${String(choice.pick)} of: ${options}).`
137
+ : `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
138
+ });
139
+ }
140
+ const missing = membershipsUsed(card).filter((m) => !wallet.memberships.includes(m));
141
+ if (missing.length > 0) {
142
+ notes.push({
143
+ kind: 'membership',
144
+ text: `Some of ${name}'s rates need a membership: ${list(missing)}. Add any that apply to the wallet's memberships.`,
145
+ });
146
+ }
147
+ const toActivate = activationRules(card, today).filter((id) => !held.activated.includes(id));
148
+ if (toActivate.length > 0) {
149
+ notes.push({
150
+ kind: 'activation',
151
+ text: `${name} has rates that apply only once activated with the issuer: ${list(toActivate)}. Mark the ones already activated.`,
152
+ });
153
+ }
154
+ if (needsAnniversary(card) && held.anniversary === undefined) {
155
+ notes.push({
156
+ kind: 'anniversary',
157
+ text: `${name} has a cap that resets on the card's anniversary. Add the anniversary (MM-DD) so it can be tracked.`,
158
+ });
159
+ }
160
+ const gap = rotationGap(card, today);
161
+ if (gap !== null) {
162
+ notes.push({
163
+ kind: 'rotation',
164
+ text: `${name}'s rotating categories for ${gap} aren't in earnrate's data yet.`,
165
+ });
166
+ }
167
+ return notes;
168
+ }
@@ -1,6 +1,7 @@
1
1
  import { bonusStatus } from "./bonus.js";
2
2
  import { pickedCategories } from "./evaluate.js";
3
3
  import { daysBetween, periodKey } from "./periods.js";
4
+ import { rotationGap } from "./rotation.js";
4
5
  import { STALE_AFTER_DAYS } from "./recommend.js";
5
6
  import { cardValuation } from "./valuation.js";
6
7
  function capSummaries(card, held, today) {
@@ -54,6 +55,7 @@ export function walletSummary(catalog, wallet, today) {
54
55
  lastVerified: card.last_verified,
55
56
  verifiedBy: card.verified_by,
56
57
  dataIsStale: daysBetween(card.last_verified, today) > STALE_AFTER_DAYS,
58
+ rotationGap: rotationGap(card, today),
57
59
  caps: capSummaries(card, held, today),
58
60
  chosen: held.chosen
59
61
  ? {
@@ -103,6 +103,7 @@ export const WalletSummarySchema = z.object({
103
103
  lastVerified: z.string(),
104
104
  verifiedBy: z.string(),
105
105
  dataIsStale: z.boolean(),
106
+ rotationGap: z.string().nullable(),
106
107
  caps: z.array(z.object({
107
108
  key: z.string(),
108
109
  rules: z.array(z.string()),
@@ -172,3 +173,48 @@ export const OfferComparisonSchema = z.object({
172
173
  withoutOffer: z.array(z.string()),
173
174
  notes: z.array(z.string()),
174
175
  });
176
+ export const CardSearchSchema = z.object({
177
+ exact: z.boolean(),
178
+ cards: z.array(z.object({
179
+ id: z.string(),
180
+ name: z.string(),
181
+ issuer: z.string(),
182
+ network: CardNetwork,
183
+ currency: z.string(),
184
+ annualFee: z.number().nullable(),
185
+ business: z.boolean(),
186
+ onlyAt: z.array(z.string()).nullable(),
187
+ held: z.boolean(),
188
+ choices: z.array(z.object({
189
+ id: z.string(),
190
+ pick: z.number(),
191
+ period: z.enum([
192
+ 'month',
193
+ 'quarter',
194
+ 'calendar_year',
195
+ 'until_changed',
196
+ ]),
197
+ options: z.array(z.string()),
198
+ default: z.array(z.string()).nullable(),
199
+ })),
200
+ memberships: z.array(z.string()),
201
+ activation: z.array(z.string()),
202
+ needsAnniversary: z.boolean(),
203
+ })),
204
+ });
205
+ export const WalletChangeSchema = z.object({
206
+ message: z.string(),
207
+ created: z.boolean(),
208
+ cardIds: z.array(z.string()),
209
+ memberships: z.array(z.string()),
210
+ notes: z.array(z.object({
211
+ kind: z.enum([
212
+ 'pick',
213
+ 'membership',
214
+ 'activation',
215
+ 'anniversary',
216
+ 'rotation',
217
+ ]),
218
+ text: z.string(),
219
+ })),
220
+ });
@@ -1,19 +1,22 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { MCP_SERVER_NAME } from "../brand.js";
4
+ import { formatCardInfo, formatNotes } from "../cli/cards.js";
4
5
  import { formatRecommendation } from "../cli/format.js";
5
6
  import { formatOffers } from "../cli/offers.js";
6
7
  import { walletProblems } from "../data/integrity.js";
7
8
  import { loadWallet } from "../data/load.js";
8
9
  import { withWalletLock, writeBonusSpent, writeCapBooking, } from "../data/wallet-write.js";
10
+ import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
9
11
  import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
10
12
  import { compareOffers } from "../engine/offers.js";
11
13
  import { capBooking } from "../engine/record.js";
12
14
  import { recommend } from "../engine/recommend.js";
15
+ import { knownMemberships, searchCards } from "../engine/setup.js";
13
16
  import { walletSummary } from "../engine/wallet-summary.js";
14
17
  import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
15
18
  import { IsoDate } from "../schema/common.js";
16
- import { MerchantResolutionSchema, RecommendationSchema, OfferComparisonSchema, RecordResultSchema, WalletSummarySchema, } from "./schemas.js";
19
+ import { CardSearchSchema, MerchantResolutionSchema, RecommendationSchema, OfferComparisonSchema, RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "./schemas.js";
17
20
  const Merchant = z
18
21
  .string()
19
22
  .min(1)
@@ -23,13 +26,16 @@ const ChannelInput = z
23
26
  .optional()
24
27
  .describe('How the purchase is made. Omit if unknown; the answer says what it assumed.');
25
28
  const DateInput = IsoDate.optional().describe('Purchase date, YYYY-MM-DD. Defaults to today.');
29
+ const INSTRUCTIONS = `earnrate picks which of the user's credit cards earns the most for a purchase. Its wallet lists the cards they hold by product id, never card numbers; never ask for card numbers, expiry dates or security codes.
30
+
31
+ If a tool says there's no wallet yet, ask the user which cards they have, find each with search_cards, and add it with add_card. add_card and update_card reply with what a card still needs, such as category picks, memberships like Amazon Prime, or activated bonus categories. Ask the user about those and set them with update_card or set_memberships.`;
26
32
  const fail = (text) => ({
27
33
  content: [{ type: 'text', text }],
28
34
  isError: true,
29
35
  });
30
36
  const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
31
37
  export function createEarnrateServer(deps) {
32
- const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version });
38
+ const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: INSTRUCTIONS });
33
39
  const { catalog } = deps;
34
40
  // Read on every call, so edits to the wallet file apply without a restart.
35
41
  async function wallet() {
@@ -38,6 +44,9 @@ export function createEarnrateServer(deps) {
38
44
  loaded = await loadWallet(deps.walletFile);
39
45
  }
40
46
  catch (e) {
47
+ if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
48
+ return `There's no wallet yet (${deps.walletFile}). Ask the user which credit cards they have, find each one with search_cards, and add it with add_card.`;
49
+ }
41
50
  return `Couldn't read the wallet at ${deps.walletFile}: ${e instanceof Error ? e.message : String(e)}`;
42
51
  }
43
52
  const problems = walletProblems(loaded, catalog);
@@ -145,13 +154,16 @@ export function createEarnrateServer(deps) {
145
154
  ? `; chosen categories for ${c.chosen.period}${c.chosen.current ? '' : ' (out of date)'}`
146
155
  : '';
147
156
  const onlyAt = c.onlyAt ? `; works only at ${c.onlyAt.join(', ')}` : '';
157
+ const rotation = c.rotationGap === null
158
+ ? ''
159
+ : `; rotating categories for ${c.rotationGap} not in earnrate yet`;
148
160
  const b = c.bonus;
149
161
  const bonus = b?.state === 'open' && b.spend !== null
150
162
  ? `; welcome bonus ${dollars(b.spent)} of ${dollars(b.spend)} spent, due ${b.deadline ?? ''}`
151
163
  : b
152
164
  ? `; welcome bonus ${b.state}`
153
165
  : '';
154
- return `- ${c.checkoutLabel ?? c.cardName} (${c.cardId}): ${c.currency} at ${String(c.centsPerUnit)}¢${caps.length > 0 ? `; ${caps.join(', ')}` : ''}${chosen}${onlyAt}${bonus}`;
166
+ return `- ${c.checkoutLabel ?? c.cardName} (${c.cardId}): ${c.currency} at ${String(c.centsPerUnit)}¢${caps.length > 0 ? `; ${caps.join(', ')}` : ''}${chosen}${rotation}${onlyAt}${bonus}`;
155
167
  });
156
168
  return {
157
169
  content: [{ type: 'text', text: lines.join('\n') }],
@@ -304,5 +316,149 @@ export function createEarnrateServer(deps) {
304
316
  structuredContent: output,
305
317
  };
306
318
  }));
319
+ const CardId = z
320
+ .string()
321
+ .min(1)
322
+ .describe('The card id from search_cards, e.g. chase-sapphire-preferred.');
323
+ const cardFields = {
324
+ checkoutLabel: z
325
+ .string()
326
+ .min(1)
327
+ .optional()
328
+ .describe('How the card appears at checkout or in a browser wallet, e.g. "Amex Gold". Optional.'),
329
+ picks: z
330
+ .record(z.string(), z.array(z.string()).min(1))
331
+ .optional()
332
+ .describe('For cards where the cardholder picks bonus categories: choice id → option ids, from search_cards, e.g. {"five-percent": ["tv-internet-streaming", "fast-food"]}. Saved for the current period. Ask the user; don\'t guess.'),
333
+ anniversary: z
334
+ .string()
335
+ .regex(/^\d{2}-\d{2}$/)
336
+ .optional()
337
+ .describe("The account's anniversary as MM-DD, for cards whose cap resets on it."),
338
+ activated: z
339
+ .array(z.string())
340
+ .optional()
341
+ .describe('Rule ids the user has activated with the issuer, such as rotating categories. Replaces the current list.'),
342
+ };
343
+ // A card change reports what that card still needs; other changes don't.
344
+ function changed(result, message, cardChange = true) {
345
+ if (!result.ok)
346
+ return fail(result.error);
347
+ const output = {
348
+ message,
349
+ created: result.created,
350
+ cardIds: result.wallet.cards.map((c) => c.id),
351
+ memberships: result.wallet.memberships,
352
+ notes: result.notes,
353
+ };
354
+ const text = [
355
+ message,
356
+ `Wallet: ${output.cardIds.join(', ')}.`,
357
+ ...(cardChange ? [formatNotes(result.notes)] : []),
358
+ ].join('\n');
359
+ return {
360
+ content: [{ type: 'text', text }],
361
+ structuredContent: output,
362
+ };
363
+ }
364
+ const cardInput = (args) => ({
365
+ id: args.cardId,
366
+ ...(args.checkoutLabel === undefined
367
+ ? {}
368
+ : { checkoutLabel: args.checkoutLabel }),
369
+ ...(args.picks === undefined ? {} : { picks: args.picks }),
370
+ ...(args.anniversary === undefined
371
+ ? {}
372
+ : { anniversary: args.anniversary }),
373
+ ...(args.activated === undefined ? {} : { activated: args.activated }),
374
+ });
375
+ const cardName = (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
376
+ server.registerTool('search_cards', {
377
+ title: 'Search cards',
378
+ description: 'Finds credit cards in earnrate\'s catalog by name or issuer, e.g. "sapphire", "amex gold" or "bofa". Returns each card\'s id (for add_card), network, annual fee, whether it\'s already in the wallet, and what it needs set up: category picks and their options, memberships, activations. Leave the query out to list every card. US cards only.',
379
+ inputSchema: z.object({
380
+ query: z
381
+ .string()
382
+ .optional()
383
+ .describe('Card name or issuer, as the user said it.'),
384
+ }),
385
+ outputSchema: CardSearchSchema,
386
+ annotations: { readOnlyHint: true, openWorldHint: false },
387
+ }, async ({ query }) => {
388
+ const held = await loadWallet(deps.walletFile).catch(() => null);
389
+ const result = searchCards(catalog, query ?? '', held, deps.today());
390
+ const header = result.exact
391
+ ? ''
392
+ : result.cards.length > 0
393
+ ? 'No card matched every word. Closest:\n'
394
+ : `No card matched "${query ?? ''}". earnrate covers ${String(catalog.cards.length)} US cards; list them all by leaving the query out.`;
395
+ return {
396
+ content: [
397
+ {
398
+ type: 'text',
399
+ text: header + result.cards.map(formatCardInfo).join('\n'),
400
+ },
401
+ ],
402
+ structuredContent: result,
403
+ };
404
+ });
405
+ server.registerTool('add_card', {
406
+ title: 'Add a card',
407
+ description: "Adds a card the user holds to their wallet by its id from search_cards, creating the wallet if there isn't one. The reply says what the card still needs (category picks, memberships, activations); ask the user about those. Stores only the card's product id and settings, never card numbers.",
408
+ inputSchema: z.object({ cardId: CardId, ...cardFields }),
409
+ outputSchema: WalletChangeSchema,
410
+ annotations: {
411
+ readOnlyHint: false,
412
+ destructiveHint: false,
413
+ idempotentHint: false,
414
+ openWorldHint: false,
415
+ },
416
+ }, (args) => withWalletLock(deps.walletFile, async () => {
417
+ const result = await addCard(deps.walletFile, catalog, cardInput(args), deps.today());
418
+ return changed(result, `Added ${cardName(args.cardId)} (${args.cardId})${result.ok && result.created ? `, starting a new wallet at ${deps.walletFile}` : ''}.`);
419
+ }));
420
+ server.registerTool('update_card', {
421
+ title: 'Update a card',
422
+ description: 'Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary or activated categories. Picks for the current period are merged with the ones already saved. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+.',
423
+ inputSchema: z.object({ cardId: CardId, ...cardFields }),
424
+ outputSchema: WalletChangeSchema,
425
+ annotations: {
426
+ readOnlyHint: false,
427
+ destructiveHint: false,
428
+ idempotentHint: true,
429
+ openWorldHint: false,
430
+ },
431
+ }, (args) => withWalletLock(deps.walletFile, async () => changed(await updateCard(deps.walletFile, catalog, cardInput(args), deps.today()), `Updated ${cardName(args.cardId)} (${args.cardId}).`)));
432
+ server.registerTool('remove_card', {
433
+ title: 'Remove a card',
434
+ description: "Removes a card from the wallet, with its tracked cap usage and bonus progress. Only when the user says they no longer have it or don't want it considered.",
435
+ inputSchema: z.object({ cardId: CardId }),
436
+ outputSchema: WalletChangeSchema,
437
+ annotations: {
438
+ readOnlyHint: false,
439
+ destructiveHint: true,
440
+ idempotentHint: true,
441
+ openWorldHint: false,
442
+ },
443
+ }, ({ cardId }) => withWalletLock(deps.walletFile, async () => changed(await removeCard(deps.walletFile, catalog, cardId), `Removed ${cardName(cardId)} (${cardId}).`, false)));
444
+ const memberships = knownMemberships(catalog);
445
+ server.registerTool('set_memberships', {
446
+ title: 'Set memberships',
447
+ description: "Sets the memberships that unlock some cards' rates, such as amazon-prime for Prime Visa's 5% or a Bank of America Preferred Rewards tier. Replaces the whole list, so include the ones to keep.",
448
+ inputSchema: z.object({
449
+ memberships: z
450
+ .array(z.enum(memberships))
451
+ .describe(`Every membership the user has. Known: ${memberships.join(', ')}.`),
452
+ }),
453
+ outputSchema: WalletChangeSchema,
454
+ annotations: {
455
+ readOnlyHint: false,
456
+ destructiveHint: false,
457
+ idempotentHint: true,
458
+ openWorldHint: false,
459
+ },
460
+ }, (args) => withWalletLock(deps.walletFile, async () => changed(await setMemberships(deps.walletFile, catalog, args.memberships), args.memberships.length > 0
461
+ ? `Memberships set to ${args.memberships.join(', ')}.`
462
+ : 'Memberships cleared.', false)));
307
463
  return server;
308
464
  }
@@ -12,7 +12,13 @@ export const CapSchema = z.strictObject({
12
12
  period: CapPeriod,
13
13
  then_rate: z.number().nonnegative(),
14
14
  });
15
- export const SharedCapSchema = CapSchema.extend({ id: KebabId });
15
+ export const SharedCapSchema = CapSchema.extend({
16
+ id: KebabId,
17
+ // Rotating categories (Freedom Flex): every rule on this cap covers one
18
+ // dated period, and a period with no rule means the issuer's categories
19
+ // aren't in the data yet, not that there's no bonus.
20
+ rotating: z.literal(true).optional(),
21
+ });
16
22
  const hasAny = (m) => Object.values(m).some((v) => v === true || (v !== undefined && v.length > 0));
17
23
  // Kinds combine with AND, values within a kind with OR: `{ merchants:
18
24
  // [lyft], categories: [transit] }` is a Lyft purchase that codes as transit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Picks the best credit card from your wallet for a merchant, and explains why",
5
5
  "keywords": [
6
6
  "credit cards",
@@ -21,7 +21,7 @@
21
21
  "mcpName": "io.github.cwasley/earnrate",
22
22
  "type": "module",
23
23
  "engines": {
24
- "node": ">=24"
24
+ "node": ">=22"
25
25
  },
26
26
  "bin": {
27
27
  "earnrate": "./dist/cli/bin.js",