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 +13 -7
- package/data/CHANGELOG.md +9 -0
- package/data/VERSION +1 -1
- package/data/cards/capital-one-savor.yaml +1 -1
- package/data/cards/capital-one-venture-x.yaml +1 -1
- package/data/cards/capital-one-venture.yaml +1 -1
- package/data/cards/chase-freedom-flex.yaml +1 -0
- package/dist/cli/cards.js +34 -0
- package/dist/cli/run.js +14 -2
- package/dist/cli/wallet.js +194 -0
- package/dist/data/integrity.js +14 -0
- package/dist/data/wallet-edit.js +162 -0
- package/dist/data/wallet-setup.js +202 -0
- package/dist/engine/recommend.js +9 -0
- package/dist/engine/rotation.js +16 -0
- package/dist/engine/setup.js +168 -0
- package/dist/engine/wallet-summary.js +2 -0
- package/dist/mcp/schemas.js +46 -0
- package/dist/mcp/server.js +159 -3
- package/dist/schema/card.js +7 -1
- package/package.json +2 -2
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
|
|
19
|
+
Needs Node 22 or later.
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
22
|
npm install -g earnrate
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1
|
+
2026.10.30.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
id: capital-one-venture
|
|
2
2
|
name: Capital One Venture Rewards Credit Card
|
|
3
|
-
issuer:
|
|
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
|
|
@@ -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
|
|
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)}.
|
|
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
|
+
}
|
package/dist/data/integrity.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/engine/recommend.js
CHANGED
|
@@ -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
|
? {
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
}
|
package/dist/schema/card.js
CHANGED
|
@@ -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({
|
|
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.
|
|
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
|
+
"node": ">=22"
|
|
25
25
|
},
|
|
26
26
|
"bin": {
|
|
27
27
|
"earnrate": "./dist/cli/bin.js",
|