earnrate 0.2.3 → 0.2.4
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 +12 -11
- package/data/CHANGELOG.md +35 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-cash-everyday.yaml +5 -4
- package/data/cards/chase-freedom-flex.yaml +1 -1
- package/data/cards/chase-southwest-plus.yaml +4 -0
- package/data/cards/chase-southwest-premier.yaml +4 -0
- package/data/cards/chase-southwest-priority.yaml +4 -0
- package/data/cards/citi-aadvantage-globe.yaml +7 -7
- package/data/cards/citi-costco-anywhere-visa-business.yaml +15 -1
- package/data/cards/citi-costco-anywhere-visa.yaml +15 -1
- package/data/merchants/overrides.yaml +8 -0
- package/data/merchants/seed.yaml +0 -6
- package/dist/cli/cheatsheet.js +1 -1
- package/dist/cli/explore.js +1 -1
- package/dist/cli/import.js +4 -3
- package/dist/cli/run.js +1 -1
- package/dist/data/integrity.js +10 -0
- package/dist/data/wallet-edit.js +8 -2
- package/dist/engine/cheat-sheet.js +44 -2
- package/dist/engine/explore.js +14 -5
- package/dist/engine/memberships.js +45 -0
- package/dist/engine/offers.js +22 -2
- package/dist/engine/recommend.js +78 -31
- package/dist/engine/setup.js +22 -3
- package/dist/engine/simulate.js +32 -10
- package/dist/engine/text.js +51 -0
- package/dist/engine/wallet-change.js +25 -4
- package/dist/engine/wallet-summary.js +62 -34
- package/dist/format/card-text.js +7 -13
- package/dist/format/cards.js +7 -8
- package/dist/format/explore.js +17 -8
- package/dist/format/offers.js +5 -9
- package/dist/format/recommendation.js +11 -5
- package/dist/format/wallet-yaml.js +18 -3
- package/dist/format/wallet.js +12 -19
- package/dist/mcp/inputs.js +2 -2
- package/dist/mcp/schemas.js +13 -0
- package/dist/mcp/tools/account.js +27 -9
- package/dist/mcp/tools/answers.js +24 -7
- package/dist/mcp/tools/wallet.js +49 -7
- package/dist/schema/card.js +14 -0
- package/dist/schema/wallet.js +4 -0
- package/package.json +1 -1
- package/wallet.example.yaml +1 -0
|
@@ -4,49 +4,40 @@ import { daysBetween } from "./periods.js";
|
|
|
4
4
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
5
5
|
import { rotationGap } from "./rotation.js";
|
|
6
6
|
import { activationRules } from "./setup.js";
|
|
7
|
-
import { categoryPhrase, earnText } from "./text.js";
|
|
7
|
+
import { categoryPhrase, choiceName, currencyLabel, earnText, listed, words, } from "./text.js";
|
|
8
8
|
import { cardValuation } from "./valuation.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
three: '3',
|
|
13
|
-
four: '4',
|
|
14
|
-
five: '5',
|
|
15
|
-
six: '6',
|
|
16
|
-
};
|
|
17
|
-
// "five-percent" reads "5%"; other ids read as words.
|
|
18
|
-
export function choiceName(id) {
|
|
19
|
-
return id
|
|
20
|
-
.replace(/\b(one|two|three|four|five|six)-percent\b/g, (_, n) => `${NUMBERS[n] ?? n}%`)
|
|
21
|
-
.replace(/[-_.]/g, ' ');
|
|
22
|
-
}
|
|
23
|
-
const listed = (parts) => parts.length <= 2
|
|
24
|
-
? parts.join(' and ')
|
|
25
|
-
: `${parts.slice(0, -1).join(', ')} and ${parts.at(-1) ?? ''}`;
|
|
9
|
+
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
10
|
+
// lower case and plural; brand names keep theirs.
|
|
11
|
+
const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
|
|
26
12
|
function merchantName(catalog, id) {
|
|
27
13
|
const all = [...catalog.merchants.overrides, ...catalog.merchants.seed];
|
|
28
|
-
|
|
14
|
+
const name = all.find((m) => m.id === id)?.name ?? words(id);
|
|
15
|
+
return name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`);
|
|
29
16
|
}
|
|
30
17
|
function optionName(catalog, card, choiceId, option) {
|
|
31
18
|
const o = card.choices.find((c) => c.id === choiceId)?.options[option];
|
|
32
19
|
if (!o)
|
|
33
|
-
return option
|
|
20
|
+
return words(option);
|
|
34
21
|
const parts = [
|
|
35
22
|
...o.categories.map((c) => categoryPhrase(catalog, c)),
|
|
36
23
|
...o.merchants.map((m) => merchantName(catalog, m)),
|
|
37
24
|
];
|
|
38
25
|
if (o.online)
|
|
39
26
|
parts.push('online shopping');
|
|
40
|
-
return parts.length > 0 ? listed(parts) : option
|
|
27
|
+
return parts.length > 0 ? listed(parts) : words(option);
|
|
41
28
|
}
|
|
42
|
-
|
|
29
|
+
// What a rule covers, as a list of phrases.
|
|
30
|
+
function ruleParts(catalog, rule) {
|
|
43
31
|
if (rule.match.choice !== undefined)
|
|
44
|
-
return `your ${choiceName(rule.match.choice)} picks
|
|
32
|
+
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
45
33
|
const parts = [
|
|
46
34
|
...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
47
35
|
...(rule.match.merchants ?? []).map((m) => merchantName(catalog, m)),
|
|
48
36
|
];
|
|
49
|
-
return parts.length > 0 ?
|
|
37
|
+
return parts.length > 0 ? parts : [words(rule.id)];
|
|
38
|
+
}
|
|
39
|
+
function ruleWhat(catalog, rule) {
|
|
40
|
+
return listed(ruleParts(catalog, rule));
|
|
50
41
|
}
|
|
51
42
|
function periodEnd(period) {
|
|
52
43
|
const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
|
|
@@ -63,14 +54,45 @@ function periodEnd(period) {
|
|
|
63
54
|
}
|
|
64
55
|
const applies = (rule, today) => (rule.valid_from === undefined || rule.valid_from <= today) &&
|
|
65
56
|
(rule.valid_to === undefined || rule.valid_to >= today);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
.
|
|
70
|
-
.
|
|
71
|
-
|
|
57
|
+
const matchKeys = (rule) => rule.match.choice !== undefined
|
|
58
|
+
? []
|
|
59
|
+
: [
|
|
60
|
+
...(rule.match.categories ?? []).map((c) => `category:${c}`),
|
|
61
|
+
...(rule.match.merchants ?? []).map((m) => `merchant:${m}`),
|
|
62
|
+
];
|
|
63
|
+
// Every bonus rate the cardholder gets now, best first, so none reads as the
|
|
64
|
+
// base rate. Rates behind a membership the wallet lacks are left out, and so
|
|
65
|
+
// is a lower tier that an uncapped, always-on higher rate fully covers (Prime
|
|
66
|
+
// Visa's 3% without Prime, once Prime is held). Rules at the same rate share
|
|
67
|
+
// a line.
|
|
68
|
+
function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
69
|
+
const shown = card.rules
|
|
70
|
+
.filter((r) => applies(r, today) &&
|
|
71
|
+
r.rate > card.base_rate &&
|
|
72
|
+
(r.requires?.membership === undefined ||
|
|
73
|
+
wallet.memberships.includes(r.requires.membership)))
|
|
74
|
+
.sort((a, b) => b.rate - a.rate);
|
|
75
|
+
const lines = new Map();
|
|
76
|
+
for (const r of shown) {
|
|
77
|
+
const keys = matchKeys(r);
|
|
78
|
+
const higher = shown
|
|
79
|
+
.filter((h) => h.rate > r.rate &&
|
|
80
|
+
h.cap === undefined &&
|
|
81
|
+
(!h.requires?.activation || isActivated(card, held, h.id)))
|
|
82
|
+
.flatMap(matchKeys);
|
|
83
|
+
if (keys.length > 0 && keys.every((k) => higher.includes(k)))
|
|
84
|
+
continue;
|
|
85
|
+
const pending = r.requires?.activation && !isActivated(card, held, r.id);
|
|
86
|
+
const head = `${earnText(catalog, r.rate, currency)} on`;
|
|
87
|
+
const key = pending ? `${head}|activate` : head;
|
|
88
|
+
const whats = lines.get(key) ?? [];
|
|
89
|
+
for (const part of ruleParts(catalog, r))
|
|
90
|
+
if (!whats.includes(part))
|
|
91
|
+
whats.push(part);
|
|
92
|
+
lines.set(key, whats);
|
|
93
|
+
}
|
|
72
94
|
return [
|
|
73
|
-
...
|
|
95
|
+
...[...lines].map(([key, whats]) => `${key.replace('|activate', '')} ${listed(whats)}${key.endsWith('|activate') ? ' (once activated)' : ''}`),
|
|
74
96
|
`${earnText(catalog, card.base_rate, currency)} on everything else`,
|
|
75
97
|
];
|
|
76
98
|
}
|
|
@@ -113,7 +135,7 @@ function capSummaries(catalog, card, held, today) {
|
|
|
113
135
|
const label = shared?.rotating
|
|
114
136
|
? 'the rotating bonus categories'
|
|
115
137
|
: [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join(', ') ||
|
|
116
|
-
key
|
|
138
|
+
words(key);
|
|
117
139
|
return {
|
|
118
140
|
key,
|
|
119
141
|
label,
|
|
@@ -142,6 +164,9 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
142
164
|
onlyAt: card.only_at ?? null,
|
|
143
165
|
bonus: bonusStatus(catalog, wallet, held, today),
|
|
144
166
|
currency: valuation.currency,
|
|
167
|
+
currencyName: valuation.currency === 'cash'
|
|
168
|
+
? 'cash back'
|
|
169
|
+
: currencyLabel(catalog, valuation.currency),
|
|
145
170
|
centsPerUnit: valuation.centsPerUnit,
|
|
146
171
|
lastVerified: card.last_verified,
|
|
147
172
|
verifiedBy: card.verified_by,
|
|
@@ -152,7 +177,10 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
152
177
|
? {
|
|
153
178
|
period: held.chosen.period,
|
|
154
179
|
current: pickedCategories(card, held, today).staleChoice === null,
|
|
155
|
-
|
|
180
|
+
// Picks that carry over have no end date.
|
|
181
|
+
until: card.choices.every((c) => c.period === 'until_changed')
|
|
182
|
+
? null
|
|
183
|
+
: periodEnd(held.chosen.period),
|
|
156
184
|
picks: held.chosen.picks,
|
|
157
185
|
pickNames: Object.fromEntries(Object.entries(held.chosen.picks).map(([choice, options]) => [
|
|
158
186
|
choiceName(choice),
|
|
@@ -160,7 +188,7 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
160
188
|
])),
|
|
161
189
|
}
|
|
162
190
|
: null,
|
|
163
|
-
rates: headlineRates(catalog, card, valuation.currency, today),
|
|
191
|
+
rates: headlineRates(catalog, wallet, card, held, valuation.currency, today),
|
|
164
192
|
activated: held.activated,
|
|
165
193
|
toActivate: toActivate(catalog, card, held, today),
|
|
166
194
|
anniversary: held.anniversary ?? null,
|
package/dist/format/card-text.js
CHANGED
|
@@ -13,14 +13,6 @@ const ISSUERS = {
|
|
|
13
13
|
usbank: 'U.S. Bank',
|
|
14
14
|
'wells-fargo': 'Wells Fargo',
|
|
15
15
|
};
|
|
16
|
-
export const MEMBERSHIPS = {
|
|
17
|
-
'amazon-prime': 'Amazon Prime',
|
|
18
|
-
'bank-of-america-account': 'Bank of America checking or savings',
|
|
19
|
-
'bofa-rewards-member': 'BofA Rewards Member',
|
|
20
|
-
'bofa-rewards-preferred-plus': 'BofA Rewards Preferred Plus',
|
|
21
|
-
'bofa-rewards-preferred-honors': 'BofA Rewards Preferred Honors',
|
|
22
|
-
'bofa-rewards-premier': 'BofA Rewards Premier',
|
|
23
|
-
};
|
|
24
16
|
export const PICK_PERIODS = {
|
|
25
17
|
month: 'each month',
|
|
26
18
|
quarter: 'each quarter',
|
|
@@ -33,11 +25,11 @@ export const CAP_PERIODS = {
|
|
|
33
25
|
calendar_year: 'a year',
|
|
34
26
|
cardmember_year: 'a card year',
|
|
35
27
|
};
|
|
36
|
-
export
|
|
28
|
+
export { MEMBERSHIPS, membershipName, words } from "../engine/text.js";
|
|
29
|
+
import { membershipName, words } from "../engine/text.js";
|
|
37
30
|
// Whole dollars, for fees and caps.
|
|
38
31
|
export const usd = (n) => `${n < 0 ? '−' : ''}$${Math.abs(Math.round(n)).toLocaleString('en-US')}`;
|
|
39
32
|
export const issuerName = (id) => ISSUERS[id] ?? id;
|
|
40
|
-
export const membershipName = (id) => MEMBERSHIPS[id] ?? words(id);
|
|
41
33
|
export function cardText(catalog) {
|
|
42
34
|
const cards = new Map(catalog.cards.map((c) => [c.id, c]));
|
|
43
35
|
function shortName(id) {
|
|
@@ -50,15 +42,17 @@ export function cardText(catalog) {
|
|
|
50
42
|
}
|
|
51
43
|
function feeLabel(id) {
|
|
52
44
|
const card = cards.get(id);
|
|
45
|
+
const m = card?.requires_membership;
|
|
46
|
+
const needs = m ? `; needs ${m.name} (${usd(m.annual_fee)} a year)` : '';
|
|
53
47
|
const fee = card?.annual_fee;
|
|
54
48
|
if (fee === undefined)
|
|
55
49
|
return 'Annual fee not on file';
|
|
56
50
|
if (fee === 0)
|
|
57
|
-
return
|
|
51
|
+
return `No annual fee${needs}`;
|
|
58
52
|
const first = card?.first_year_fee;
|
|
59
53
|
if (first !== undefined && first !== fee)
|
|
60
|
-
return `${usd(fee)} a year (${first === 0 ? 'waived' : usd(first)} the first year)`;
|
|
61
|
-
return `${usd(fee)} a year`;
|
|
54
|
+
return `${usd(fee)} a year (${first === 0 ? 'waived' : usd(first)} the first year)${needs}`;
|
|
55
|
+
return `${usd(fee)} a year${needs}`;
|
|
62
56
|
}
|
|
63
57
|
// `until` is the last day a time-limited rate applies, already formatted.
|
|
64
58
|
function requirementLines(cardId, r, until = null) {
|
package/dist/format/cards.js
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
visa: 'Visa',
|
|
3
|
-
mastercard: 'Mastercard',
|
|
4
|
-
amex: 'Amex',
|
|
5
|
-
discover: 'Discover',
|
|
6
|
-
store: 'store card',
|
|
7
|
-
};
|
|
1
|
+
import { NETWORK_NAMES } from "../engine/text.js";
|
|
8
2
|
export function formatCardInfo(card) {
|
|
9
3
|
const fee = card.annualFee === null
|
|
10
4
|
? ''
|
|
@@ -13,6 +7,11 @@ export function formatCardInfo(card) {
|
|
|
13
7
|
: `, $${String(card.annualFee)} a year`;
|
|
14
8
|
const needs = [
|
|
15
9
|
...card.choices.map((c) => `${c.id}: pick ${String(c.pick)} of ${c.options.join(', ')}${c.default ? ` (default ${c.default.join(', ')})` : ''}`),
|
|
10
|
+
...(card.requiresMembership
|
|
11
|
+
? [
|
|
12
|
+
`needs ${card.requiresMembership.name} ($${String(card.requiresMembership.annualFee)} a year; membership id ${card.requiresMembership.id})`,
|
|
13
|
+
]
|
|
14
|
+
: []),
|
|
16
15
|
...(card.memberships.length > 0
|
|
17
16
|
? [`rates that need a membership: ${card.memberships.join(', ')}`]
|
|
18
17
|
: []),
|
|
@@ -23,7 +22,7 @@ export function formatCardInfo(card) {
|
|
|
23
22
|
...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
|
|
24
23
|
];
|
|
25
24
|
return [
|
|
26
|
-
`${card.id}: ${card.name} (${
|
|
25
|
+
`${card.id}: ${card.name} (${NETWORK_NAMES[card.network] ?? card.network}${fee})${card.held ? ' [in wallet]' : ''}`,
|
|
27
26
|
...needs.map((n) => ` ${n}`),
|
|
28
27
|
].join('\n');
|
|
29
28
|
}
|
package/dist/format/explore.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { membershipNote } from "../engine/memberships.js";
|
|
2
|
+
import { feeText, membershipName, money, pct } from "../engine/text.js";
|
|
2
3
|
const wholeUsd = (n) => money(Math.round(n));
|
|
4
|
+
const membershipText = (memberships) => memberships
|
|
5
|
+
.map((m) => ` + ${wholeUsd(m.annualFee)} for ${m.name.replace(/^an? /, '')}`)
|
|
6
|
+
.join('');
|
|
3
7
|
const TOP_BEST = 10;
|
|
4
8
|
export function formatExploration(r, all) {
|
|
5
9
|
const m = r.merchant;
|
|
@@ -20,7 +24,7 @@ export function formatExploration(r, all) {
|
|
|
20
24
|
lines.push(` ${c}`);
|
|
21
25
|
const upgrade = e.withMembership[0];
|
|
22
26
|
if (upgrade)
|
|
23
|
-
lines.push(` With ${upgrade.membership}: ${pct(upgrade.effectiveReturnPct)}.`);
|
|
27
|
+
lines.push(` With ${membershipName(upgrade.membership)}: ${pct(upgrade.effectiveReturnPct)}.`);
|
|
24
28
|
for (const w of e.warnings)
|
|
25
29
|
lines.push(` ! ${w}`);
|
|
26
30
|
});
|
|
@@ -64,7 +68,7 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
64
68
|
const card = s.cards[0];
|
|
65
69
|
if (!card)
|
|
66
70
|
return;
|
|
67
|
-
lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeUsd(s.net)} (${wholeUsd(s.rewards)} rewards, ${wholeUsd(s.annualFees)} fee)${picksText(card.picks)}`);
|
|
71
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeUsd(s.net)} (${wholeUsd(s.rewards)} rewards, ${wholeUsd(s.annualFees)} fee${membershipText(s.memberships)})${picksText(card.picks)}`);
|
|
68
72
|
});
|
|
69
73
|
const pairs = r.pairs.slice(0, top);
|
|
70
74
|
if (pairs.length > 0) {
|
|
@@ -92,7 +96,7 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
92
96
|
export function formatAdditions(r, catalog, all) {
|
|
93
97
|
const name = cardNamer(catalog);
|
|
94
98
|
const lines = [
|
|
95
|
-
`Your wallet earns about ${wholeUsd(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeUsd(r.current.annualFees)} in annual fees` : ''}.`,
|
|
99
|
+
`Your wallet earns about ${wholeUsd(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeUsd(r.current.annualFees)} in annual fees: ${wholeUsd(r.current.net)} after fees` : ''}.`,
|
|
96
100
|
];
|
|
97
101
|
for (const split of splitText(r.current, name))
|
|
98
102
|
lines.push(` ${split}`);
|
|
@@ -101,14 +105,19 @@ export function formatAdditions(r, catalog, all) {
|
|
|
101
105
|
lines.push('', 'No card would add more than its annual fee costs.');
|
|
102
106
|
}
|
|
103
107
|
else {
|
|
104
|
-
lines.push('', 'Cards that would add the most (after their annual fee):');
|
|
108
|
+
lines.push('', 'Cards that would add the most (after their annual fee and any membership they need):');
|
|
105
109
|
gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
|
|
106
|
-
lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeUsd(a.gain)} a year (+${wholeUsd(a.extraRewards)} rewards, ${wholeUsd(a.annualFee)} fee)${picksText(a.picks)}`);
|
|
110
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeUsd(a.gain)} a year (+${wholeUsd(a.extraRewards)} rewards, ${wholeUsd(a.annualFee)} fee${membershipText(a.simulation.memberships.filter((m) => m.cardIds.includes(a.cardId)))})${picksText(a.picks)}`);
|
|
107
111
|
});
|
|
108
112
|
}
|
|
109
|
-
const notes = new Set(
|
|
113
|
+
const notes = new Set([
|
|
114
|
+
...r.current.notes,
|
|
115
|
+
...gains
|
|
116
|
+
.slice(0, all ? 20 : TOP_SIMULATED)
|
|
117
|
+
.flatMap((a) => a.simulation.memberships.map((m) => membershipNote(catalog, m))),
|
|
118
|
+
]);
|
|
110
119
|
if (notes.size > 0)
|
|
111
120
|
lines.push('', ...notes);
|
|
112
|
-
lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh.');
|
|
121
|
+
lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh. Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.');
|
|
113
122
|
return `${lines.join('\n')}\n`;
|
|
114
123
|
}
|
package/dist/format/offers.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { feeText, money } from "../engine/text.js";
|
|
1
|
+
import { feeText, money, shortDate } from "../engine/text.js";
|
|
2
2
|
const TOP = 10;
|
|
3
3
|
function reward(o, currencyName) {
|
|
4
4
|
const amount = o.dollars !== null
|
|
@@ -6,12 +6,6 @@ function reward(o, currencyName) {
|
|
|
6
6
|
: `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
|
|
7
7
|
return o.varies ? `Up to ${amount}` : amount;
|
|
8
8
|
}
|
|
9
|
-
const asOf = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
|
|
10
|
-
month: 'short',
|
|
11
|
-
day: 'numeric',
|
|
12
|
-
year: 'numeric',
|
|
13
|
-
timeZone: 'UTC',
|
|
14
|
-
});
|
|
15
9
|
export function formatOffers(c, currencyName, all) {
|
|
16
10
|
if (c.offers.length === 0) {
|
|
17
11
|
return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
|
|
@@ -25,8 +19,10 @@ export function formatOffers(c, currencyName, all) {
|
|
|
25
19
|
const spend = o.spend > 0
|
|
26
20
|
? `after ${money(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
|
|
27
21
|
: 'with no spending required';
|
|
28
|
-
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${money(Math.round(o.firstYearValue))} the first year`, ` ${reward(o, currencyName(o.currency))} ${spend}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.held ? ' In your wallet.' : ''}`);
|
|
29
|
-
lines.push(` ${o.validTo ? `Offer ends ${o.validTo}. ` : ''}Public offer as of ${
|
|
22
|
+
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${money(Math.round(o.firstYearValue))} the first year`, ` ${reward(o, currencyName(o.currency))} ${spend}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
|
|
23
|
+
lines.push(` ${o.validTo ? `Offer ends ${o.validTo}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
|
|
24
|
+
if (o.ineligible)
|
|
25
|
+
lines.push(` ${o.ineligible}`);
|
|
30
26
|
if (o.note)
|
|
31
27
|
lines.push(` ${o.note}`);
|
|
32
28
|
});
|
|
@@ -24,13 +24,15 @@ function header(rec, purchase) {
|
|
|
24
24
|
parts.push(m.channel === 'online' ? 'online' : 'in-store');
|
|
25
25
|
return parts.join(' · ');
|
|
26
26
|
}
|
|
27
|
-
function cardLines(cards, style) {
|
|
27
|
+
function cardLines(cards, style, bonusPick) {
|
|
28
|
+
const rankWidth = Math.max(...cards.map((c) => c.rank));
|
|
28
29
|
const nameWidth = Math.max(...cards.map((c) => label(c).length));
|
|
29
30
|
const pctWidth = Math.max(...cards.map((c) => pct(c.effectiveReturnPct).length));
|
|
30
31
|
const lines = [];
|
|
31
|
-
cards.forEach((c
|
|
32
|
+
cards.forEach((c) => {
|
|
32
33
|
const earned = c.earned === null ? '' : ` ${cents(c.earned)}`;
|
|
33
|
-
|
|
34
|
+
const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
|
|
35
|
+
lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
|
|
34
36
|
for (const r of c.reasoning)
|
|
35
37
|
lines.push(style('dim', ` ${r}`));
|
|
36
38
|
for (const w of c.warnings)
|
|
@@ -60,9 +62,13 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
|
|
|
60
62
|
else {
|
|
61
63
|
lines.push(style('bold', noCard));
|
|
62
64
|
}
|
|
63
|
-
const
|
|
65
|
+
const bonusPick = rec.bonus?.cardId ?? null;
|
|
66
|
+
const ranked = rec.ranked.map((c, i) => ({ ...c, rank: i + 1 }));
|
|
67
|
+
const shown = all
|
|
68
|
+
? ranked
|
|
69
|
+
: ranked.filter((c, i) => i < TOP || c.cardId === bonusPick);
|
|
64
70
|
if (shown.length > 0)
|
|
65
|
-
lines.push('', ...cardLines(shown, style));
|
|
71
|
+
lines.push('', ...cardLines(shown, style, bonusPick));
|
|
66
72
|
const hidden = rec.ranked.length - shown.length;
|
|
67
73
|
if (hidden > 0) {
|
|
68
74
|
lines.push(style('dim', ` + ${String(hidden)} more ${hidden === 1 ? 'card' : 'cards'} (--all lists them)`));
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { parse,
|
|
1
|
+
import { Document, parse, visit } from 'yaml';
|
|
2
2
|
import { walletProblems } from "../data/integrity.js";
|
|
3
|
-
import { WalletSchema } from "../schema/wallet.js";
|
|
3
|
+
import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
|
|
4
4
|
// A wallet as the YAML file the CLI reads, for moving it between the
|
|
5
5
|
// website, the remote server and the command line. Node-free.
|
|
6
6
|
function compact(wallet) {
|
|
7
7
|
return {
|
|
8
|
+
version: wallet.version ?? WALLET_VERSION,
|
|
8
9
|
...(Object.keys(wallet.valuations).length > 0
|
|
9
10
|
? { valuations: wallet.valuations }
|
|
10
11
|
: {}),
|
|
@@ -26,8 +27,22 @@ function compact(wallet) {
|
|
|
26
27
|
})),
|
|
27
28
|
};
|
|
28
29
|
}
|
|
30
|
+
// Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare 2026-10-15 as a
|
|
31
|
+
// date, not a string.
|
|
32
|
+
function quotedYaml(value) {
|
|
33
|
+
const doc = new Document(value);
|
|
34
|
+
visit(doc, {
|
|
35
|
+
Scalar(key, node) {
|
|
36
|
+
if (key === 'value' &&
|
|
37
|
+
typeof node.value === 'string' &&
|
|
38
|
+
/^(\d{4}(-|$)|\d{2}-\d{2}$)/.test(node.value))
|
|
39
|
+
node.type = 'QUOTE_DOUBLE';
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
return doc.toString();
|
|
43
|
+
}
|
|
29
44
|
export function walletYaml(wallet) {
|
|
30
|
-
return `# Your earnrate wallet: the cards you hold, by id. Never card numbers.\n# For the command line, save it as ~/.earnrate/wallet.yaml\n\n${
|
|
45
|
+
return `# Your earnrate wallet: the cards you hold, by id. Never card numbers.\n# For the command line, save it as ~/.earnrate/wallet.yaml\n\n${quotedYaml(compact(wallet))}`;
|
|
31
46
|
}
|
|
32
47
|
export function parseWalletYaml(text, catalog) {
|
|
33
48
|
let raw;
|
package/dist/format/wallet.js
CHANGED
|
@@ -1,24 +1,11 @@
|
|
|
1
|
-
import { money } from "../engine/text.js";
|
|
2
|
-
const NETWORKS = {
|
|
3
|
-
visa: 'Visa',
|
|
4
|
-
mastercard: 'Mastercard',
|
|
5
|
-
amex: 'American Express',
|
|
6
|
-
discover: 'Discover',
|
|
7
|
-
store: 'store card',
|
|
8
|
-
};
|
|
9
|
-
const day = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
|
|
10
|
-
month: 'short',
|
|
11
|
-
day: 'numeric',
|
|
12
|
-
year: 'numeric',
|
|
13
|
-
timeZone: 'UTC',
|
|
14
|
-
});
|
|
1
|
+
import { money, NETWORK_NAMES, shortDate } from "../engine/text.js";
|
|
15
2
|
export function formatWalletSummary(summary) {
|
|
16
3
|
const cards = summary.cards.map((c) => {
|
|
17
4
|
const label = c.checkoutLabel
|
|
18
5
|
? `, shown at checkout as "${c.checkoutLabel}"`
|
|
19
6
|
: '';
|
|
20
7
|
const lines = [
|
|
21
|
-
`- ${c.cardName} (${c.cardId})${label}: ${
|
|
8
|
+
`- ${c.cardName} (${c.cardId})${label}: ${NETWORK_NAMES[c.network] ?? c.network}, ${c.currencyName} at ${String(c.centsPerUnit)}¢. Data checked ${c.lastVerified}${c.dataIsStale ? ' (old; check the issuer)' : ''}.`,
|
|
22
9
|
` Earns: ${c.rates.join('; ')}.`,
|
|
23
10
|
];
|
|
24
11
|
if (c.onlyAt)
|
|
@@ -27,10 +14,14 @@ export function formatWalletSummary(summary) {
|
|
|
27
14
|
const picks = Object.entries(c.chosen.pickNames)
|
|
28
15
|
.map(([choice, names]) => `${choice}: ${names.join(', ')}`)
|
|
29
16
|
.join('; ');
|
|
30
|
-
const until = c.chosen.until
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
17
|
+
const until = c.chosen.until
|
|
18
|
+
? `, through ${shortDate(c.chosen.until)}`
|
|
19
|
+
: '';
|
|
20
|
+
lines.push(!c.chosen.current
|
|
21
|
+
? ` Picks are for ${c.chosen.period}, which is over: ask for this period's picks.`
|
|
22
|
+
: c.chosen.until === null
|
|
23
|
+
? ` Picks: ${picks}. They stay until the user changes them.`
|
|
24
|
+
: ` Picks for ${c.chosen.period}${until}: ${picks}. Pick again after that.`);
|
|
34
25
|
}
|
|
35
26
|
if (c.toActivate.length > 0)
|
|
36
27
|
lines.push(` Not activated yet: ${c.toActivate.join('; ')}. Activating once with the issuer covers each group.`);
|
|
@@ -39,6 +30,8 @@ export function formatWalletSummary(summary) {
|
|
|
39
30
|
for (const cap of c.caps)
|
|
40
31
|
if (cap.remaining !== null)
|
|
41
32
|
lines.push(` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''} (cap key ${cap.key}).`);
|
|
33
|
+
if (c.anniversary !== null)
|
|
34
|
+
lines.push(` Account anniversary: ${c.anniversary}.`);
|
|
42
35
|
const b = c.bonus;
|
|
43
36
|
if (b?.state === 'open' && b.spend !== null)
|
|
44
37
|
lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -120,11 +120,11 @@ export function makeInputs(catalog) {
|
|
|
120
120
|
.array(NamedCard)
|
|
121
121
|
.optional()
|
|
122
122
|
.describe("Cards the user named in this conversation, to answer for just those cards without saving anything. Leave out to use the user's saved wallet if they have one; with neither, the answer covers every card earnrate knows.");
|
|
123
|
-
const NamedMemberships = Memberships.optional().describe("Memberships the user has, such as amazon-prime, when they're named in the conversation rather than saved.");
|
|
123
|
+
const NamedMemberships = Memberships.optional().describe("Memberships the user has, such as amazon-prime or costco, when they're named in the conversation rather than saved.");
|
|
124
124
|
const CatalogFilters = {
|
|
125
125
|
...OfferFilters,
|
|
126
126
|
issuer: z.enum(issuers).optional().describe("Only this issuer's cards."),
|
|
127
|
-
memberships: Memberships.optional().describe("Memberships the user has, such as amazon-prime. Rates that need one they don't have are listed separately."),
|
|
127
|
+
memberships: Memberships.optional().describe("Memberships the user has, such as amazon-prime or costco. Rates that need one they don't have are listed separately; a card that needs one (Costco Anywhere) says so."),
|
|
128
128
|
valuation: z
|
|
129
129
|
.enum(['flat', 'points-heavy'])
|
|
130
130
|
.optional()
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -170,6 +170,8 @@ export const OfferComparisonSchema = z.object({
|
|
|
170
170
|
note: z.string().nullable(),
|
|
171
171
|
annualFee: z.number().nullable(),
|
|
172
172
|
firstYearFee: z.number().nullable(),
|
|
173
|
+
membershipFee: z.number(),
|
|
174
|
+
ineligible: z.string().nullable(),
|
|
173
175
|
bonusValue: z.number(),
|
|
174
176
|
firstYearValue: z.number(),
|
|
175
177
|
held: z.boolean(),
|
|
@@ -204,6 +206,9 @@ export const CardSearchSchema = z.object({
|
|
|
204
206
|
default: z.array(z.string()).nullable(),
|
|
205
207
|
})),
|
|
206
208
|
memberships: z.array(z.string()),
|
|
209
|
+
requiresMembership: z
|
|
210
|
+
.object({ id: z.string(), name: z.string(), annualFee: z.number() })
|
|
211
|
+
.nullable(),
|
|
207
212
|
activation: z.array(z.string()),
|
|
208
213
|
needsAnniversary: z.boolean(),
|
|
209
214
|
})),
|
|
@@ -272,6 +277,13 @@ const SimulationSchema = z.object({
|
|
|
272
277
|
spend: z.number(),
|
|
273
278
|
rewards: z.number(),
|
|
274
279
|
annualFees: z.number(),
|
|
280
|
+
memberships: z.array(z.object({
|
|
281
|
+
id: z.string(),
|
|
282
|
+
name: z.string(),
|
|
283
|
+
annualFee: z.number(),
|
|
284
|
+
cardIds: z.array(z.string()),
|
|
285
|
+
})),
|
|
286
|
+
membershipFees: z.number(),
|
|
275
287
|
net: z.number(),
|
|
276
288
|
lines: z.array(z.object({
|
|
277
289
|
label: z.string(),
|
|
@@ -292,6 +304,7 @@ export const SpendingSimulationSchema = z.object({
|
|
|
292
304
|
picks: Picks,
|
|
293
305
|
extraRewards: z.number(),
|
|
294
306
|
annualFee: z.number(),
|
|
307
|
+
membershipFee: z.number(),
|
|
295
308
|
gain: z.number(),
|
|
296
309
|
})),
|
|
297
310
|
});
|
|
@@ -46,19 +46,35 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
46
46
|
const parsed = parseWalletYaml(yaml, catalog);
|
|
47
47
|
if (typeof parsed === 'string')
|
|
48
48
|
return fail(parsed);
|
|
49
|
-
|
|
49
|
+
let current;
|
|
50
|
+
try {
|
|
51
|
+
current = await store.load();
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
if (replace !== true)
|
|
55
|
+
return fail(`Couldn't read the saved wallet to check what it holds (${e instanceof Error ? e.message : String(e)}). Ask the user, then call again with replace: true to overwrite it.`);
|
|
56
|
+
current = null;
|
|
57
|
+
}
|
|
50
58
|
if (current !== null && current.cards.length > 0 && replace !== true)
|
|
51
59
|
return fail(`This would replace the ${String(current.cards.length)} ${current.cards.length === 1 ? 'card' : 'cards'} already saved (${current.cards.map((c) => c.id).join(', ')}). Ask the user, then call again with replace: true.`);
|
|
52
|
-
const
|
|
53
|
-
|
|
60
|
+
const cards = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
|
|
61
|
+
const before = current?.cards.length ?? 0;
|
|
62
|
+
return ctx.changed(await store.replace(parsed), before > 0
|
|
63
|
+
? `Replaced the ${cards(before)} that were saved with the ${cards(parsed.cards.length)} imported.`
|
|
64
|
+
: `Imported a wallet with ${cards(parsed.cards.length)}.`, { notes: false });
|
|
54
65
|
});
|
|
55
66
|
server.registerTool('delete_wallet', {
|
|
56
67
|
title: 'Delete the wallet',
|
|
57
|
-
description: "Deletes the saved wallet and its recovery code now and for good, and signs out every Claude app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: offer export_wallet
|
|
68
|
+
description: "Deletes the saved wallet and its recovery code now and for good, and signs out every Claude app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: show it with list_wallet, offer export_wallet, and pass confirm and cards only after the user confirms. It refuses if the wallet no longer holds that many cards, so a retried call can't delete a different wallet.",
|
|
58
69
|
inputSchema: z.object({
|
|
59
70
|
confirm: z
|
|
60
71
|
.literal(true)
|
|
61
72
|
.describe('true, only after the user confirms the deletion.'),
|
|
73
|
+
cards: z
|
|
74
|
+
.number()
|
|
75
|
+
.int()
|
|
76
|
+
.nonnegative()
|
|
77
|
+
.describe('How many cards the wallet held when the user confirmed, from list_wallet: 0 for an empty wallet.'),
|
|
62
78
|
}),
|
|
63
79
|
outputSchema: z.object({ deleted: z.boolean() }),
|
|
64
80
|
annotations: {
|
|
@@ -67,10 +83,12 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
67
83
|
idempotentHint: true,
|
|
68
84
|
openWorldHint: false,
|
|
69
85
|
},
|
|
70
|
-
}, async () => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
86
|
+
}, async ({ cards }) => {
|
|
87
|
+
// An unreadable wallet can still be deleted: the user confirmed it.
|
|
88
|
+
const current = await store.load().catch(() => undefined);
|
|
89
|
+
const held = current === undefined ? cards : (current?.cards.length ?? 0);
|
|
90
|
+
if (held !== cards)
|
|
91
|
+
return fail(`This saved wallet holds ${String(held)} ${held === 1 ? 'card' : 'cards'}, not ${String(cards)}, so it isn't the wallet the user confirmed (it may have been deleted and a new one connected). Show it with list_wallet and confirm again.`);
|
|
74
92
|
await store.erase();
|
|
75
93
|
return {
|
|
76
94
|
content: [
|
|
@@ -84,7 +102,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
84
102
|
});
|
|
85
103
|
server.registerTool('switch_wallet', {
|
|
86
104
|
title: 'Switch wallets',
|
|
87
|
-
description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
|
|
105
|
+
description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. It also signs out anyone else connected to this wallet through the same app (another Claude account the recovery code was shared with): say so before calling it. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
|
|
88
106
|
inputSchema: z.object({}),
|
|
89
107
|
outputSchema: z.object({ disconnected: z.boolean() }),
|
|
90
108
|
annotations: {
|