earnrate 0.2.1 → 0.2.3
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 +2 -1
- 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/data/merchants/overrides.yaml +4 -1
- package/dist/data/file-store.js +3 -1
- package/dist/data/store.js +9 -1
- package/dist/data/wallet-edit.js +10 -1
- package/dist/engine/evaluate.js +22 -3
- package/dist/engine/offers.js +2 -1
- package/dist/engine/recommend.js +14 -1
- package/dist/engine/setup.js +3 -3
- package/dist/engine/wallet-change.js +40 -8
- package/dist/engine/wallet-summary.js +105 -3
- package/dist/format/offers.js +11 -5
- package/dist/format/wallet.js +48 -19
- package/dist/mcp/context.js +7 -3
- package/dist/mcp/inputs.js +12 -5
- package/dist/mcp/schemas.js +6 -0
- package/dist/mcp/server.js +4 -4
- package/dist/mcp/tools/account.js +42 -5
- package/dist/mcp/tools/answers.js +13 -4
- package/dist/schema/card.js +4 -0
- package/package.json +2 -1
package/dist/engine/setup.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pickedCategories } from "./evaluate.js";
|
|
1
|
+
import { isActivated, pickedCategories } from "./evaluate.js";
|
|
2
2
|
import { daysBetween } from "./periods.js";
|
|
3
3
|
import { rotationGap } from "./rotation.js";
|
|
4
4
|
// Rules that start within this many days are worth activating now.
|
|
@@ -49,7 +49,7 @@ function needsAnniversary(card) {
|
|
|
49
49
|
return (card.caps.some((c) => c.period === 'cardmember_year') ||
|
|
50
50
|
card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year'));
|
|
51
51
|
}
|
|
52
|
-
function activationRules(card, today) {
|
|
52
|
+
export function activationRules(card, today) {
|
|
53
53
|
return card.rules
|
|
54
54
|
.filter((r) => r.requires?.activation === true &&
|
|
55
55
|
(r.valid_to === undefined || r.valid_to >= today) &&
|
|
@@ -144,7 +144,7 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
144
144
|
text: `Some of ${name}'s rates need a membership: ${list(missing)}. Add any that apply to the wallet's memberships.`,
|
|
145
145
|
});
|
|
146
146
|
}
|
|
147
|
-
const toActivate = activationRules(card, today).filter((id) => !held
|
|
147
|
+
const toActivate = activationRules(card, today).filter((id) => !isActivated(card, held, id));
|
|
148
148
|
if (toActivate.length > 0) {
|
|
149
149
|
notes.push({
|
|
150
150
|
kind: 'activation',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { capByKey } from "./evaluate.js";
|
|
1
2
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
2
3
|
import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
|
|
3
4
|
const failed = (error) => ({ ok: false, error });
|
|
@@ -25,7 +26,9 @@ function pickKey(card, today) {
|
|
|
25
26
|
export function cardFields(card, held, input, today) {
|
|
26
27
|
const problems = [];
|
|
27
28
|
const fields = {};
|
|
28
|
-
if (input.checkoutLabel
|
|
29
|
+
if (input.checkoutLabel === null)
|
|
30
|
+
fields.checkout_label = null;
|
|
31
|
+
else if (input.checkoutLabel !== undefined) {
|
|
29
32
|
if (input.checkoutLabel.trim() === '')
|
|
30
33
|
problems.push('The checkout label is empty.');
|
|
31
34
|
// earnrate never stores card numbers; a last four is fine.
|
|
@@ -83,6 +86,34 @@ export function cardFields(card, held, input, today) {
|
|
|
83
86
|
};
|
|
84
87
|
}
|
|
85
88
|
}
|
|
89
|
+
if (input.capSpent !== undefined) {
|
|
90
|
+
const usage = { ...held?.cap_usage };
|
|
91
|
+
const anniversary = fields.anniversary ?? held?.anniversary ?? null;
|
|
92
|
+
for (const [key, spent] of Object.entries(input.capSpent)) {
|
|
93
|
+
const cap = capByKey(card, key);
|
|
94
|
+
if (!cap) {
|
|
95
|
+
const keys = [
|
|
96
|
+
...card.caps.map((c) => c.id),
|
|
97
|
+
...card.rules
|
|
98
|
+
.filter((r) => typeof r.cap === 'object')
|
|
99
|
+
.map((r) => r.id),
|
|
100
|
+
];
|
|
101
|
+
problems.push(keys.length > 0
|
|
102
|
+
? `${card.id} has no cap '${key}'. Its caps are: ${list(keys)}.`
|
|
103
|
+
: `${card.id} has no spending caps.`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!(spent >= 0))
|
|
107
|
+
problems.push('Spending on a cap must be $0 or more.');
|
|
108
|
+
const period = periodKey(cap.period, today, anniversary);
|
|
109
|
+
if (period === null) {
|
|
110
|
+
problems.push(`The '${key}' cap resets on the card's anniversary; give the anniversary (MM-DD) too.`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
usage[key] = { period, spent };
|
|
114
|
+
}
|
|
115
|
+
fields.cap_usage = usage;
|
|
116
|
+
}
|
|
86
117
|
if (input.bonus === null)
|
|
87
118
|
fields.bonus = null;
|
|
88
119
|
else if (input.bonus !== undefined) {
|
|
@@ -115,15 +146,17 @@ const newHolding = (id) => ({
|
|
|
115
146
|
cap_usage: {},
|
|
116
147
|
activated: [],
|
|
117
148
|
});
|
|
118
|
-
const withFields = ({ bonus, ...held }, f) => ({
|
|
149
|
+
const withFields = ({ bonus, checkout_label, ...held }, f) => ({
|
|
119
150
|
...held,
|
|
120
151
|
...(bonus && f.bonus === undefined ? { bonus } : {}),
|
|
121
|
-
...(f.checkout_label === undefined
|
|
122
|
-
? {}
|
|
123
|
-
: {
|
|
152
|
+
...(checkout_label !== undefined && f.checkout_label === undefined
|
|
153
|
+
? { checkout_label }
|
|
154
|
+
: {}),
|
|
155
|
+
...(f.checkout_label ? { checkout_label: f.checkout_label } : {}),
|
|
124
156
|
...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
|
|
125
157
|
...(f.chosen === undefined ? {} : { chosen: f.chosen }),
|
|
126
158
|
...(f.activated === undefined ? {} : { activated: f.activated }),
|
|
159
|
+
...(f.cap_usage === undefined ? {} : { cap_usage: f.cap_usage }),
|
|
127
160
|
...(f.bonus ? { bonus: f.bonus } : {}),
|
|
128
161
|
});
|
|
129
162
|
// `wallet` is null when there isn't one yet; adding a card starts it.
|
|
@@ -175,9 +208,8 @@ export function removeFromWallet(wallet, id) {
|
|
|
175
208
|
if (!wallet.cards.some((c) => c.id === id)) {
|
|
176
209
|
return failed(`${id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
|
|
177
210
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
211
|
+
// Removing the last card leaves an empty wallet, which each store keeps
|
|
212
|
+
// in its own way (or refuses, for a wallet file).
|
|
181
213
|
return {
|
|
182
214
|
ok: true,
|
|
183
215
|
wallet: { ...wallet, cards: wallet.cards.filter((c) => c.id !== id) },
|
|
@@ -1,10 +1,98 @@
|
|
|
1
1
|
import { bonusStatus } from "./bonus.js";
|
|
2
|
-
import { capUsed, pickedCategories } from "./evaluate.js";
|
|
2
|
+
import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
|
|
3
3
|
import { daysBetween } from "./periods.js";
|
|
4
4
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
5
5
|
import { rotationGap } from "./rotation.js";
|
|
6
|
+
import { activationRules } from "./setup.js";
|
|
7
|
+
import { categoryPhrase, earnText } from "./text.js";
|
|
6
8
|
import { cardValuation } from "./valuation.js";
|
|
7
|
-
|
|
9
|
+
const NUMBERS = {
|
|
10
|
+
one: '1',
|
|
11
|
+
two: '2',
|
|
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) ?? ''}`;
|
|
26
|
+
function merchantName(catalog, id) {
|
|
27
|
+
const all = [...catalog.merchants.overrides, ...catalog.merchants.seed];
|
|
28
|
+
return all.find((m) => m.id === id)?.name ?? id.replace(/[-_.]/g, ' ');
|
|
29
|
+
}
|
|
30
|
+
function optionName(catalog, card, choiceId, option) {
|
|
31
|
+
const o = card.choices.find((c) => c.id === choiceId)?.options[option];
|
|
32
|
+
if (!o)
|
|
33
|
+
return option.replace(/[-_.]/g, ' ');
|
|
34
|
+
const parts = [
|
|
35
|
+
...o.categories.map((c) => categoryPhrase(catalog, c)),
|
|
36
|
+
...o.merchants.map((m) => merchantName(catalog, m)),
|
|
37
|
+
];
|
|
38
|
+
if (o.online)
|
|
39
|
+
parts.push('online shopping');
|
|
40
|
+
return parts.length > 0 ? listed(parts) : option.replace(/[-_.]/g, ' ');
|
|
41
|
+
}
|
|
42
|
+
function ruleWhat(catalog, rule) {
|
|
43
|
+
if (rule.match.choice !== undefined)
|
|
44
|
+
return `your ${choiceName(rule.match.choice)} picks`;
|
|
45
|
+
const parts = [
|
|
46
|
+
...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
47
|
+
...(rule.match.merchants ?? []).map((m) => merchantName(catalog, m)),
|
|
48
|
+
];
|
|
49
|
+
return parts.length > 0 ? listed(parts) : rule.id.replace(/[-_.]/g, ' ');
|
|
50
|
+
}
|
|
51
|
+
function periodEnd(period) {
|
|
52
|
+
const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
|
|
53
|
+
if (quarter) {
|
|
54
|
+
const end = ['03-31', '06-30', '09-30', '12-31'][Number(quarter[2]) - 1];
|
|
55
|
+
return `${quarter[1] ?? ''}-${end ?? ''}`;
|
|
56
|
+
}
|
|
57
|
+
const month = /^(\d{4})-(\d{2})$/.exec(period);
|
|
58
|
+
if (month) {
|
|
59
|
+
const last = new Date(Date.UTC(Number(month[1]), Number(month[2]), 0));
|
|
60
|
+
return last.toISOString().slice(0, 10);
|
|
61
|
+
}
|
|
62
|
+
return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
|
|
63
|
+
}
|
|
64
|
+
const applies = (rule, today) => (rule.valid_from === undefined || rule.valid_from <= today) &&
|
|
65
|
+
(rule.valid_to === undefined || rule.valid_to >= today);
|
|
66
|
+
function headlineRates(catalog, card, currency, today) {
|
|
67
|
+
const best = card.rules
|
|
68
|
+
.filter((r) => applies(r, today) && r.rate > card.base_rate)
|
|
69
|
+
.sort((a, b) => b.rate - a.rate)
|
|
70
|
+
.slice(0, 4)
|
|
71
|
+
.map((r) => `${earnText(catalog, r.rate, currency)} on ${ruleWhat(catalog, r)}${r.requires?.activation ? ' (once activated)' : ''}`);
|
|
72
|
+
return [
|
|
73
|
+
...best,
|
|
74
|
+
`${earnText(catalog, card.base_rate, currency)} on everything else`,
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
function toActivate(catalog, card, held, today) {
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const id of activationRules(card, today)) {
|
|
81
|
+
if (seen.has(id) || isActivated(card, held, id))
|
|
82
|
+
continue;
|
|
83
|
+
const group = activationGroup(card, id);
|
|
84
|
+
for (const g of group)
|
|
85
|
+
seen.add(g);
|
|
86
|
+
const rules = card.rules.filter((r) => group.includes(r.id));
|
|
87
|
+
const first = rules[0];
|
|
88
|
+
const when = first?.valid_from !== undefined && first.valid_to !== undefined
|
|
89
|
+
? ` (${first.valid_from} to ${first.valid_to})`
|
|
90
|
+
: '';
|
|
91
|
+
out.push(`${rules.map((r) => ruleWhat(catalog, r)).join(', ')}${when}`);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
function capSummaries(catalog, card, held, today) {
|
|
8
96
|
const caps = new Map();
|
|
9
97
|
for (const shared of card.caps)
|
|
10
98
|
caps.set(shared.id, { cap: shared, rules: [] });
|
|
@@ -20,8 +108,15 @@ function capSummaries(card, held, today) {
|
|
|
20
108
|
}
|
|
21
109
|
return [...caps].map(([key, { cap, rules }]) => {
|
|
22
110
|
const { period: currentPeriod, spent } = capUsed(cap, key, held, today);
|
|
111
|
+
const covered = card.rules.filter((r) => rules.includes(r.id));
|
|
112
|
+
const shared = card.caps.find((c) => c.id === key);
|
|
113
|
+
const label = shared?.rotating
|
|
114
|
+
? 'the rotating bonus categories'
|
|
115
|
+
: [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join(', ') ||
|
|
116
|
+
key.replace(/[-_.]/g, ' ');
|
|
23
117
|
return {
|
|
24
118
|
key,
|
|
119
|
+
label,
|
|
25
120
|
rules,
|
|
26
121
|
amount: cap.amount,
|
|
27
122
|
period: cap.period,
|
|
@@ -52,15 +147,22 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
52
147
|
verifiedBy: card.verified_by,
|
|
53
148
|
dataIsStale: daysBetween(card.last_verified, today) > STALE_AFTER_DAYS,
|
|
54
149
|
rotationGap: rotationGap(card, today),
|
|
55
|
-
caps: capSummaries(card, held, today),
|
|
150
|
+
caps: capSummaries(catalog, card, held, today),
|
|
56
151
|
chosen: held.chosen
|
|
57
152
|
? {
|
|
58
153
|
period: held.chosen.period,
|
|
59
154
|
current: pickedCategories(card, held, today).staleChoice === null,
|
|
155
|
+
until: periodEnd(held.chosen.period),
|
|
60
156
|
picks: held.chosen.picks,
|
|
157
|
+
pickNames: Object.fromEntries(Object.entries(held.chosen.picks).map(([choice, options]) => [
|
|
158
|
+
choiceName(choice),
|
|
159
|
+
options.map((o) => optionName(catalog, card, choice, o)),
|
|
160
|
+
])),
|
|
61
161
|
}
|
|
62
162
|
: null,
|
|
163
|
+
rates: headlineRates(catalog, card, valuation.currency, today),
|
|
63
164
|
activated: held.activated,
|
|
165
|
+
toActivate: toActivate(catalog, card, held, today),
|
|
64
166
|
anniversary: held.anniversary ?? null,
|
|
65
167
|
});
|
|
66
168
|
}
|
package/dist/format/offers.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { feeText, money } from "../engine/text.js";
|
|
2
2
|
const TOP = 10;
|
|
3
3
|
function reward(o, currencyName) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
const amount = o.dollars !== null
|
|
5
|
+
? money(o.dollars)
|
|
6
|
+
: `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
|
|
7
|
+
return o.varies ? `Up to ${amount}` : amount;
|
|
7
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
|
+
});
|
|
8
15
|
export function formatOffers(c, currencyName, all) {
|
|
9
16
|
if (c.offers.length === 0) {
|
|
10
17
|
return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
|
|
@@ -19,8 +26,7 @@ export function formatOffers(c, currencyName, all) {
|
|
|
19
26
|
? `after ${money(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
|
|
20
27
|
: 'with no spending required';
|
|
21
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.' : ''}`);
|
|
22
|
-
|
|
23
|
-
lines.push(` Offer ends ${o.validTo}.`);
|
|
29
|
+
lines.push(` ${o.validTo ? `Offer ends ${o.validTo}. ` : ''}Public offer as of ${asOf(o.lastVerified)}; yours may differ.`);
|
|
24
30
|
if (o.note)
|
|
25
31
|
lines.push(` ${o.note}`);
|
|
26
32
|
});
|
package/dist/format/wallet.js
CHANGED
|
@@ -1,26 +1,55 @@
|
|
|
1
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
|
+
});
|
|
2
15
|
export function formatWalletSummary(summary) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
.filter((cap) => cap.remaining !== null)
|
|
7
|
-
.map((cap) => `${cap.key} ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''}`);
|
|
8
|
-
const chosen = c.chosen
|
|
9
|
-
? `; chosen categories for ${c.chosen.period}${c.chosen.current ? '' : ' (out of date)'}`
|
|
16
|
+
const cards = summary.cards.map((c) => {
|
|
17
|
+
const label = c.checkoutLabel
|
|
18
|
+
? `, shown at checkout as "${c.checkoutLabel}"`
|
|
10
19
|
: '';
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
const lines = [
|
|
21
|
+
`- ${c.cardName} (${c.cardId})${label}: ${NETWORKS[c.network] ?? c.network}, ${c.currency} at ${String(c.centsPerUnit)}¢. Data checked ${c.lastVerified}${c.dataIsStale ? ' (old; check the issuer)' : ''}.`,
|
|
22
|
+
` Earns: ${c.rates.join('; ')}.`,
|
|
23
|
+
];
|
|
24
|
+
if (c.onlyAt)
|
|
25
|
+
lines.push(` Works only at ${c.onlyAt.join(', ')}.`);
|
|
26
|
+
if (c.chosen) {
|
|
27
|
+
const picks = Object.entries(c.chosen.pickNames)
|
|
28
|
+
.map(([choice, names]) => `${choice}: ${names.join(', ')}`)
|
|
29
|
+
.join('; ');
|
|
30
|
+
const until = c.chosen.until ? `, through ${day(c.chosen.until)}` : '';
|
|
31
|
+
lines.push(c.chosen.current
|
|
32
|
+
? ` Picks for ${c.chosen.period}${until}: ${picks}. Pick again after that.`
|
|
33
|
+
: ` Picks are for ${c.chosen.period}, which is over: ask for this period's picks.`);
|
|
34
|
+
}
|
|
35
|
+
if (c.toActivate.length > 0)
|
|
36
|
+
lines.push(` Not activated yet: ${c.toActivate.join('; ')}. Activating once with the issuer covers each group.`);
|
|
37
|
+
if (c.rotationGap !== null)
|
|
38
|
+
lines.push(` Rotating categories for ${c.rotationGap} aren't in earnrate yet.`);
|
|
39
|
+
for (const cap of c.caps)
|
|
40
|
+
if (cap.remaining !== null)
|
|
41
|
+
lines.push(` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''} (cap key ${cap.key}).`);
|
|
15
42
|
const b = c.bonus;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.join('
|
|
43
|
+
if (b?.state === 'open' && b.spend !== null)
|
|
44
|
+
lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
|
|
45
|
+
else if (b)
|
|
46
|
+
lines.push(` Welcome bonus: ${b.state}.`);
|
|
47
|
+
return lines.join('\n');
|
|
48
|
+
});
|
|
49
|
+
const memberships = summary.memberships.length > 0
|
|
50
|
+
? `Memberships: ${summary.memberships.join(', ')}.`
|
|
51
|
+
: 'Memberships: none saved.';
|
|
52
|
+
return [...cards, memberships].join('\n');
|
|
24
53
|
}
|
|
25
54
|
export function formatBooking(amount, cardId, cap, bonus) {
|
|
26
55
|
const messages = [];
|
package/dist/mcp/context.js
CHANGED
|
@@ -7,7 +7,7 @@ export const fail = (text) => ({
|
|
|
7
7
|
content: [{ type: 'text', text }],
|
|
8
8
|
isError: true,
|
|
9
9
|
});
|
|
10
|
-
export const ALL_CARDS_NOTE = "
|
|
10
|
+
export const ALL_CARDS_NOTE = "No cards are saved or named, so this covers every card earnrate knows. That's a complete answer. If the user wants the best of their own cards, they can name them (pass them as `cards`); saving them is optional.";
|
|
11
11
|
// What every tool shares: the saved wallet, merchant resolution, and which
|
|
12
12
|
// cards an answer is about.
|
|
13
13
|
export function makeContext(deps) {
|
|
@@ -15,7 +15,9 @@ export function makeContext(deps) {
|
|
|
15
15
|
const theWallet = deps.savedWallets === 'file'
|
|
16
16
|
? `the wallet at ${deps.store.where}`
|
|
17
17
|
: 'the saved wallet';
|
|
18
|
-
const noWallet =
|
|
18
|
+
const noWallet = deps.savedWallets === 'file'
|
|
19
|
+
? `There's no saved wallet yet (${deps.store.where}). Answers don't need one: they cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.`
|
|
20
|
+
: "The user's saved wallet has no cards yet. Answers cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.";
|
|
19
21
|
// Read on every call, so edits to the wallet apply without a restart.
|
|
20
22
|
async function wallet() {
|
|
21
23
|
let loaded;
|
|
@@ -79,7 +81,9 @@ export function makeContext(deps) {
|
|
|
79
81
|
};
|
|
80
82
|
const text = [
|
|
81
83
|
message,
|
|
82
|
-
|
|
84
|
+
output.cardIds.length > 0
|
|
85
|
+
? `Wallet: ${output.cardIds.join(', ')}.`
|
|
86
|
+
: 'The wallet has no cards now; answers cover every card.',
|
|
83
87
|
...(notes ? [formatNotes(result.notes)] : []),
|
|
84
88
|
].join('\n');
|
|
85
89
|
return {
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -46,8 +46,9 @@ const cardFields = {
|
|
|
46
46
|
checkoutLabel: z
|
|
47
47
|
.string()
|
|
48
48
|
.min(1)
|
|
49
|
+
.nullable()
|
|
49
50
|
.optional()
|
|
50
|
-
.describe('How the card appears at checkout or in a browser wallet, e.g. "Amex Gold". Optional.'),
|
|
51
|
+
.describe('How the card appears at checkout or in a browser wallet, e.g. "Amex Gold". Optional; null goes back to the card\'s own name.'),
|
|
51
52
|
picks: z
|
|
52
53
|
.record(z.string(), z.array(z.string()).min(1))
|
|
53
54
|
.optional()
|
|
@@ -61,6 +62,10 @@ const cardFields = {
|
|
|
61
62
|
.array(z.string())
|
|
62
63
|
.optional()
|
|
63
64
|
.describe('Rule ids the user has activated with the issuer, such as rotating categories. Replaces the current list.'),
|
|
65
|
+
capSpent: z
|
|
66
|
+
.record(z.string(), z.number().nonnegative())
|
|
67
|
+
.optional()
|
|
68
|
+
.describe('Dollars already spent this period on a capped bonus, for a card the user starts tracking partway through: cap key (as list_wallet shows it) → dollars, e.g. {"five-percent-quarterly": 400}. Ask; recorded purchases add to it from then on.'),
|
|
64
69
|
bonus: z
|
|
65
70
|
.object({
|
|
66
71
|
opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
|
|
@@ -95,10 +100,12 @@ export function cardInput({ cardId, bonus, ...rest }) {
|
|
|
95
100
|
}
|
|
96
101
|
// Inputs that list the catalog's categories, issuers and memberships.
|
|
97
102
|
export function makeInputs(catalog) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.
|
|
103
|
+
// A parent with subcategories and no merchant codes of its own ("Grocery")
|
|
104
|
+
// isn't something a purchase codes as, so it's left out.
|
|
105
|
+
const categories = catalog.categories.filter((c) => c.mccs.length > 0 ||
|
|
106
|
+
!catalog.categories.some((k) => k.id.startsWith(`${c.id}.`)));
|
|
107
|
+
const categoryIds = categories.map((c) => c.id);
|
|
108
|
+
const categoryList = categories.map((c) => `${c.id} (${c.name})`).join(', ');
|
|
102
109
|
const membershipIds = knownMemberships(catalog);
|
|
103
110
|
const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
|
|
104
111
|
const CategoryInput = z
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -106,6 +106,7 @@ export const WalletSummarySchema = z.object({
|
|
|
106
106
|
rotationGap: z.string().nullable(),
|
|
107
107
|
caps: z.array(z.object({
|
|
108
108
|
key: z.string(),
|
|
109
|
+
label: z.string(),
|
|
109
110
|
rules: z.array(z.string()),
|
|
110
111
|
amount: z.number(),
|
|
111
112
|
period: CapPeriod,
|
|
@@ -117,10 +118,14 @@ export const WalletSummarySchema = z.object({
|
|
|
117
118
|
.object({
|
|
118
119
|
period: z.string(),
|
|
119
120
|
current: z.boolean(),
|
|
121
|
+
until: z.string().nullable(),
|
|
120
122
|
picks: z.record(z.string(), z.array(z.string())),
|
|
123
|
+
pickNames: z.record(z.string(), z.array(z.string())),
|
|
121
124
|
})
|
|
122
125
|
.nullable(),
|
|
126
|
+
rates: z.array(z.string()),
|
|
123
127
|
activated: z.array(z.string()),
|
|
128
|
+
toActivate: z.array(z.string()),
|
|
124
129
|
anniversary: z.string().nullable(),
|
|
125
130
|
})),
|
|
126
131
|
});
|
|
@@ -161,6 +166,7 @@ export const OfferComparisonSchema = z.object({
|
|
|
161
166
|
spend: z.number(),
|
|
162
167
|
months: z.number(),
|
|
163
168
|
validTo: z.string().nullable(),
|
|
169
|
+
varies: z.boolean(), // the amount is the most the issuer shows
|
|
164
170
|
note: z.string().nullable(),
|
|
165
171
|
annualFee: z.number().nullable(),
|
|
166
172
|
firstYearFee: z.number().nullable(),
|
package/dist/mcp/server.js
CHANGED
|
@@ -15,8 +15,7 @@ export const ANSWER_TOOLS = [
|
|
|
15
15
|
'resolve_merchant',
|
|
16
16
|
'search_cards',
|
|
17
17
|
];
|
|
18
|
-
// The tools that read or change a saved wallet.
|
|
19
|
-
// sign-in before running one.
|
|
18
|
+
// The tools that read or change a saved wallet.
|
|
20
19
|
export const SAVED_WALLET_TOOLS = [
|
|
21
20
|
'list_wallet',
|
|
22
21
|
'record_purchase',
|
|
@@ -27,14 +26,15 @@ export const SAVED_WALLET_TOOLS = [
|
|
|
27
26
|
'export_wallet',
|
|
28
27
|
'import_wallet',
|
|
29
28
|
'delete_wallet',
|
|
29
|
+
'switch_wallet',
|
|
30
30
|
];
|
|
31
31
|
const SAVING = {
|
|
32
32
|
file: 'Only if they want earnrate to remember their cards, track spending caps or welcome bonuses, or record purchases, save a wallet with add_card and the other wallet tools.',
|
|
33
|
-
account: "
|
|
33
|
+
account: "The user signed in to earnrate when they connected it, to an anonymous saved wallet (no email or name). It may have no cards yet; answers then cover every card. Save cards with add_card only when the user agrees. The wallet can change outside this conversation when the user signs in again on earnrate's page, so read it with list_wallet rather than from memory. To use a different wallet or start a new one, call switch_wallet; the user then connects on earnrate's page with that wallet's recovery code. Never ask for the recovery code in the conversation; it's typed only on earnrate's page. To change the code, the user opens https://mcp.earnrate.dev/change-code. earnrate.dev and the earnrate command line keep their own separate wallets; export_wallet and import_wallet move one between them.",
|
|
34
34
|
};
|
|
35
35
|
const instructions = (savedWallets) => `earnrate answers which credit card earns the most for a purchase, from each issuer's own terms. Anyone can use it without entering anything: with no cards given, best_card_for_purchase, best_cards_for, cheat_sheet, simulate_spending and compare_welcome_offers answer from every card earnrate knows, and that's a complete answer. Never require the user's cards, and don't ask what they spend unless the answer depends on it; simulate_spending assumes typical amounts when none are given and says so.
|
|
36
36
|
|
|
37
|
-
Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing.
|
|
37
|
+
Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. After answering, offer once to save those cards so earnrate remembers them, and save them with add_card only if the user says yes. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing.
|
|
38
38
|
|
|
39
39
|
The wallet lists cards by product id, never card numbers; never ask for card numbers, expiry dates or security codes.`;
|
|
40
40
|
export function createEarnrateServer(deps) {
|
|
@@ -23,13 +23,17 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
23
23
|
});
|
|
24
24
|
server.registerTool('import_wallet', {
|
|
25
25
|
title: 'Import a wallet',
|
|
26
|
-
description: 'Replaces the saved wallet with a wallet.yaml from the earnrate command line or earnrate.dev, when the user pastes or attaches one. Wallet files only: never statements or card numbers.
|
|
26
|
+
description: 'Replaces the saved wallet with a wallet.yaml from the earnrate command line or earnrate.dev, when the user pastes or attaches one. Wallet files only: never statements or card numbers. If cards are already saved, it refuses unless replace is true: confirm with the user first.',
|
|
27
27
|
inputSchema: z.object({
|
|
28
28
|
yaml: z
|
|
29
29
|
.string()
|
|
30
30
|
.min(1)
|
|
31
31
|
.max(100_000)
|
|
32
32
|
.describe('The wallet.yaml text, as the user gave it.'),
|
|
33
|
+
replace: z
|
|
34
|
+
.boolean()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe('true to replace a wallet that already has cards, after the user confirms.'),
|
|
33
37
|
}),
|
|
34
38
|
outputSchema: WalletChangeSchema,
|
|
35
39
|
annotations: {
|
|
@@ -38,17 +42,24 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
38
42
|
idempotentHint: true,
|
|
39
43
|
openWorldHint: false,
|
|
40
44
|
},
|
|
41
|
-
}, async ({ yaml }) => {
|
|
45
|
+
}, async ({ yaml, replace }) => {
|
|
42
46
|
const parsed = parseWalletYaml(yaml, catalog);
|
|
43
47
|
if (typeof parsed === 'string')
|
|
44
48
|
return fail(parsed);
|
|
49
|
+
const current = await store.load().catch(() => null);
|
|
50
|
+
if (current !== null && current.cards.length > 0 && replace !== true)
|
|
51
|
+
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.`);
|
|
45
52
|
const count = parsed.cards.length;
|
|
46
53
|
return ctx.changed(await store.replace(parsed), `Imported a wallet with ${String(count)} ${count === 1 ? 'card' : 'cards'}.`, { notes: false });
|
|
47
54
|
});
|
|
48
55
|
server.registerTool('delete_wallet', {
|
|
49
56
|
title: 'Delete the wallet',
|
|
50
|
-
description:
|
|
51
|
-
inputSchema: z.object({
|
|
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 first, and pass confirm only after the user confirms. It refuses when the wallet has no cards, since there's nothing to delete.",
|
|
58
|
+
inputSchema: z.object({
|
|
59
|
+
confirm: z
|
|
60
|
+
.literal(true)
|
|
61
|
+
.describe('true, only after the user confirms the deletion.'),
|
|
62
|
+
}),
|
|
52
63
|
outputSchema: z.object({ deleted: z.boolean() }),
|
|
53
64
|
annotations: {
|
|
54
65
|
readOnlyHint: false,
|
|
@@ -57,15 +68,41 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
57
68
|
openWorldHint: false,
|
|
58
69
|
},
|
|
59
70
|
}, async () => {
|
|
71
|
+
const current = await store.load().catch(() => null);
|
|
72
|
+
if (current === null || current.cards.length === 0)
|
|
73
|
+
return fail("This saved wallet has no cards, so there's nothing to delete. If the user meant a different wallet, it may already be deleted.");
|
|
60
74
|
await store.erase();
|
|
61
75
|
return {
|
|
62
76
|
content: [
|
|
63
77
|
{
|
|
64
78
|
type: 'text',
|
|
65
|
-
text:
|
|
79
|
+
text: "Deleted the saved wallet and its recovery code, and signed out every Claude app connected to it, this one included. To keep using earnrate here, the user connects again on earnrate's page and starts a new wallet.",
|
|
66
80
|
},
|
|
67
81
|
],
|
|
68
82
|
structuredContent: { deleted: true },
|
|
69
83
|
};
|
|
70
84
|
});
|
|
85
|
+
server.registerTool('switch_wallet', {
|
|
86
|
+
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.",
|
|
88
|
+
inputSchema: z.object({}),
|
|
89
|
+
outputSchema: z.object({ disconnected: z.boolean() }),
|
|
90
|
+
annotations: {
|
|
91
|
+
readOnlyHint: false,
|
|
92
|
+
destructiveHint: false,
|
|
93
|
+
idempotentHint: true,
|
|
94
|
+
openWorldHint: false,
|
|
95
|
+
},
|
|
96
|
+
}, async () => {
|
|
97
|
+
await store.disconnect();
|
|
98
|
+
return {
|
|
99
|
+
content: [
|
|
100
|
+
{
|
|
101
|
+
type: 'text',
|
|
102
|
+
text: "Disconnected from this saved wallet; nothing was deleted. earnrate's sign-in page opens on the next earnrate request (or reconnect earnrate in the app's connector settings). There the user chooses \"Already have a wallet?\" and types the other wallet's recovery code, or starts a new wallet. Other Claude accounts connected to this wallet through the same app are signed out too.",
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
structuredContent: { disconnected: true },
|
|
106
|
+
};
|
|
107
|
+
});
|
|
71
108
|
}
|
|
@@ -125,6 +125,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
125
125
|
description: "Ranks the public welcome (sign-up) offers on cards the user doesn't have by first-year value: the bonus at the user's point values minus the first year's annual fee. Use it when the user asks which new card to get or which has the best sign-up bonus. It covers only the cards earnrate knows, offers vary by person, and each offer's note gives the issuer's eligibility rules; pass those caveats on. It doesn't account for what a card would earn on the user's spending.",
|
|
126
126
|
inputSchema: z.object({
|
|
127
127
|
...OfferFilters,
|
|
128
|
+
valuation: CatalogFilters.valuation,
|
|
128
129
|
includeHeld: z
|
|
129
130
|
.boolean()
|
|
130
131
|
.optional()
|
|
@@ -137,7 +138,8 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
137
138
|
const wallet = await ctx.cardsFor(args.cards, undefined);
|
|
138
139
|
if (typeof wallet === 'string')
|
|
139
140
|
return fail(wallet);
|
|
140
|
-
const
|
|
141
|
+
const base = wallet ?? EMPTY_WALLET;
|
|
142
|
+
const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
|
|
141
143
|
kind: args.kind ?? 'any',
|
|
142
144
|
noAnnualFee: args.noAnnualFee ?? false,
|
|
143
145
|
business: args.business ?? false,
|
|
@@ -273,11 +275,16 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
273
275
|
});
|
|
274
276
|
server.registerTool('cheat_sheet', {
|
|
275
277
|
title: 'Cheat sheet',
|
|
276
|
-
description: "The card to use for each everyday kind of purchase right now: groceries, dining, gas, Amazon, Costco, travel, streaming, drugstores and everything else. With no cards given and no saved wallet, it picks from every card earnrate knows (`scope: all cards`, each row noting the card's fee and conditions). With cards named in the conversation or a saved wallet, it picks from the user's own cards, with caps, picks and activations as they are. For one specific store, call best_card_for_purchase instead. Pass on the notes.",
|
|
278
|
+
description: "The card to use for each everyday kind of purchase right now: groceries, dining, gas, Amazon, Costco, travel, streaming, drugstores and everything else. With no cards given and no saved wallet, it picks from every card earnrate knows (`scope: all cards`, each row noting the card's fee and conditions). With cards named in the conversation or a saved wallet, it picks from the user's own cards, with caps, picks and activations as they are. The kind, noAnnualFee, business and issuer filters apply only when it picks from every card. For one specific store, call best_card_for_purchase instead. Pass on the notes.",
|
|
277
279
|
inputSchema: z.object({
|
|
278
280
|
date: DateInput,
|
|
279
281
|
cards: NamedCards,
|
|
280
282
|
memberships: NamedMemberships,
|
|
283
|
+
kind: CatalogFilters.kind,
|
|
284
|
+
noAnnualFee: CatalogFilters.noAnnualFee,
|
|
285
|
+
business: CatalogFilters.business,
|
|
286
|
+
issuer: CatalogFilters.issuer,
|
|
287
|
+
valuation: CatalogFilters.valuation,
|
|
281
288
|
}),
|
|
282
289
|
outputSchema: CheatSheetSchema,
|
|
283
290
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
@@ -288,10 +295,12 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
288
295
|
const date = args.date ?? deps.today();
|
|
289
296
|
const sheet = wallet === null
|
|
290
297
|
? {
|
|
291
|
-
...catalogCheatSheet(catalog, date, settings(
|
|
298
|
+
...catalogCheatSheet(catalog, date, settings(args)),
|
|
292
299
|
notes: [ALL_CARDS_NOTE],
|
|
293
300
|
}
|
|
294
|
-
: cheatSheet(catalog,
|
|
301
|
+
: cheatSheet(catalog, args.valuation
|
|
302
|
+
? withValuationProfile(wallet, args.valuation)
|
|
303
|
+
: wallet, date);
|
|
295
304
|
return {
|
|
296
305
|
content: [{ type: 'text', text: formatCheatSheet(sheet) }],
|
|
297
306
|
structuredContent: sheet,
|
package/dist/schema/card.js
CHANGED
|
@@ -75,6 +75,10 @@ export const WelcomeOfferSchema = z
|
|
|
75
75
|
spend: z.number().nonnegative(), // dollars of purchases required
|
|
76
76
|
months: z.number().int().positive(), // from account opening
|
|
77
77
|
valid_to: IsoDate.optional(), // when the issuer says the offer ends
|
|
78
|
+
// The issuer shows each applicant their own amount ("as high as"), or
|
|
79
|
+
// the page showed different offers on different visits. The amount
|
|
80
|
+
// above is then the most it shows.
|
|
81
|
+
varies: z.literal(true).optional(),
|
|
78
82
|
note: z.string().optional(),
|
|
79
83
|
})
|
|
80
84
|
.refine((o) => (o.points === undefined) !== (o.dollars === undefined), 'a welcome offer needs points or dollars, not both');
|