earnrate 0.2.9 → 0.2.10
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/data/CHANGELOG.md +12 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-cash-preferred.yaml +3 -1
- package/data/cards/chase-ihg-one-rewards.yaml +1 -1
- package/data/cards/chase-united-quest.yaml +1 -1
- package/data/merchants/overrides.yaml +8 -2
- package/data/merchants/seed.yaml +0 -6
- package/dist/engine/explore.js +8 -4
- package/dist/engine/milestones.js +10 -2
- package/dist/engine/recommend.js +32 -8
- package/dist/engine/rule-text.js +12 -4
- package/dist/format/recommendation.js +8 -2
- package/dist/format/wallet.js +6 -2
- package/dist/mcp/schemas.js +3 -0
- package/dist/mcp/server.js +17 -1
- package/package.json +1 -1
package/data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
Every change to card rules, merchants, categories or currencies under `data/`. Bump `data/VERSION` (`YYYY.MM.DD`, plus `.N` for a second release on the same day) and add a section here in the same commit. Each row cites the issuer page the new value came from.
|
|
4
4
|
|
|
5
|
+
## 2026.11.03.4
|
|
6
|
+
|
|
7
|
+
Fixes from testing 0.2.9 in Claude. "BJ's" alone now means BJ's Wholesale Club (BJ's Restaurant & Brewhouse keeps its own names), and the streaming rules on Blue Cash Preferred, United Quest and IHG One Rewards read "select streaming services" with a few examples instead of every service. No rates changed.
|
|
8
|
+
|
|
9
|
+
| Card or merchant | Field | Old → new | Source |
|
|
10
|
+
| ------------------------ | ----------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
11
|
+
| bjs-wholesale-club | aliases | adds "BJ's" | n/a |
|
|
12
|
+
| bjs-restaurant | file | moved to overrides.yaml, so its own names still win over "BJ's" | n/a |
|
|
13
|
+
| amex-blue-cash-preferred | us-streaming note | shortened; the services earnrate doesn't know yet move to a todo | [Amex Select U.S. Streaming list](https://www.americanexpress.com/us/content/rewards-info/retail.html) |
|
|
14
|
+
| chase-united-quest | streaming note | shortened | [Chase rewards category FAQ](https://www.chase.com/personal/credit-cards/rewards-category-faq) |
|
|
15
|
+
| chase-ihg-one-rewards | streaming note | shortened | [Chase rewards category FAQ](https://www.chase.com/personal/credit-cards/rewards-category-faq) |
|
|
16
|
+
|
|
5
17
|
## 2026.11.03.3
|
|
6
18
|
|
|
7
19
|
Every card has a foreign transaction fee, from its issuer's rates-and-fees table where one is readable. Abroad, answers rank each card after its fee. Atmos Rewards Summit's 3x on foreign purchases is modeled with a new `foreign_only` geography. Six cards have no readable page stating the fee and are left as todos.
|
package/data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.11.03.
|
|
1
|
+
2026.11.03.4
|
|
@@ -61,7 +61,7 @@ rules:
|
|
|
61
61
|
}
|
|
62
62
|
rate: 6
|
|
63
63
|
geography: us_only
|
|
64
|
-
note: Only
|
|
64
|
+
note: Only the services on Amex's Select U.S. Streaming list, like Netflix, Hulu, Disney+, Spotify and Audible. Subscriptions bundled with or billed by another company (cable, phone, a car maker) may not count.
|
|
65
65
|
- id: transit
|
|
66
66
|
match: { categories: [transit] }
|
|
67
67
|
rate: 3
|
|
@@ -72,6 +72,8 @@ rules:
|
|
|
72
72
|
rate: 3
|
|
73
73
|
geography: us_only
|
|
74
74
|
note: Superstores, supermarkets and warehouse clubs that sell gas are not gas stations.
|
|
75
|
+
todo:
|
|
76
|
+
- Amex's Select U.S. Streaming list also names AMC+, Britbox, iHeartRadio, Kindle Unlimited, Luminary and MGM+, which earnrate doesn't know as stores yet.
|
|
75
77
|
# Issuer text backing each rule; `yarn check-sources` checks it weekly.
|
|
76
78
|
evidence:
|
|
77
79
|
- rule: base_rate
|
|
@@ -60,7 +60,7 @@ rules:
|
|
|
60
60
|
}
|
|
61
61
|
rate: 3
|
|
62
62
|
valid_to: 2026-12-31
|
|
63
|
-
note: Only Chase's select streaming list
|
|
63
|
+
note: Only the services on Chase's select streaming list, like Netflix, Hulu, Disney+ and Spotify. Purchases posting after Dec 31, 2026 earn the 2x base rate.
|
|
64
64
|
milestones:
|
|
65
65
|
- id: spend-15k
|
|
66
66
|
spend: 15000
|
|
@@ -64,7 +64,7 @@ rules:
|
|
|
64
64
|
],
|
|
65
65
|
}
|
|
66
66
|
rate: 2
|
|
67
|
-
note: Only Chase's select streaming list
|
|
67
|
+
note: Only the services on Chase's select streaming list, like Netflix, Hulu, Disney+ and Spotify.
|
|
68
68
|
todo:
|
|
69
69
|
- United Card Events from Chase also earn 4x; no merchant exists for them, so they aren't modeled.
|
|
70
70
|
# Issuer text backing each rule; `yarn check-sources` checks it weekly.
|
|
@@ -287,10 +287,16 @@
|
|
|
287
287
|
# drugstores" (chase.com/personal/credit-cards/rewards-category-faq).
|
|
288
288
|
- id: bjs-wholesale-club
|
|
289
289
|
name: "BJ's Wholesale Club"
|
|
290
|
-
aliases: [bjs wholesale, bjs wholesale club, bjs club]
|
|
290
|
+
aliases: [bjs, bjs wholesale, bjs wholesale club, bjs club]
|
|
291
291
|
domains: [bjs.com]
|
|
292
292
|
category: warehouse_club
|
|
293
|
-
note: Codes as a warehouse club, which Amex, Chase and Capital One leave out of their grocery bonuses.
|
|
293
|
+
note: Codes as a warehouse club, which Amex, Chase and Capital One leave out of their grocery bonuses. For the restaurant chain, ask about BJ's Restaurant & Brewhouse.
|
|
294
|
+
# Listed here, beside BJ's Wholesale Club, so "BJ's" alone means the club
|
|
295
|
+
# and the restaurant still answers to its own names.
|
|
296
|
+
- id: bjs-restaurant
|
|
297
|
+
name: "BJ's Restaurant & Brewhouse"
|
|
298
|
+
aliases: [bjs restaurant, bjs restaurant and brewhouse, bjs brewhouse]
|
|
299
|
+
category: dining
|
|
294
300
|
- id: bjs-gas
|
|
295
301
|
name: "BJ's Gas"
|
|
296
302
|
aliases: [bjs gas, bjs gas station]
|
package/data/merchants/seed.yaml
CHANGED
|
@@ -266,12 +266,6 @@
|
|
|
266
266
|
aliases: [pf changs, p f changs, p.f. changs],
|
|
267
267
|
category: dining,
|
|
268
268
|
}
|
|
269
|
-
- {
|
|
270
|
-
id: bjs-restaurant,
|
|
271
|
-
name: "BJ's Restaurant & Brewhouse",
|
|
272
|
-
aliases: [bjs restaurant, bjs restaurant and brewhouse, bjs brewhouse],
|
|
273
|
-
category: dining,
|
|
274
|
-
}
|
|
275
269
|
- { id: waffle-house, name: Waffle House, category: dining }
|
|
276
270
|
- {
|
|
277
271
|
id: tgi-fridays,
|
package/dist/engine/explore.js
CHANGED
|
@@ -2,7 +2,7 @@ import { categoryResolution, resolvePlace } from "../merchants/resolve.js";
|
|
|
2
2
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
3
|
import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
|
|
4
4
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
5
|
-
import { recommend } from "./recommend.js";
|
|
5
|
+
import { FOREIGN_ASSUMPTION, isForeign, recommend } from "./recommend.js";
|
|
6
6
|
import { rotationGap } from "./rotation.js";
|
|
7
7
|
import { membershipsUsed } from "./setup.js";
|
|
8
8
|
import { optionName } from "./rule-text.js";
|
|
@@ -129,8 +129,10 @@ function membershipUpgrades(catalog, card, held, merchant, purchase, options, cu
|
|
|
129
129
|
memberships: [...options.memberships, membership],
|
|
130
130
|
});
|
|
131
131
|
const ctx = purchaseContext(catalog, wallet, merchant, merchant.category, purchase);
|
|
132
|
+
const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
|
|
132
133
|
const pct = evaluateCard(card, held, ctx).best.rate *
|
|
133
|
-
cardValuation(catalog, wallet, card).centsPerUnit
|
|
134
|
+
cardValuation(catalog, wallet, card).centsPerUnit -
|
|
135
|
+
fee;
|
|
134
136
|
if (pct > current + EPSILON)
|
|
135
137
|
upgrades.push({ membership, effectiveReturnPct: pct });
|
|
136
138
|
}
|
|
@@ -212,11 +214,11 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
212
214
|
merchant,
|
|
213
215
|
ranked,
|
|
214
216
|
excluded,
|
|
215
|
-
assumptions: exploreAssumptions(catalog, merchant, ranked, options),
|
|
217
|
+
assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase),
|
|
216
218
|
warnings: exploreWarnings(catalog, merchant),
|
|
217
219
|
};
|
|
218
220
|
}
|
|
219
|
-
function exploreAssumptions(catalog, merchant, ranked, options) {
|
|
221
|
+
function exploreAssumptions(catalog, merchant, ranked, options, purchase) {
|
|
220
222
|
const assumptions = [];
|
|
221
223
|
if (merchant.source === 'category') {
|
|
222
224
|
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
@@ -231,6 +233,8 @@ function exploreAssumptions(catalog, merchant, ranked, options) {
|
|
|
231
233
|
const values = pointValuesText(catalog, ranked.slice(0, 10));
|
|
232
234
|
if (values !== null)
|
|
233
235
|
assumptions.push(values);
|
|
236
|
+
if (isForeign(purchase))
|
|
237
|
+
assumptions.push(FOREIGN_ASSUMPTION);
|
|
234
238
|
return assumptions;
|
|
235
239
|
}
|
|
236
240
|
function exploreWarnings(catalog, merchant) {
|
|
@@ -45,11 +45,19 @@ export function milestonePays(m) {
|
|
|
45
45
|
}
|
|
46
46
|
// "the $15,000 calendar-year spend for 10,000 bonus points"
|
|
47
47
|
export const milestoneWhat = (m) => `the ${wholeDollars(m.spend)} ${MILESTONE_PERIODS[m.period]} spend for ${milestonePays(m)}`;
|
|
48
|
-
export function reachedText(card, hit) {
|
|
48
|
+
export function reachedText(card, hit, centsPerPoint) {
|
|
49
49
|
const m = card.milestones.find((x) => x.id === hit.id);
|
|
50
50
|
if (!m)
|
|
51
51
|
return '';
|
|
52
|
-
|
|
52
|
+
const points = hit.points === null
|
|
53
|
+
? null
|
|
54
|
+
: `${hit.points.toLocaleString('en-US')} bonus points, worth about ${money(roundCents((hit.points * centsPerPoint) / 100))}`;
|
|
55
|
+
const pays = points === null
|
|
56
|
+
? listed(hit.rewards)
|
|
57
|
+
: hit.rewards.length === 0
|
|
58
|
+
? points
|
|
59
|
+
: `${points}, plus ${listed(hit.rewards)}`;
|
|
60
|
+
return `This purchase reaches the ${wholeDollars(m.spend)} ${MILESTONE_PERIODS[m.period]} spend: ${pays} (${money(hit.before)} tracked on this card in ${hit.period} before it).`;
|
|
53
61
|
}
|
|
54
62
|
const THIS_PERIOD = {
|
|
55
63
|
calendar_year: 'this year',
|
package/dist/engine/recommend.js
CHANGED
|
@@ -11,6 +11,7 @@ import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, e
|
|
|
11
11
|
import { cardValuation } from "./valuation.js";
|
|
12
12
|
export const STALE_AFTER_DAYS = 180;
|
|
13
13
|
// The engine only tells the U.S. from everywhere else.
|
|
14
|
+
export const FOREIGN_ASSUMPTION = 'Treats this as a purchase outside the U.S.: each card is ranked after its foreign transaction fee.';
|
|
14
15
|
export const isForeign = (purchase) => purchase.country !== 'US';
|
|
15
16
|
// Any purchase outside the U.S., when no country is named: ZZ is a code ISO
|
|
16
17
|
// leaves unassigned, and nothing prints it.
|
|
@@ -87,6 +88,7 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
87
88
|
perDollar,
|
|
88
89
|
fee,
|
|
89
90
|
effectiveReturnPct: perDollar * valuation.centsPerUnit - fee,
|
|
91
|
+
rateReturnPct: evaluation.best.rate * valuation.centsPerUnit - fee,
|
|
90
92
|
upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) - fee,
|
|
91
93
|
});
|
|
92
94
|
}
|
|
@@ -111,7 +113,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
111
113
|
lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, best.afterCap ?? cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
|
|
112
114
|
}
|
|
113
115
|
for (const hit of entry.milestones)
|
|
114
|
-
lines.push(reachedText(card, hit));
|
|
116
|
+
lines.push(reachedText(card, hit, valuation.centsPerUnit));
|
|
115
117
|
if (valuation.pooledFrom !== null) {
|
|
116
118
|
const partners = card.pools_into?.when_wallet_has ?? [];
|
|
117
119
|
const heldPartners = wallet.cards
|
|
@@ -122,7 +124,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
122
124
|
lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
|
|
123
125
|
}
|
|
124
126
|
if (currency !== 'cash') {
|
|
125
|
-
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.
|
|
127
|
+
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.evaluation.best.rate * valuation.centsPerUnit)} back.`);
|
|
126
128
|
}
|
|
127
129
|
return lines;
|
|
128
130
|
}
|
|
@@ -184,11 +186,19 @@ function toRanked(catalog, wallet, entry, purchase) {
|
|
|
184
186
|
rate: evaluation.best.rate,
|
|
185
187
|
currency: valuation.currency,
|
|
186
188
|
centsPerUnit: valuation.centsPerUnit,
|
|
187
|
-
effectiveReturnPct: entry.
|
|
189
|
+
effectiveReturnPct: entry.rateReturnPct,
|
|
188
190
|
foreignFee: entry.fee > 0 ? entry.fee : null,
|
|
189
191
|
earned: purchase.amount === null
|
|
190
192
|
? null
|
|
191
|
-
: Math.round(purchase.amount * entry.
|
|
193
|
+
: Math.round(purchase.amount * entry.rateReturnPct) / 100,
|
|
194
|
+
milestoneBonus: entry.milestones.length === 0
|
|
195
|
+
? null
|
|
196
|
+
: {
|
|
197
|
+
points: milestonePoints(entry.milestones),
|
|
198
|
+
dollars: roundCents((milestonePoints(entry.milestones) *
|
|
199
|
+
entry.valuation.centsPerUnit) /
|
|
200
|
+
100),
|
|
201
|
+
},
|
|
192
202
|
capped: evaluation.best.cap !== null,
|
|
193
203
|
reasoning: reasoningFor(catalog, wallet, entry, purchase.amount),
|
|
194
204
|
warnings: warningsFor(catalog, entry, purchase),
|
|
@@ -215,6 +225,8 @@ function tieNote(entries) {
|
|
|
215
225
|
// Finds the closest point inside a currency's value range where the winner
|
|
216
226
|
// stops winning, varying one currency at a time. Returns are linear in the
|
|
217
227
|
// cents-per-point value, so each pair of cards flips at most once.
|
|
228
|
+
// "Chase Ultimate Rewards points", but not "IHG One Rewards points points".
|
|
229
|
+
const pointsName = (label) => /\b(points|miles)$/i.test(label) ? label : `${label} points`;
|
|
218
230
|
function sensitivity(catalog, entries) {
|
|
219
231
|
const [winner, ...rest] = entries;
|
|
220
232
|
if (!winner)
|
|
@@ -235,13 +247,13 @@ function sensitivity(catalog, entries) {
|
|
|
235
247
|
if (w.range && wRate > 0) {
|
|
236
248
|
const threshold = (other.effectiveReturnPct + winner.fee) / wRate;
|
|
237
249
|
if (threshold < w.centsPerUnit && threshold >= w.range[0]) {
|
|
238
|
-
consider((w.centsPerUnit - threshold) / w.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, w.currency)}
|
|
250
|
+
consider((w.centsPerUnit - threshold) / w.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, w.currency))} are worth less than ${roundDown(threshold)}¢ (you value them at ${num(w.centsPerUnit)}¢).`);
|
|
239
251
|
}
|
|
240
252
|
}
|
|
241
253
|
if (o.range && oRate > 0) {
|
|
242
254
|
const threshold = (winner.effectiveReturnPct + other.fee) / oRate;
|
|
243
255
|
if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
|
|
244
|
-
consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, o.currency)}
|
|
256
|
+
consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, o.currency))} are worth ${worthAbove(threshold)} (you value them at ${num(o.centsPerUnit)}¢).`);
|
|
245
257
|
}
|
|
246
258
|
}
|
|
247
259
|
}
|
|
@@ -256,7 +268,19 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
256
268
|
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
257
269
|
}
|
|
258
270
|
else if (merchant.category === 'other') {
|
|
259
|
-
|
|
271
|
+
// A card whose rule names this store by name (Audible on Amex's
|
|
272
|
+
// streaming list) earns its bonus there anyway.
|
|
273
|
+
const naming = entries.flatMap((e) => {
|
|
274
|
+
const rule = e.evaluation.best.rule;
|
|
275
|
+
return rule &&
|
|
276
|
+
merchant.merchantId !== null &&
|
|
277
|
+
(rule.match.merchants ?? []).includes(merchant.merchantId)
|
|
278
|
+
? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
|
|
279
|
+
: [];
|
|
280
|
+
});
|
|
281
|
+
assumptions.push(naming.length === 0
|
|
282
|
+
? `Assumes ${merchant.resolvedName} falls outside the usual bonus categories.`
|
|
283
|
+
: `Assumes ${merchant.resolvedName} falls outside the usual bonus categories, except on cards that name it: ${listed(naming)}.`);
|
|
260
284
|
}
|
|
261
285
|
else {
|
|
262
286
|
assumptions.push(codesAs(catalog, merchant));
|
|
@@ -274,7 +298,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
274
298
|
if (values !== null)
|
|
275
299
|
assumptions.push(values);
|
|
276
300
|
if (isForeign(purchase))
|
|
277
|
-
assumptions.push(
|
|
301
|
+
assumptions.push(FOREIGN_ASSUMPTION);
|
|
278
302
|
for (const e of entries)
|
|
279
303
|
for (const hit of e.milestones)
|
|
280
304
|
assumptions.push(`Counts the ${money(hit.before)} tracked on ${displayName(e)} in ${hit.period} toward its ${wholeDollars(hit.spend)} spend bonus; spending earnrate wasn't told about isn't counted.`);
|
package/dist/engine/rule-text.js
CHANGED
|
@@ -34,10 +34,18 @@ export function ruleParts(catalog, rule) {
|
|
|
34
34
|
if (rule.match.choice !== undefined)
|
|
35
35
|
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
36
36
|
const merchants = rule.match.merchants ?? [];
|
|
37
|
-
// A long list of one kind of store reads as "select streaming
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
const
|
|
37
|
+
// A long list of mostly one kind of store reads as "select streaming
|
|
38
|
+
// services": Amex's streaming list also names Audible, which isn't one.
|
|
39
|
+
const counts = new Map();
|
|
40
|
+
for (const m of merchants) {
|
|
41
|
+
const category = findMerchant(catalog, m)?.category;
|
|
42
|
+
if (category)
|
|
43
|
+
counts.set(category, (counts.get(category) ?? 0) + 1);
|
|
44
|
+
}
|
|
45
|
+
const [kind, count = 0] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
|
|
46
|
+
const merchantParts = merchants.length > 4 &&
|
|
47
|
+
kind !== undefined &&
|
|
48
|
+
count >= merchants.length * 0.75
|
|
41
49
|
? [`select ${categoryPhrase(catalog, kind)}`]
|
|
42
50
|
: merchants.map((m) => merchantName(catalog, m));
|
|
43
51
|
const parts = [
|
|
@@ -30,7 +30,10 @@ function cardLines(cards, style, bonusPick) {
|
|
|
30
30
|
const pctWidth = Math.max(...cards.map((c) => pct(c.effectiveReturnPct).length));
|
|
31
31
|
const lines = [];
|
|
32
32
|
cards.forEach((c) => {
|
|
33
|
-
const earned = c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}
|
|
33
|
+
const earned = (c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`) +
|
|
34
|
+
(c.milestoneBonus === null
|
|
35
|
+
? ''
|
|
36
|
+
: ` + ${money(c.milestoneBonus.dollars)} spend bonus`);
|
|
34
37
|
const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
|
|
35
38
|
lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
|
|
36
39
|
for (const r of c.reasoning)
|
|
@@ -53,9 +56,12 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
|
|
|
53
56
|
const noCard = noCardText(rec.merchant.resolvedName);
|
|
54
57
|
if (rec.decision && top) {
|
|
55
58
|
const earned = top.earned === null ? '' : `, about ${dollarsAndCents(top.earned)}`;
|
|
59
|
+
const milestone = top.milestoneBonus === null
|
|
60
|
+
? ''
|
|
61
|
+
: `, plus a spend bonus worth about ${money(top.milestoneBonus.dollars)}`;
|
|
56
62
|
const confidence = rec.decision.confidence;
|
|
57
63
|
const why = rec.bonus ? ' for its welcome bonus' : '';
|
|
58
|
-
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${capitalize(confidence)} confidence.`);
|
|
64
|
+
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}${milestone}.`)} ${capitalize(confidence)} confidence.`);
|
|
59
65
|
if (rec.bonus && rec.notes[0])
|
|
60
66
|
lines.push(rec.notes[0]);
|
|
61
67
|
}
|
package/dist/format/wallet.js
CHANGED
|
@@ -36,7 +36,7 @@ export function formatWalletSummary(summary) {
|
|
|
36
36
|
for (const cap of c.caps)
|
|
37
37
|
lines.push(cap.remaining === null
|
|
38
38
|
? ` Cap on ${cap.label}: ${money(cap.amount)} ${CAP_PERIODS[cap.period]}; give the card's anniversary to track it (cap key ${cap.key}).`
|
|
39
|
-
: ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''}${cap.until === null ? '' : `, through ${shortDate(cap.until)}`} (cap key ${cap.key}).`);
|
|
39
|
+
: ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used ${cap.period === 'cardmember_year' ? 'this cardmember year' : `for ${cap.currentPeriod ?? ''}`}${cap.until === null ? '' : `, through ${shortDate(cap.until)}`} (cap key ${cap.key}).`);
|
|
40
40
|
for (const m of c.milestones)
|
|
41
41
|
lines.push(` Spend bonus (${m.id}): ${m.text}`);
|
|
42
42
|
if (c.anniversary !== null)
|
|
@@ -76,7 +76,11 @@ export function formatBooking(amount, cardId, cap, bonus, milestones = []) {
|
|
|
76
76
|
messages.push([
|
|
77
77
|
`Spend on ${cardId} this year: ${money(first.after)}.`,
|
|
78
78
|
...reached.map((m) => `That reaches its ${money(m.spend)} spend bonus.`),
|
|
79
|
-
...(next
|
|
79
|
+
...(next
|
|
80
|
+
? [
|
|
81
|
+
`${money(next.spend - next.after)} to go for the ${money(next.spend)} spend bonus.`,
|
|
82
|
+
]
|
|
83
|
+
: []),
|
|
80
84
|
].join(' '));
|
|
81
85
|
}
|
|
82
86
|
return messages.join(' ');
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -40,6 +40,9 @@ const RankedCardSchema = z.object({
|
|
|
40
40
|
effectiveReturnPct: z.number(),
|
|
41
41
|
earned: z.number().nullable(),
|
|
42
42
|
foreignFee: z.number().nullable(),
|
|
43
|
+
milestoneBonus: z
|
|
44
|
+
.object({ points: z.number(), dollars: z.number() })
|
|
45
|
+
.nullable(),
|
|
43
46
|
capped: z.boolean(),
|
|
44
47
|
reasoning: z.array(z.string()),
|
|
45
48
|
warnings: z.array(z.string()),
|
package/dist/mcp/server.js
CHANGED
|
@@ -45,7 +45,23 @@ The wallet lists cards by product id, never card numbers; never ask for card num
|
|
|
45
45
|
|
|
46
46
|
${WHICH_WALLET[savedWallets]} The earnrate extension on the user's computer and the hosted earnrate connector keep separate wallets with the same tools. If both are connected, say which wallet an answer comes from, and ask the user which one to use before saving or recording anything.`;
|
|
47
47
|
export function createEarnrateServer(deps) {
|
|
48
|
-
const server = new McpServer({
|
|
48
|
+
const server = new McpServer({
|
|
49
|
+
name: MCP_SERVER_NAME,
|
|
50
|
+
version: deps.version,
|
|
51
|
+
websiteUrl: 'https://earnrate.dev',
|
|
52
|
+
icons: [
|
|
53
|
+
{
|
|
54
|
+
src: 'https://earnrate.dev/icon-192.png',
|
|
55
|
+
mimeType: 'image/png',
|
|
56
|
+
sizes: ['192x192'],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
src: 'https://earnrate.dev/favicon.svg',
|
|
60
|
+
mimeType: 'image/svg+xml',
|
|
61
|
+
sizes: ['any'],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}, { instructions: instructions(deps.savedWallets) });
|
|
49
65
|
const ctx = makeContext(deps);
|
|
50
66
|
const inputs = makeInputs(deps.catalog);
|
|
51
67
|
registerAnswerTools(server, deps, ctx, inputs);
|