earnrate 0.2.0 → 0.2.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 +23 -20
- package/data/cards/amex-blue-business-plus.yaml +1 -0
- package/data/cards/amex-blue-cash-everyday.yaml +4 -3
- package/data/cards/amex-blue-cash-preferred.yaml +5 -7
- package/data/cards/amex-gold.yaml +1 -0
- package/data/cards/amex-platinum.yaml +1 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +6 -6
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +6 -6
- package/data/cards/chase-freedom-flex.yaml +5 -1
- package/data/cards/chase-southwest-plus.yaml +5 -5
- package/data/cards/chase-southwest-premier-business.yaml +8 -5
- package/data/cards/chase-southwest-premier.yaml +6 -6
- package/data/cards/chase-southwest-priority.yaml +6 -6
- package/data/cards/citi-aadvantage-business.yaml +6 -6
- package/data/cards/citi-aadvantage-platinum-select.yaml +10 -7
- package/data/cards/citi-costco-anywhere-visa-business.yaml +4 -4
- package/dist/brand.js +2 -3
- package/dist/cli/args.js +64 -51
- package/dist/cli/cheatsheet.js +9 -12
- package/dist/cli/explore.js +28 -27
- package/dist/cli/import.js +3 -8
- package/dist/cli/offers.js +7 -6
- package/dist/cli/run.js +17 -13
- package/dist/cli/wallet.js +7 -6
- package/dist/data/file-store.js +54 -15
- package/dist/data/load.js +1 -1
- package/dist/data/store.js +0 -16
- package/dist/data/wallet-edit.js +78 -3
- package/dist/engine/bonus.js +15 -12
- package/dist/engine/cheat-sheet.js +2 -2
- package/dist/engine/evaluate.js +56 -13
- package/dist/engine/explore.js +9 -22
- package/dist/engine/offers.js +10 -22
- package/dist/engine/periods.js +9 -0
- package/dist/engine/recommend.js +14 -53
- package/dist/engine/record.js +2 -7
- package/dist/engine/setup.js +2 -2
- package/dist/engine/simulate.js +12 -19
- package/dist/engine/text.js +53 -0
- package/dist/engine/transient.js +5 -0
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +46 -19
- package/dist/engine/wallet-summary.js +4 -8
- package/dist/format/card-text.js +79 -0
- package/dist/format/cheat-sheet.js +1 -1
- package/dist/format/explore.js +3 -13
- package/dist/format/offers.js +15 -18
- package/dist/{cli/format.js → format/recommendation.js} +2 -6
- package/dist/format/wallet.js +57 -0
- package/dist/import/analyze.js +17 -19
- package/dist/mcp/context.js +92 -0
- package/dist/mcp/inputs.js +155 -0
- package/dist/mcp/schemas.js +2 -1
- package/dist/mcp/server.js +15 -819
- package/dist/mcp/tools/account.js +78 -0
- package/dist/mcp/tools/answers.js +338 -0
- package/dist/mcp/tools/wallet.js +128 -0
- package/dist/merchants/from-env.js +1 -1
- package/dist/merchants/resolve.js +3 -3
- package/dist/schema/card.js +4 -0
- package/package.json +3 -2
- package/dist/data/wallet-setup.js +0 -60
- package/dist/data/wallet-write.js +0 -140
- /package/dist/{cli → format}/cards.js +0 -0
package/dist/cli/args.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { isValuationProfile, } from "../engine/profiles.js";
|
|
3
|
+
import { knownMemberships } from "../engine/setup.js";
|
|
4
|
+
// Shared by every subcommand, so each flag means the same thing everywhere.
|
|
5
|
+
// A string result is the error to show.
|
|
3
6
|
const LOOKS_LIKE_AMOUNT = /^\$?[\d,]*\.?\d+$/;
|
|
4
7
|
const AMOUNT = /^\$?(\d{1,3}(,\d{3})+|\d+)(\.\d{1,2})?$/;
|
|
5
8
|
function parseAmount(text) {
|
|
@@ -9,11 +12,54 @@ function parseAmount(text) {
|
|
|
9
12
|
const amount = Number(text.replace(/[$,]/g, ''));
|
|
10
13
|
return amount > 0 ? amount : 'The amount must be more than $0.';
|
|
11
14
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
// A trailing number is the amount only when something comes before it, so
|
|
16
|
+
// a merchant named "76" still works on its own.
|
|
17
|
+
export function splitAmount(positionals) {
|
|
18
|
+
const words = [...positionals];
|
|
19
|
+
const last = words.at(-1);
|
|
20
|
+
let amount = null;
|
|
21
|
+
if (words.length > 1 && last !== undefined && LOOKS_LIKE_AMOUNT.test(last)) {
|
|
22
|
+
const result = parseAmount(last);
|
|
23
|
+
if (typeof result === 'string')
|
|
24
|
+
return result;
|
|
25
|
+
amount = result;
|
|
26
|
+
words.pop();
|
|
27
|
+
}
|
|
28
|
+
return { text: words.join(' ').trim(), amount };
|
|
29
|
+
}
|
|
30
|
+
export function dateProblem(text) {
|
|
31
|
+
if (text === undefined)
|
|
32
|
+
return null;
|
|
15
33
|
const date = new Date(`${text}T00:00:00Z`);
|
|
16
|
-
return
|
|
34
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(text) &&
|
|
35
|
+
!Number.isNaN(date.getTime()) &&
|
|
36
|
+
date.toISOString().startsWith(text)
|
|
37
|
+
? null
|
|
38
|
+
: `"${text}" isn't a date. Use YYYY-MM-DD.`;
|
|
39
|
+
}
|
|
40
|
+
export function valuationProblem(text) {
|
|
41
|
+
return text === undefined || isValuationProfile(text)
|
|
42
|
+
? null
|
|
43
|
+
: `Unknown valuation "${text}". Use flat or points-heavy.`;
|
|
44
|
+
}
|
|
45
|
+
export function membershipProblem(catalog, members) {
|
|
46
|
+
const known = knownMemberships(catalog);
|
|
47
|
+
const unknown = members.filter((m) => !known.includes(m));
|
|
48
|
+
return unknown.length === 0
|
|
49
|
+
? null
|
|
50
|
+
: `Unknown membership "${unknown.join('", "')}". Use one of: ${known.join(', ')}.`;
|
|
51
|
+
}
|
|
52
|
+
export const BOTH_CHANNELS = 'Pick --online or --in-store, not both.';
|
|
53
|
+
export function channelOf(values) {
|
|
54
|
+
return values.online ? 'online' : values['in-store'] ? 'in_store' : null;
|
|
55
|
+
}
|
|
56
|
+
export function cardList(text) {
|
|
57
|
+
return text === undefined
|
|
58
|
+
? null
|
|
59
|
+
: text
|
|
60
|
+
.split(',')
|
|
61
|
+
.map((c) => c.trim())
|
|
62
|
+
.filter((c) => c !== '');
|
|
17
63
|
}
|
|
18
64
|
export function parseCliArgs(argv) {
|
|
19
65
|
let parsed;
|
|
@@ -47,49 +93,21 @@ export function parseCliArgs(argv) {
|
|
|
47
93
|
const { values, positionals } = parsed;
|
|
48
94
|
if (values.help)
|
|
49
95
|
return { kind: 'help' };
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const last = words.at(-1);
|
|
55
|
-
if (words.length > 1 && last !== undefined && LOOKS_LIKE_AMOUNT.test(last)) {
|
|
56
|
-
const result = parseAmount(last);
|
|
57
|
-
if (typeof result === 'string')
|
|
58
|
-
return { kind: 'error', message: result };
|
|
59
|
-
amount = result;
|
|
60
|
-
words.pop();
|
|
61
|
-
}
|
|
62
|
-
const merchant = words.join(' ').trim();
|
|
96
|
+
const split = splitAmount(positionals);
|
|
97
|
+
if (typeof split === 'string')
|
|
98
|
+
return { kind: 'error', message: split };
|
|
99
|
+
const { text: merchant, amount } = split;
|
|
63
100
|
if (merchant === '') {
|
|
64
101
|
return { kind: 'error', message: 'Name a merchant, like "whole foods".' };
|
|
65
102
|
}
|
|
66
|
-
if (values.online && values['in-store'])
|
|
67
|
-
return {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
: values['in-store']
|
|
75
|
-
? 'in_store'
|
|
76
|
-
: null;
|
|
77
|
-
let valuation = null;
|
|
78
|
-
if (values.valuation !== undefined) {
|
|
79
|
-
if (!isValuationProfile(values.valuation)) {
|
|
80
|
-
return {
|
|
81
|
-
kind: 'error',
|
|
82
|
-
message: `Unknown valuation "${values.valuation}". Use flat or points-heavy.`,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
valuation = values.valuation;
|
|
86
|
-
}
|
|
87
|
-
if (values.date !== undefined && !isValidDate(values.date)) {
|
|
88
|
-
return {
|
|
89
|
-
kind: 'error',
|
|
90
|
-
message: `"${values.date}" isn't a date. Use YYYY-MM-DD.`,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
103
|
+
if (values.online && values['in-store'])
|
|
104
|
+
return { kind: 'error', message: BOTH_CHANNELS };
|
|
105
|
+
const problem = valuationProblem(values.valuation) ?? dateProblem(values.date);
|
|
106
|
+
if (problem !== null)
|
|
107
|
+
return { kind: 'error', message: problem };
|
|
108
|
+
const valuation = values.valuation !== undefined && isValuationProfile(values.valuation)
|
|
109
|
+
? values.valuation
|
|
110
|
+
: null;
|
|
93
111
|
const country = values.country ?? 'US';
|
|
94
112
|
if (!/^[A-Za-z]{2}$/.test(country)) {
|
|
95
113
|
return {
|
|
@@ -102,7 +120,7 @@ export function parseCliArgs(argv) {
|
|
|
102
120
|
options: {
|
|
103
121
|
merchant,
|
|
104
122
|
amount,
|
|
105
|
-
channel,
|
|
123
|
+
channel: channelOf(values),
|
|
106
124
|
valuation,
|
|
107
125
|
date: values.date ?? null,
|
|
108
126
|
country: country.toUpperCase(),
|
|
@@ -110,12 +128,7 @@ export function parseCliArgs(argv) {
|
|
|
110
128
|
all: values.all ?? false,
|
|
111
129
|
category: values.category ?? null,
|
|
112
130
|
llm: values.llm ?? null,
|
|
113
|
-
cards: values.cards
|
|
114
|
-
? null
|
|
115
|
-
: values.cards
|
|
116
|
-
.split(',')
|
|
117
|
-
.map((c) => c.trim())
|
|
118
|
-
.filter((c) => c !== ''),
|
|
131
|
+
cards: cardList(values.cards),
|
|
119
132
|
members: values.member ?? [],
|
|
120
133
|
},
|
|
121
134
|
};
|
package/dist/cli/cheatsheet.js
CHANGED
|
@@ -2,11 +2,12 @@ import { parseArgs } from 'node:util';
|
|
|
2
2
|
import { CLI_NAME } from "../brand.js";
|
|
3
3
|
import { loadCatalog, walletPath } from "../data/load.js";
|
|
4
4
|
import { catalogCheatSheet, cheatSheet } from "../engine/cheat-sheet.js";
|
|
5
|
-
import { formatCheatSheet } from "../format/cheat-sheet.js";
|
|
6
5
|
import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
|
|
7
6
|
import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
|
|
7
|
+
import { formatCheatSheet } from "../format/cheat-sheet.js";
|
|
8
|
+
import { cardList, dateProblem, valuationProblem } from "./args.js";
|
|
8
9
|
import { cardsToUse, NO_WALLET_HINT, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
|
|
9
|
-
|
|
10
|
+
const CHEATSHEET_HELP = `Usage: ${CLI_NAME} cheatsheet [options]
|
|
10
11
|
|
|
11
12
|
The card to use for each everyday kind of purchase this quarter: from your
|
|
12
13
|
wallet (with your caps, picks and activations as they are now), from cards you
|
|
@@ -44,21 +45,18 @@ export async function runCheatSheet(argv, io) {
|
|
|
44
45
|
io.stdout(CHEATSHEET_HELP);
|
|
45
46
|
return 0;
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
if (values.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(values.date)) {
|
|
52
|
-
io.stderr(`"${values.date}" isn't a date. Use YYYY-MM-DD.\n`);
|
|
48
|
+
const problem = valuationProblem(values.valuation) ?? dateProblem(values.date);
|
|
49
|
+
if (problem !== null) {
|
|
50
|
+
io.stderr(`${problem}\n`);
|
|
53
51
|
return USAGE_ERROR;
|
|
54
52
|
}
|
|
55
|
-
const
|
|
56
|
-
|
|
53
|
+
const catalog = await loadCatalog();
|
|
54
|
+
const loaded = await cardsToUse(catalog, walletPath(io.env), io, {
|
|
55
|
+
cards: cardList(values.cards),
|
|
57
56
|
members: values.member ?? [],
|
|
58
57
|
});
|
|
59
58
|
if (loaded === null)
|
|
60
59
|
return RUNTIME_ERROR;
|
|
61
|
-
const catalog = await loadCatalog();
|
|
62
60
|
const date = values.date ?? io.today;
|
|
63
61
|
if (loaded === 'none') {
|
|
64
62
|
const sheet = catalogCheatSheet(catalog, date, {
|
|
@@ -82,4 +80,3 @@ export async function runCheatSheet(argv, io) {
|
|
|
82
80
|
: formatCheatSheet(sheet));
|
|
83
81
|
return 0;
|
|
84
82
|
}
|
|
85
|
-
export { formatCheatSheet };
|
package/dist/cli/explore.js
CHANGED
|
@@ -4,8 +4,8 @@ import { loadCatalog, walletPath } from "../data/load.js";
|
|
|
4
4
|
import { ALL_PERSONAL_CARDS, exploreCards, findCategory, } from "../engine/explore.js";
|
|
5
5
|
import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
|
|
6
6
|
import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, TYPICAL_MONTHLY_SPEND, TYPICAL_SPEND_NOTE, } from "../engine/simulate.js";
|
|
7
|
-
import { knownMemberships } from "../engine/setup.js";
|
|
8
7
|
import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../format/explore.js";
|
|
8
|
+
import { BOTH_CHANNELS, cardList, channelOf, dateProblem, membershipProblem, splitAmount, valuationProblem, } from "./args.js";
|
|
9
9
|
import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
|
|
10
10
|
const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or points and miles cards only.
|
|
11
11
|
--no-fee Cards with no annual fee.
|
|
@@ -15,7 +15,7 @@ const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or point
|
|
|
15
15
|
--member <name> A membership you have, e.g. amazon-prime. Repeatable.
|
|
16
16
|
--valuation <profile> flat (points at 1¢, the default) or points-heavy
|
|
17
17
|
(MR and UR at 1.7¢).`;
|
|
18
|
-
|
|
18
|
+
const BEST_HELP = `Usage: ${CLI_NAME} best <merchant or category> [amount] [options]
|
|
19
19
|
|
|
20
20
|
Ranks every card in earnrate's catalog for one purchase, not just yours. Each
|
|
21
21
|
card is set up at its best: the right category pick, activated, cap unused.
|
|
@@ -33,7 +33,7 @@ ${FILTER_OPTIONS}
|
|
|
33
33
|
--json Print the full result as JSON.
|
|
34
34
|
-h, --help Show this help.
|
|
35
35
|
`;
|
|
36
|
-
|
|
36
|
+
const SIMULATE_HELP = `Usage: ${CLI_NAME} simulate [<what>=<dollars a month>...] [options]
|
|
37
37
|
|
|
38
38
|
Spends this much every month for a year and ranks the cards, and the
|
|
39
39
|
two-card pairs, that earn the most after annual fees. Each <what> is a
|
|
@@ -70,15 +70,13 @@ const FILTER_FLAGS = {
|
|
|
70
70
|
function settingsFrom(values, catalog) {
|
|
71
71
|
if (values.cash && values.points)
|
|
72
72
|
return 'Pick --cash or --points, not both.';
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
const problem = valuationProblem(values.valuation) ??
|
|
74
|
+
membershipProblem(catalog, values.member ?? []);
|
|
75
|
+
if (problem !== null)
|
|
76
|
+
return problem;
|
|
75
77
|
const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
|
|
76
78
|
if (values.issuer !== undefined && !issuers.includes(values.issuer))
|
|
77
79
|
return `Unknown issuer "${values.issuer}". Use one of: ${issuers.join(', ')}.`;
|
|
78
|
-
const known = knownMemberships(catalog);
|
|
79
|
-
const unknown = (values.member ?? []).filter((m) => !known.includes(m));
|
|
80
|
-
if (unknown.length > 0)
|
|
81
|
-
return `Unknown membership "${unknown.join('", "')}". Use one of: ${known.join(', ')}.`;
|
|
82
80
|
return {
|
|
83
81
|
filters: {
|
|
84
82
|
...ALL_PERSONAL_CARDS,
|
|
@@ -117,20 +115,23 @@ export async function runBest(argv, io) {
|
|
|
117
115
|
io.stdout(BEST_HELP);
|
|
118
116
|
return 0;
|
|
119
117
|
}
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
words.pop();
|
|
127
|
-
const text = words.join(' ').trim();
|
|
118
|
+
const split = splitAmount(positionals);
|
|
119
|
+
if (typeof split === 'string') {
|
|
120
|
+
io.stderr(`${split}\n`);
|
|
121
|
+
return USAGE_ERROR;
|
|
122
|
+
}
|
|
123
|
+
const { text, amount } = split;
|
|
128
124
|
if (text === '') {
|
|
129
125
|
io.stderr(`Say what you're buying, e.g. ${CLI_NAME} best groceries.\n`);
|
|
130
126
|
return USAGE_ERROR;
|
|
131
127
|
}
|
|
132
128
|
if (values.online && values['in-store']) {
|
|
133
|
-
io.stderr(
|
|
129
|
+
io.stderr(`${BOTH_CHANNELS}\n`);
|
|
130
|
+
return USAGE_ERROR;
|
|
131
|
+
}
|
|
132
|
+
const dated = dateProblem(values.date);
|
|
133
|
+
if (dated !== null) {
|
|
134
|
+
io.stderr(`${dated}\n`);
|
|
134
135
|
return USAGE_ERROR;
|
|
135
136
|
}
|
|
136
137
|
const catalog = await loadCatalog();
|
|
@@ -144,11 +145,7 @@ export async function runBest(argv, io) {
|
|
|
144
145
|
merchant: category === null ? text : null,
|
|
145
146
|
category,
|
|
146
147
|
amount,
|
|
147
|
-
channel: values
|
|
148
|
-
? 'online'
|
|
149
|
-
: values['in-store']
|
|
150
|
-
? 'in_store'
|
|
151
|
-
: null,
|
|
148
|
+
channel: channelOf(values),
|
|
152
149
|
date: values.date ?? io.today,
|
|
153
150
|
}, settings);
|
|
154
151
|
io.stdout(values.json
|
|
@@ -156,7 +153,7 @@ export async function runBest(argv, io) {
|
|
|
156
153
|
: formatExploration(result, values.all ?? false));
|
|
157
154
|
return 0;
|
|
158
155
|
}
|
|
159
|
-
|
|
156
|
+
function parseSpend(catalog, args) {
|
|
160
157
|
const lines = [];
|
|
161
158
|
for (const arg of args) {
|
|
162
159
|
const match = /^(.+)=\$?(\d+(?:\.\d+)?)$/.exec(arg);
|
|
@@ -217,10 +214,15 @@ export async function runSimulate(argv, io) {
|
|
|
217
214
|
io.stderr(`Not a category or a merchant earnrate knows: ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Categories include groceries, dining, gas, travel, streaming and other.\n`);
|
|
218
215
|
return USAGE_ERROR;
|
|
219
216
|
}
|
|
217
|
+
const started = dateProblem(values.start);
|
|
218
|
+
if (started !== null) {
|
|
219
|
+
io.stderr(`${started}\n`);
|
|
220
|
+
return USAGE_ERROR;
|
|
221
|
+
}
|
|
220
222
|
const options = { ...settings, start: values.start ?? io.today };
|
|
221
223
|
if (values.wallet || values.cards !== undefined) {
|
|
222
|
-
const wallet = await cardsToUse(walletPath(io.env), io, {
|
|
223
|
-
cards: values.cards
|
|
224
|
+
const wallet = await cardsToUse(catalog, walletPath(io.env), io, {
|
|
225
|
+
cards: cardList(values.cards),
|
|
224
226
|
members: values.member ?? [],
|
|
225
227
|
});
|
|
226
228
|
if (wallet === null)
|
|
@@ -244,4 +246,3 @@ export async function runSimulate(argv, io) {
|
|
|
244
246
|
: `${typical ? `${TYPICAL_SPEND_NOTE}\n\n` : ''}${formatCatalogSimulation(result, catalog, values.all ?? false)}`);
|
|
245
247
|
return 0;
|
|
246
248
|
}
|
|
247
|
-
export { formatAdditions, formatCatalogSimulation, formatExploration };
|
package/dist/cli/import.js
CHANGED
|
@@ -6,13 +6,13 @@ import { z } from 'zod';
|
|
|
6
6
|
import { CLI_NAME } from "../brand.js";
|
|
7
7
|
import { walletProblems } from "../data/integrity.js";
|
|
8
8
|
import { loadCatalog, walletPath } from "../data/load.js";
|
|
9
|
-
import {
|
|
9
|
+
import { writeProgress } from "../data/wallet-edit.js";
|
|
10
10
|
import { isValuationProfile, withValuationProfile } from "../engine/profiles.js";
|
|
11
11
|
import { cardValuation } from "../engine/valuation.js";
|
|
12
12
|
import { analyzeStatement, } from "../import/analyze.js";
|
|
13
13
|
import { parseStatement } from "../import/statement.js";
|
|
14
14
|
import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
|
|
15
|
-
|
|
15
|
+
const IMPORT_HELP = `Usage: ${CLI_NAME} import <statement.csv> --card <card-id> [--apply]
|
|
16
16
|
${CLI_NAME} import <statement.csv>:<card-id> [<statement.csv>:<card-id> ...]
|
|
17
17
|
|
|
18
18
|
Reads card statements (Amex and Chase activity CSVs) and reports:
|
|
@@ -241,12 +241,7 @@ export async function runImport(argv, io) {
|
|
|
241
241
|
: `Nothing was written. Run again with --apply to record ${toRecord.join(' and ')}.\n`);
|
|
242
242
|
return 0;
|
|
243
243
|
}
|
|
244
|
-
|
|
245
|
-
await writeCapBooking(walletFile, booking);
|
|
246
|
-
}
|
|
247
|
-
for (const b of analysis.bonuses) {
|
|
248
|
-
await writeBonusSpent(walletFile, b.cardId, b.after);
|
|
249
|
-
}
|
|
244
|
+
await writeProgress(walletFile, analysis.bookings.map((e) => e.booking), analysis.bonuses);
|
|
250
245
|
for (const entry of analysis.bookings)
|
|
251
246
|
recorded.add(fingerprint(entry));
|
|
252
247
|
for (const entry of bonusEntries)
|
package/dist/cli/offers.js
CHANGED
|
@@ -4,8 +4,9 @@ import { loadCatalog, walletPath } from "../data/load.js";
|
|
|
4
4
|
import { compareOffers } from "../engine/offers.js";
|
|
5
5
|
import { isValuationProfile, withValuationProfile } from "../engine/profiles.js";
|
|
6
6
|
import { formatOffers } from "../format/offers.js";
|
|
7
|
+
import { valuationProblem } from "./args.js";
|
|
7
8
|
import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
|
|
8
|
-
|
|
9
|
+
const OFFERS_HELP = `Usage: ${CLI_NAME} offers [--cash | --points] [--no-fee] [--business] [options]
|
|
9
10
|
|
|
10
11
|
Ranks the welcome offers on cards you don't have by first-year value: the
|
|
11
12
|
bonus at your point values minus the first year's annual fee.
|
|
@@ -52,17 +53,18 @@ export async function runOffers(argv, io) {
|
|
|
52
53
|
io.stderr('Pick --cash or --points, not both.\n');
|
|
53
54
|
return USAGE_ERROR;
|
|
54
55
|
}
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
const problem = valuationProblem(values.valuation);
|
|
57
|
+
if (problem !== null) {
|
|
58
|
+
io.stderr(`${problem}\n`);
|
|
57
59
|
return USAGE_ERROR;
|
|
58
60
|
}
|
|
59
|
-
const
|
|
61
|
+
const catalog = await loadCatalog();
|
|
62
|
+
const found = await cardsToUse(catalog, walletPath(io.env), io, {
|
|
60
63
|
cards: null,
|
|
61
64
|
members: [],
|
|
62
65
|
});
|
|
63
66
|
if (found === null)
|
|
64
67
|
return RUNTIME_ERROR;
|
|
65
|
-
const catalog = await loadCatalog();
|
|
66
68
|
const loaded = found === 'none' ? { valuations: {}, memberships: [], cards: [] } : found;
|
|
67
69
|
const wallet = values.valuation && isValuationProfile(values.valuation)
|
|
68
70
|
? withValuationProfile(loaded, values.valuation)
|
|
@@ -81,4 +83,3 @@ export async function runOffers(argv, io) {
|
|
|
81
83
|
io.stdout(formatOffers(comparison, currencyName, values.all ?? false));
|
|
82
84
|
return 0;
|
|
83
85
|
}
|
|
84
|
-
export { formatOffers };
|
package/dist/cli/run.js
CHANGED
|
@@ -5,23 +5,24 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { CLI_NAME, CONFIG_DIR, LLM_ENV_VAR, LLM_MAX_CALLS_ENV_VAR, LLM_MODEL_ENV_VAR, MERCHANT_CACHE_ENV_VAR, WALLET_ENV_VAR, } from "../brand.js";
|
|
6
6
|
import { walletProblems } from "../data/integrity.js";
|
|
7
7
|
import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
|
|
8
|
-
import { VALUATION_PROFILES, withValuationProfile } from "../engine/profiles.js";
|
|
9
8
|
import { ALL_PERSONAL_CARDS, categoryResolution, exploreCards, findCategory, } from "../engine/explore.js";
|
|
9
|
+
import { VALUATION_PROFILES, withValuationProfile } from "../engine/profiles.js";
|
|
10
10
|
import { recommend } from "../engine/recommend.js";
|
|
11
11
|
import { walletFromCards } from "../engine/transient.js";
|
|
12
|
+
import { formatExploration } from "../format/explore.js";
|
|
13
|
+
import { formatRecommendation, plain, } from "../format/recommendation.js";
|
|
12
14
|
import { DEFAULT_LLM_MODEL } from "../merchants/anthropic.js";
|
|
13
15
|
import { DEFAULT_MAX_CALLS } from "../merchants/fallback.js";
|
|
14
16
|
import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
|
|
15
17
|
import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
|
|
16
18
|
import { packageVersion } from "../version.js";
|
|
17
|
-
import { parseCliArgs } from "./args.js";
|
|
19
|
+
import { membershipProblem, parseCliArgs } from "./args.js";
|
|
18
20
|
import { runCheatSheet } from "./cheatsheet.js";
|
|
19
|
-
import {
|
|
21
|
+
import { runBest, runSimulate } from "./explore.js";
|
|
20
22
|
import { runImport } from "./import.js";
|
|
21
23
|
import { runOffers } from "./offers.js";
|
|
22
24
|
import { runWalletCommand, WALLET_HELP } from "./wallet.js";
|
|
23
|
-
|
|
24
|
-
export const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
|
|
25
|
+
const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
|
|
25
26
|
|
|
26
27
|
Picks the card that earns the most at a merchant, and says why: from your
|
|
27
28
|
wallet if you've saved one, from cards you name with --cards, or else from
|
|
@@ -36,7 +37,7 @@ Compare every card, no wallet needed:
|
|
|
36
37
|
${CLI_NAME} best <merchant or category> Rank all cards for one purchase.
|
|
37
38
|
${CLI_NAME} simulate groceries=600 dining=300 Best cards and pairs for a year of spending.
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
Save your cards (optional):
|
|
40
41
|
${CLI_NAME} cards [search words] Find cards and their ids.
|
|
41
42
|
${CLI_NAME} add <card-id> [--pick ...] Add a card you hold.
|
|
42
43
|
${CLI_NAME} update <card-id> [--pick ...] Change picks, label or activations.
|
|
@@ -44,10 +45,10 @@ Set up your wallet:
|
|
|
44
45
|
${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime).
|
|
45
46
|
|
|
46
47
|
Other commands:
|
|
47
|
-
${CLI_NAME} cheatsheet
|
|
48
|
+
${CLI_NAME} cheatsheet The best card for each category this quarter.
|
|
48
49
|
${CLI_NAME} import <statement.csv> --card <id> Replay a statement against your wallet.
|
|
49
50
|
${CLI_NAME} offers [--cash | --points] Rank welcome offers on cards you don't have.
|
|
50
|
-
${CLI_NAME} mcp Run the MCP server over stdio, for AI
|
|
51
|
+
${CLI_NAME} mcp Run the MCP server over stdio, for AI assistants.
|
|
51
52
|
Add --help to any command for its options.
|
|
52
53
|
|
|
53
54
|
Options:
|
|
@@ -85,8 +86,12 @@ export function displayPath(file) {
|
|
|
85
86
|
}
|
|
86
87
|
// The cards to answer for: those named with --cards, else the wallet file.
|
|
87
88
|
// 'none' means no wallet and no --cards, which answers from every card.
|
|
88
|
-
export async function cardsToUse(file, io, named) {
|
|
89
|
-
const
|
|
89
|
+
export async function cardsToUse(catalog, file, io, named) {
|
|
90
|
+
const problem = membershipProblem(catalog, named.members);
|
|
91
|
+
if (problem !== null) {
|
|
92
|
+
io.stderr(`${problem}\n`);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
90
95
|
if (named.cards !== null) {
|
|
91
96
|
const wallet = walletFromCards(catalog, {
|
|
92
97
|
cards: named.cards.map((id) => ({ id })),
|
|
@@ -159,11 +164,10 @@ export async function run(argv, io) {
|
|
|
159
164
|
}
|
|
160
165
|
const { options } = args;
|
|
161
166
|
const llmOn = options.llm ?? llmEnabled(io.env);
|
|
162
|
-
const
|
|
163
|
-
const loaded = await cardsToUse(
|
|
167
|
+
const catalog = await loadCatalog();
|
|
168
|
+
const loaded = await cardsToUse(catalog, walletPath(io.env), io, options);
|
|
164
169
|
if (loaded === null)
|
|
165
170
|
return RUNTIME_ERROR;
|
|
166
|
-
const catalog = await loadCatalog();
|
|
167
171
|
const purchase = {
|
|
168
172
|
merchant: options.merchant,
|
|
169
173
|
amount: options.amount,
|
package/dist/cli/wallet.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { CLI_NAME } from "../brand.js";
|
|
3
|
+
import { FileWalletStore } from "../data/file-store.js";
|
|
3
4
|
import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
|
|
4
|
-
import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
|
|
5
5
|
import { knownMemberships, searchCards } from "../engine/setup.js";
|
|
6
|
-
import { formatCardInfo, formatNotes } from "
|
|
6
|
+
import { formatCardInfo, formatNotes } from "../format/cards.js";
|
|
7
7
|
import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
|
|
8
8
|
const CARD_OPTIONS = `Options:
|
|
9
9
|
--label <text> How the card appears at checkout, e.g. "Amex Gold".
|
|
@@ -148,6 +148,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
148
148
|
const catalog = await loadCatalog();
|
|
149
149
|
const file = walletPath(io.env);
|
|
150
150
|
const where = displayPath(file);
|
|
151
|
+
const store = new FileWalletStore(catalog, file);
|
|
151
152
|
if (command === 'cards') {
|
|
152
153
|
const query = positionals.join(' ');
|
|
153
154
|
const result = searchCards(catalog, query, await heldWallet(io), io.today);
|
|
@@ -169,7 +170,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
169
170
|
return usage('Give memberships or --none, not both.');
|
|
170
171
|
}
|
|
171
172
|
const list = values.none ? [] : positionals;
|
|
172
|
-
return report(await setMemberships(
|
|
173
|
+
return report(await store.setMemberships(list), list.length > 0
|
|
173
174
|
? `Memberships set to ${list.join(', ')} in ${where}.`
|
|
174
175
|
: `Memberships cleared in ${where}.`, null, io);
|
|
175
176
|
}
|
|
@@ -179,7 +180,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
179
180
|
const id = positionals[0] ?? '';
|
|
180
181
|
const name = catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
181
182
|
if (command === 'remove') {
|
|
182
|
-
return report(await removeCard(
|
|
183
|
+
return report(await store.removeCard(id), `Removed ${name} from ${where}.`, null, io);
|
|
183
184
|
}
|
|
184
185
|
const input = { id };
|
|
185
186
|
if (values.label !== undefined)
|
|
@@ -227,8 +228,8 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
227
228
|
return usage('Give --bonus-opened with the other --bonus options.');
|
|
228
229
|
}
|
|
229
230
|
if (command === 'add') {
|
|
230
|
-
const result = await addCard(
|
|
231
|
+
const result = await store.addCard(input, io.today);
|
|
231
232
|
return report(result, `Added ${name} to ${where}${result.ok && result.created ? ' (a new wallet)' : ''}.`, id, io);
|
|
232
233
|
}
|
|
233
|
-
return report(await updateCard(
|
|
234
|
+
return report(await store.updateCard(input, io.today), `Updated ${name} in ${where}.`, id, io);
|
|
234
235
|
}
|
package/dist/data/file-store.js
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import { bookPurchase } from "../engine/record.js";
|
|
2
|
+
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
|
|
2
3
|
import { loadWallet } from "./load.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, updateCardText, writeProgress, } from "./wallet-edit.js";
|
|
5
|
+
const failed = (error) => ({ ok: false, error });
|
|
6
|
+
const errorText = (e) => (e instanceof Error ? e.message : String(e));
|
|
7
|
+
const queues = new Map();
|
|
8
|
+
// Runs `task` after every earlier task for the same file has settled, so a
|
|
9
|
+
// read-modify-write of the wallet never interleaves with another.
|
|
10
|
+
function withWalletLock(file, task) {
|
|
11
|
+
const previous = queues.get(file) ?? Promise.resolve();
|
|
12
|
+
const run = previous.then(task, task);
|
|
13
|
+
queues.set(file, run.catch(() => undefined));
|
|
14
|
+
return run;
|
|
15
|
+
}
|
|
16
|
+
// The wallet as a YAML file (the CLI, the local MCP server and the .mcpb).
|
|
17
|
+
// Each change is worked out on the parsed wallet, then spliced into the text,
|
|
18
|
+
// which must re-parse to exactly that wallet before it's written, so the
|
|
19
|
+
// user's comments survive.
|
|
7
20
|
export class FileWalletStore {
|
|
8
21
|
where;
|
|
9
22
|
catalog;
|
|
@@ -21,34 +34,60 @@ export class FileWalletStore {
|
|
|
21
34
|
throw e;
|
|
22
35
|
}
|
|
23
36
|
}
|
|
37
|
+
change(plan, edit) {
|
|
38
|
+
return withWalletLock(this.where, async () => {
|
|
39
|
+
try {
|
|
40
|
+
const { text, wallet } = await readWalletText(this.where);
|
|
41
|
+
const change = plan(wallet);
|
|
42
|
+
if (typeof change === 'string')
|
|
43
|
+
return failed(change);
|
|
44
|
+
if (!change.ok)
|
|
45
|
+
return change;
|
|
46
|
+
await commitWalletText(this.where, edit(text, change), change.wallet);
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
wallet: change.wallet,
|
|
50
|
+
created: text === null,
|
|
51
|
+
notes: change.notes,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
return failed(errorText(e));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// Changes other than adding a card need a wallet file to change.
|
|
60
|
+
existing(apply) {
|
|
61
|
+
return (wallet) => wallet === null
|
|
62
|
+
? `No wallet at ${this.where} yet. Add a card first to create it.`
|
|
63
|
+
: apply(wallet);
|
|
64
|
+
}
|
|
24
65
|
addCard(input, today) {
|
|
25
|
-
return
|
|
66
|
+
return this.change((wallet) => addToWallet(this.catalog, wallet, input, today), (text, change) => addCardText(text, { id: input.id, ...change.fields }));
|
|
26
67
|
}
|
|
27
68
|
updateCard(input, today) {
|
|
28
|
-
return
|
|
69
|
+
return this.change(this.existing((w) => updateInWallet(this.catalog, w, input, today)), (text, change) => updateCardText(text ?? '', input.id, change.fields));
|
|
29
70
|
}
|
|
30
71
|
removeCard(cardId) {
|
|
31
|
-
return
|
|
72
|
+
return this.change(this.existing((w) => removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
|
|
32
73
|
}
|
|
33
74
|
setMemberships(memberships) {
|
|
34
|
-
return
|
|
75
|
+
return this.change(this.existing((w) => withMemberships(this.catalog, w, memberships)), (text, change) => setMembershipsText(text ?? '', change.wallet.memberships));
|
|
35
76
|
}
|
|
36
77
|
record(purchase, merchant) {
|
|
37
78
|
return withWalletLock(this.where, async () => {
|
|
38
79
|
const wallet = await this.load();
|
|
39
80
|
if (!wallet)
|
|
40
|
-
return
|
|
81
|
+
return failed("There's no saved wallet to record it in.");
|
|
41
82
|
if (!wallet.cards.some((c) => c.id === purchase.cardId))
|
|
42
|
-
return
|
|
83
|
+
return failed(`${purchase.cardId} isn't in the wallet.`);
|
|
43
84
|
const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
|
|
44
85
|
try {
|
|
45
|
-
if (booked.cap)
|
|
46
|
-
await
|
|
47
|
-
if (booked.bonus)
|
|
48
|
-
await writeBonusSpent(this.where, purchase.cardId, booked.bonus.after);
|
|
86
|
+
if (booked.cap || booked.bonus)
|
|
87
|
+
await writeProgress(this.where, booked.cap ? [booked.cap] : [], booked.bonus ? [booked.bonus] : []);
|
|
49
88
|
}
|
|
50
89
|
catch (e) {
|
|
51
|
-
return
|
|
90
|
+
return failed(errorText(e));
|
|
52
91
|
}
|
|
53
92
|
return { ok: true, ...booked };
|
|
54
93
|
});
|
package/dist/data/load.js
CHANGED
|
@@ -11,7 +11,7 @@ import { CurrenciesFileSchema } from "../schema/currency.js";
|
|
|
11
11
|
import { MerchantsFileSchema } from "../schema/merchant.js";
|
|
12
12
|
import { WalletSchema } from "../schema/wallet.js";
|
|
13
13
|
export const DATA_DIR = fileURLToPath(new URL('../../data/', import.meta.url));
|
|
14
|
-
|
|
14
|
+
async function readYaml(file, schema) {
|
|
15
15
|
const raw = parse(await readFile(file, 'utf8'));
|
|
16
16
|
const result = schema.safeParse(raw);
|
|
17
17
|
if (!result.success) {
|