earnrate 0.2.2 → 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 +13 -12
- 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 +12 -1
- 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/file-store.js +3 -1
- package/dist/data/integrity.js +10 -0
- package/dist/data/store.js +9 -1
- 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 +91 -31
- package/dist/engine/setup.js +23 -4
- package/dist/engine/simulate.js +32 -10
- package/dist/engine/text.js +51 -0
- package/dist/engine/wallet-change.js +27 -7
- package/dist/engine/wallet-summary.js +133 -3
- 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 +21 -18
- package/dist/mcp/context.js +7 -3
- package/dist/mcp/inputs.js +2 -2
- package/dist/mcp/schemas.js +18 -0
- package/dist/mcp/server.js +3 -3
- package/dist/mcp/tools/account.js +55 -7
- 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
package/dist/engine/text.js
CHANGED
|
@@ -2,6 +2,15 @@ import { singleMcc } from "./evaluate.js";
|
|
|
2
2
|
export const num = (n) => String(Number(n.toFixed(2)));
|
|
3
3
|
export const pct = (n) => `${num(n)}%`;
|
|
4
4
|
export const roundCents = (n) => Math.round(n * 100) / 100;
|
|
5
|
+
// An id in words: "gas-ev-charging" reads "gas ev charging".
|
|
6
|
+
export const words = (id) => id.replace(/[-_.]/g, ' ');
|
|
7
|
+
// "Dec 31, 2026" from YYYY-MM-DD, the same in every time zone.
|
|
8
|
+
export const shortDate = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
|
|
9
|
+
month: 'short',
|
|
10
|
+
day: 'numeric',
|
|
11
|
+
year: 'numeric',
|
|
12
|
+
timeZone: 'UTC',
|
|
13
|
+
});
|
|
5
14
|
// Whole dollars stay whole ($2,800); anything else shows cents ($0.40).
|
|
6
15
|
export const money = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
7
16
|
export function feeText(fees) {
|
|
@@ -51,3 +60,45 @@ export function pointValuesText(catalog, shown) {
|
|
|
51
60
|
const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
|
|
52
61
|
return `Values points at: ${list.join(', ')}.`;
|
|
53
62
|
}
|
|
63
|
+
export const NETWORK_NAMES = {
|
|
64
|
+
visa: 'Visa',
|
|
65
|
+
mastercard: 'Mastercard',
|
|
66
|
+
amex: 'American Express',
|
|
67
|
+
discover: 'Discover',
|
|
68
|
+
store: 'store card',
|
|
69
|
+
};
|
|
70
|
+
// A name with its own "and" ("drugstores and pharmacies") would run into the
|
|
71
|
+
// list's: those lists use commas only, with those names last.
|
|
72
|
+
export const listed = (parts) => {
|
|
73
|
+
const anded = parts.filter((p) => p.includes(' and '));
|
|
74
|
+
if (anded.length > 0)
|
|
75
|
+
return [...parts.filter((p) => !p.includes(' and ')), ...anded].join(', ');
|
|
76
|
+
return parts.length <= 2
|
|
77
|
+
? parts.join(' and ')
|
|
78
|
+
: `${parts.slice(0, -1).join(', ')} and ${parts.at(-1) ?? ''}`;
|
|
79
|
+
};
|
|
80
|
+
const NUMBERS = {
|
|
81
|
+
one: '1',
|
|
82
|
+
two: '2',
|
|
83
|
+
three: '3',
|
|
84
|
+
four: '4',
|
|
85
|
+
five: '5',
|
|
86
|
+
six: '6',
|
|
87
|
+
};
|
|
88
|
+
// "five-percent" reads "5%"; other ids read as words.
|
|
89
|
+
export function choiceName(id) {
|
|
90
|
+
return words(id.replace(/\b(one|two|three|four|five|six)-percent\b/g, (_, n) => `${NUMBERS[n] ?? n}%`));
|
|
91
|
+
}
|
|
92
|
+
// Display names for membership ids in the card data.
|
|
93
|
+
export const MEMBERSHIPS = {
|
|
94
|
+
'amazon-prime': 'Amazon Prime',
|
|
95
|
+
'bank-of-america-account': 'Bank of America checking or savings',
|
|
96
|
+
'bank-of-america-small-business-account': 'Bank of America small business checking or savings',
|
|
97
|
+
'bofa-rewards-member': 'BofA Rewards Member',
|
|
98
|
+
'bofa-rewards-preferred-plus': 'BofA Rewards Preferred Plus',
|
|
99
|
+
'bofa-rewards-preferred-honors': 'BofA Rewards Preferred Honors',
|
|
100
|
+
'bofa-rewards-premier': 'BofA Rewards Premier',
|
|
101
|
+
costco: 'Costco membership',
|
|
102
|
+
'nordy-club-ambassador': 'Nordy Club Ambassador',
|
|
103
|
+
};
|
|
104
|
+
export const membershipName = (id) => MEMBERSHIPS[id] ?? words(id);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { WALLET_VERSION, } from "../schema/wallet.js";
|
|
2
|
+
import { addMonths } from "./bonus.js";
|
|
1
3
|
import { capByKey } from "./evaluate.js";
|
|
2
4
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
3
5
|
import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
|
|
@@ -132,11 +134,25 @@ export function cardFields(card, held, input, today) {
|
|
|
132
134
|
problems.push(`"${b.by}" isn't a deadline after the opening date.`);
|
|
133
135
|
if (!card.welcome_offer && (b.spend === undefined || b.by === undefined))
|
|
134
136
|
problems.push(`earnrate has no published welcome offer for ${card.id}, so give the spending needed and the deadline.`);
|
|
137
|
+
// The terms are saved with the bonus, so a later change to the published
|
|
138
|
+
// offer, or a wallet exported and imported again, keeps the user's own.
|
|
139
|
+
const offer = card.welcome_offer;
|
|
140
|
+
const before = held?.bonus?.opened === b.opened ? held.bonus : undefined;
|
|
141
|
+
const spend = b.spend ?? before?.spend ?? offer?.spend;
|
|
142
|
+
const by = b.by ??
|
|
143
|
+
before?.by ??
|
|
144
|
+
(offer && isDate(b.opened)
|
|
145
|
+
? addMonths(b.opened, offer.months)
|
|
146
|
+
: undefined);
|
|
147
|
+
const points = before?.points ?? (before?.dollars ? undefined : offer?.points);
|
|
148
|
+
const dollars = before?.dollars ?? (points !== undefined ? undefined : offer?.dollars);
|
|
135
149
|
fields.bonus = {
|
|
136
150
|
opened: b.opened,
|
|
137
151
|
spent: b.spent ?? held?.bonus?.spent ?? 0,
|
|
138
|
-
...(
|
|
139
|
-
...(
|
|
152
|
+
...(spend === undefined ? {} : { spend }),
|
|
153
|
+
...(by === undefined ? {} : { by }),
|
|
154
|
+
...(points === undefined ? {} : { points }),
|
|
155
|
+
...(dollars === undefined ? {} : { dollars }),
|
|
140
156
|
};
|
|
141
157
|
}
|
|
142
158
|
return problems.length > 0 ? problems.join('\n') : fields;
|
|
@@ -173,7 +189,12 @@ export function addToWallet(catalog, wallet, input, today) {
|
|
|
173
189
|
const held = withFields(newHolding(card.id), fields);
|
|
174
190
|
const next = wallet
|
|
175
191
|
? { ...wallet, cards: [...wallet.cards, held] }
|
|
176
|
-
: {
|
|
192
|
+
: {
|
|
193
|
+
version: WALLET_VERSION,
|
|
194
|
+
valuations: {},
|
|
195
|
+
memberships: [],
|
|
196
|
+
cards: [held],
|
|
197
|
+
};
|
|
177
198
|
return {
|
|
178
199
|
ok: true,
|
|
179
200
|
wallet: next,
|
|
@@ -208,9 +229,8 @@ export function removeFromWallet(wallet, id) {
|
|
|
208
229
|
if (!wallet.cards.some((c) => c.id === id)) {
|
|
209
230
|
return failed(`${id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
|
|
210
231
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
232
|
+
// Removing the last card leaves an empty wallet, which each store keeps
|
|
233
|
+
// in its own way (or refuses, for a wallet file).
|
|
214
234
|
return {
|
|
215
235
|
ok: true,
|
|
216
236
|
wallet: { ...wallet, cards: wallet.cards.filter((c) => c.id !== id) },
|
|
@@ -222,7 +242,7 @@ export function withMemberships(catalog, wallet, memberships) {
|
|
|
222
242
|
const known = knownMemberships(catalog);
|
|
223
243
|
const unknown = memberships.filter((m) => !known.includes(m));
|
|
224
244
|
if (unknown.length > 0) {
|
|
225
|
-
return failed(`No card
|
|
245
|
+
return failed(`No card uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
|
|
226
246
|
}
|
|
227
247
|
return {
|
|
228
248
|
ok: true,
|
|
@@ -1,10 +1,120 @@
|
|
|
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, choiceName, currencyLabel, earnText, listed, words, } from "./text.js";
|
|
6
8
|
import { cardValuation } from "./valuation.js";
|
|
7
|
-
|
|
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/;
|
|
12
|
+
function merchantName(catalog, id) {
|
|
13
|
+
const all = [...catalog.merchants.overrides, ...catalog.merchants.seed];
|
|
14
|
+
const name = all.find((m) => m.id === id)?.name ?? words(id);
|
|
15
|
+
return name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`);
|
|
16
|
+
}
|
|
17
|
+
function optionName(catalog, card, choiceId, option) {
|
|
18
|
+
const o = card.choices.find((c) => c.id === choiceId)?.options[option];
|
|
19
|
+
if (!o)
|
|
20
|
+
return words(option);
|
|
21
|
+
const parts = [
|
|
22
|
+
...o.categories.map((c) => categoryPhrase(catalog, c)),
|
|
23
|
+
...o.merchants.map((m) => merchantName(catalog, m)),
|
|
24
|
+
];
|
|
25
|
+
if (o.online)
|
|
26
|
+
parts.push('online shopping');
|
|
27
|
+
return parts.length > 0 ? listed(parts) : words(option);
|
|
28
|
+
}
|
|
29
|
+
// What a rule covers, as a list of phrases.
|
|
30
|
+
function ruleParts(catalog, rule) {
|
|
31
|
+
if (rule.match.choice !== undefined)
|
|
32
|
+
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
33
|
+
const parts = [
|
|
34
|
+
...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
35
|
+
...(rule.match.merchants ?? []).map((m) => merchantName(catalog, m)),
|
|
36
|
+
];
|
|
37
|
+
return parts.length > 0 ? parts : [words(rule.id)];
|
|
38
|
+
}
|
|
39
|
+
function ruleWhat(catalog, rule) {
|
|
40
|
+
return listed(ruleParts(catalog, rule));
|
|
41
|
+
}
|
|
42
|
+
function periodEnd(period) {
|
|
43
|
+
const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
|
|
44
|
+
if (quarter) {
|
|
45
|
+
const end = ['03-31', '06-30', '09-30', '12-31'][Number(quarter[2]) - 1];
|
|
46
|
+
return `${quarter[1] ?? ''}-${end ?? ''}`;
|
|
47
|
+
}
|
|
48
|
+
const month = /^(\d{4})-(\d{2})$/.exec(period);
|
|
49
|
+
if (month) {
|
|
50
|
+
const last = new Date(Date.UTC(Number(month[1]), Number(month[2]), 0));
|
|
51
|
+
return last.toISOString().slice(0, 10);
|
|
52
|
+
}
|
|
53
|
+
return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
|
|
54
|
+
}
|
|
55
|
+
const applies = (rule, today) => (rule.valid_from === undefined || rule.valid_from <= today) &&
|
|
56
|
+
(rule.valid_to === undefined || rule.valid_to >= today);
|
|
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
|
+
}
|
|
94
|
+
return [
|
|
95
|
+
...[...lines].map(([key, whats]) => `${key.replace('|activate', '')} ${listed(whats)}${key.endsWith('|activate') ? ' (once activated)' : ''}`),
|
|
96
|
+
`${earnText(catalog, card.base_rate, currency)} on everything else`,
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
function toActivate(catalog, card, held, today) {
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const id of activationRules(card, today)) {
|
|
103
|
+
if (seen.has(id) || isActivated(card, held, id))
|
|
104
|
+
continue;
|
|
105
|
+
const group = activationGroup(card, id);
|
|
106
|
+
for (const g of group)
|
|
107
|
+
seen.add(g);
|
|
108
|
+
const rules = card.rules.filter((r) => group.includes(r.id));
|
|
109
|
+
const first = rules[0];
|
|
110
|
+
const when = first?.valid_from !== undefined && first.valid_to !== undefined
|
|
111
|
+
? ` (${first.valid_from} to ${first.valid_to})`
|
|
112
|
+
: '';
|
|
113
|
+
out.push(`${rules.map((r) => ruleWhat(catalog, r)).join(', ')}${when}`);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
function capSummaries(catalog, card, held, today) {
|
|
8
118
|
const caps = new Map();
|
|
9
119
|
for (const shared of card.caps)
|
|
10
120
|
caps.set(shared.id, { cap: shared, rules: [] });
|
|
@@ -20,8 +130,15 @@ function capSummaries(card, held, today) {
|
|
|
20
130
|
}
|
|
21
131
|
return [...caps].map(([key, { cap, rules }]) => {
|
|
22
132
|
const { period: currentPeriod, spent } = capUsed(cap, key, held, today);
|
|
133
|
+
const covered = card.rules.filter((r) => rules.includes(r.id));
|
|
134
|
+
const shared = card.caps.find((c) => c.id === key);
|
|
135
|
+
const label = shared?.rotating
|
|
136
|
+
? 'the rotating bonus categories'
|
|
137
|
+
: [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join(', ') ||
|
|
138
|
+
words(key);
|
|
23
139
|
return {
|
|
24
140
|
key,
|
|
141
|
+
label,
|
|
25
142
|
rules,
|
|
26
143
|
amount: cap.amount,
|
|
27
144
|
period: cap.period,
|
|
@@ -47,20 +164,33 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
47
164
|
onlyAt: card.only_at ?? null,
|
|
48
165
|
bonus: bonusStatus(catalog, wallet, held, today),
|
|
49
166
|
currency: valuation.currency,
|
|
167
|
+
currencyName: valuation.currency === 'cash'
|
|
168
|
+
? 'cash back'
|
|
169
|
+
: currencyLabel(catalog, valuation.currency),
|
|
50
170
|
centsPerUnit: valuation.centsPerUnit,
|
|
51
171
|
lastVerified: card.last_verified,
|
|
52
172
|
verifiedBy: card.verified_by,
|
|
53
173
|
dataIsStale: daysBetween(card.last_verified, today) > STALE_AFTER_DAYS,
|
|
54
174
|
rotationGap: rotationGap(card, today),
|
|
55
|
-
caps: capSummaries(card, held, today),
|
|
175
|
+
caps: capSummaries(catalog, card, held, today),
|
|
56
176
|
chosen: held.chosen
|
|
57
177
|
? {
|
|
58
178
|
period: held.chosen.period,
|
|
59
179
|
current: pickedCategories(card, held, today).staleChoice === null,
|
|
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),
|
|
60
184
|
picks: held.chosen.picks,
|
|
185
|
+
pickNames: Object.fromEntries(Object.entries(held.chosen.picks).map(([choice, options]) => [
|
|
186
|
+
choiceName(choice),
|
|
187
|
+
options.map((o) => optionName(catalog, card, choice, o)),
|
|
188
|
+
])),
|
|
61
189
|
}
|
|
62
190
|
: null,
|
|
191
|
+
rates: headlineRates(catalog, wallet, card, held, valuation.currency, today),
|
|
63
192
|
activated: held.activated,
|
|
193
|
+
toActivate: toActivate(catalog, card, held, today),
|
|
64
194
|
anniversary: held.anniversary ?? null,
|
|
65
195
|
});
|
|
66
196
|
}
|
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,34 +1,37 @@
|
|
|
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 words = (id) => id.replace(/[-_.]/g, ' ');
|
|
1
|
+
import { money, NETWORK_NAMES, shortDate } from "../engine/text.js";
|
|
10
2
|
export function formatWalletSummary(summary) {
|
|
11
3
|
const cards = summary.cards.map((c) => {
|
|
4
|
+
const label = c.checkoutLabel
|
|
5
|
+
? `, shown at checkout as "${c.checkoutLabel}"`
|
|
6
|
+
: '';
|
|
12
7
|
const lines = [
|
|
13
|
-
`- ${c.
|
|
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)' : ''}.`,
|
|
9
|
+
` Earns: ${c.rates.join('; ')}.`,
|
|
14
10
|
];
|
|
15
11
|
if (c.onlyAt)
|
|
16
12
|
lines.push(` Works only at ${c.onlyAt.join(', ')}.`);
|
|
17
13
|
if (c.chosen) {
|
|
18
|
-
const picks = Object.entries(c.chosen.
|
|
19
|
-
.map(([choice,
|
|
14
|
+
const picks = Object.entries(c.chosen.pickNames)
|
|
15
|
+
.map(([choice, names]) => `${choice}: ${names.join(', ')}`)
|
|
20
16
|
.join('; ');
|
|
21
|
-
|
|
22
|
-
?
|
|
23
|
-
:
|
|
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.`);
|
|
24
25
|
}
|
|
25
|
-
if (c.
|
|
26
|
-
lines.push(`
|
|
26
|
+
if (c.toActivate.length > 0)
|
|
27
|
+
lines.push(` Not activated yet: ${c.toActivate.join('; ')}. Activating once with the issuer covers each group.`);
|
|
27
28
|
if (c.rotationGap !== null)
|
|
28
29
|
lines.push(` Rotating categories for ${c.rotationGap} aren't in earnrate yet.`);
|
|
29
30
|
for (const cap of c.caps)
|
|
30
31
|
if (cap.remaining !== null)
|
|
31
|
-
lines.push(` Cap ${cap.
|
|
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}.`);
|
|
32
35
|
const b = c.bonus;
|
|
33
36
|
if (b?.state === 'open' && b.spend !== null)
|
|
34
37
|
lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
|
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 {
|