earnrate 0.2.17 → 0.2.18
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 +9 -3
- package/data/CHANGELOG.md +19 -1
- package/data/VERSION +1 -1
- package/data/cards/amex-business-gold.yaml +1 -0
- package/data/cards/amex-platinum.yaml +1 -1
- package/data/cards/chase-freedom-flex.yaml +3 -6
- package/data/cards/chase-ihg-one-rewards-premier.yaml +2 -0
- package/data/cards/chase-united-business.yaml +3 -0
- package/data/cards/chase-united-club-business.yaml +5 -0
- package/data/cards/citi-aadvantage-business.yaml +5 -0
- package/data/cards/citi-aadvantage-executive.yaml +2 -0
- package/data/cards/citi-aadvantage-globe.yaml +2 -0
- package/data/cards/citi-aadvantage-mileup.yaml +4 -0
- package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
- package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
- package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
- package/data/cards/citi-custom-cash.yaml +8 -10
- package/data/cards/citi-double-cash.yaml +1 -0
- package/data/cards/citi-my-best-buy-visa.yaml +1 -0
- package/data/cards/citi-strata-elite.yaml +2 -0
- package/data/cards/citi-strata-premier.yaml +2 -0
- package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
- package/data/changes.yaml +2840 -0
- package/dist/cli/offers.js +21 -5
- package/dist/cli/run.js +14 -3
- package/dist/cli/wallet.js +28 -3
- package/dist/data/change-integrity.js +105 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +7 -0
- package/dist/data/load.js +5 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +16 -5
- package/dist/engine/changes.js +124 -0
- package/dist/engine/checklist.js +18 -3
- package/dist/engine/explore.js +28 -9
- package/dist/engine/offers.js +52 -3
- package/dist/engine/periods.js +7 -0
- package/dist/engine/renewal.js +101 -0
- package/dist/engine/rule-text.js +2 -7
- package/dist/engine/setup.js +6 -3
- package/dist/engine/simulate.js +14 -7
- package/dist/engine/text.js +64 -0
- package/dist/engine/wallet-change.js +16 -8
- package/dist/engine/wallet-summary.js +4 -0
- package/dist/format/card-text.js +9 -2
- package/dist/format/offers.js +5 -3
- package/dist/format/wallet-yaml.js +3 -0
- package/dist/format/wallet.js +6 -1
- package/dist/mcp/context.js +11 -3
- package/dist/mcp/inputs.js +3 -1
- package/dist/mcp/prompts.js +3 -3
- package/dist/mcp/schemas.js +24 -0
- package/dist/mcp/tools/answers.js +42 -24
- package/dist/mcp/tools/wallet.js +28 -8
- package/dist/schema/card.js +7 -0
- package/dist/schema/changes.js +73 -0
- package/dist/schema/wallet.js +5 -1
- package/package.json +1 -1
package/dist/engine/offers.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { bonusDollars } from "./bonus.js";
|
|
2
2
|
import { allCreditsYear } from "./credits.js";
|
|
3
3
|
import { matchesFilters } from "./explore.js";
|
|
4
|
+
import { simulateAdditions, typicalSpendLines } from "./simulate.js";
|
|
4
5
|
import { membershipCosts, paidMemberships } from "./memberships.js";
|
|
5
6
|
import { inEffect } from "./periods.js";
|
|
6
7
|
import { CREDITS_APART, heldName, listed, money, roundCents } from "./text.js";
|
|
@@ -25,8 +26,27 @@ export function offerTerms(o, currencyName) {
|
|
|
25
26
|
: 'with no spending required';
|
|
26
27
|
return `${o.varies ? 'Up to ' : ''}${amount} ${spend}`;
|
|
27
28
|
}
|
|
28
|
-
|
|
29
|
+
const WEEKS_A_MONTH = 52 / 12;
|
|
30
|
+
function minimumSpend(spend, months, monthly) {
|
|
31
|
+
if (spend <= 0)
|
|
32
|
+
return null;
|
|
33
|
+
const weeks = monthly > 0 ? Math.ceil(spend / (monthly / WEEKS_A_MONTH)) : Infinity;
|
|
34
|
+
return {
|
|
35
|
+
reachable: monthly * months >= spend,
|
|
36
|
+
weeks: Number.isFinite(weeks) ? weeks : 0,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function compareOffers(catalog, wallet, filters, today, spending = { lines: typicalSpendLines(catalog), typical: true }) {
|
|
29
40
|
const held = new Set(wallet.cards.map((c) => c.id));
|
|
41
|
+
const skipped = new Set(wallet.skipped_issuers ?? []);
|
|
42
|
+
const monthly = spending.lines.reduce((sum, l) => sum + l.monthly, 0);
|
|
43
|
+
const { additions } = simulateAdditions(catalog, wallet, spending.lines, {
|
|
44
|
+
filters: { ...filters, issuer: null, includeClosed: true },
|
|
45
|
+
memberships: wallet.memberships,
|
|
46
|
+
valuations: wallet.valuations,
|
|
47
|
+
start: today,
|
|
48
|
+
});
|
|
49
|
+
const added = new Map(additions.map((a) => [a.cardId, a]));
|
|
30
50
|
const paid = paidMemberships(catalog, {
|
|
31
51
|
memberships: wallet.memberships,
|
|
32
52
|
heldCards: [...held],
|
|
@@ -38,6 +58,8 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
38
58
|
continue;
|
|
39
59
|
if (!filters.includeHeld && held.has(card.id))
|
|
40
60
|
continue;
|
|
61
|
+
if (skipped.has(card.issuer))
|
|
62
|
+
continue;
|
|
41
63
|
if (!matchesFilters(card, { ...filters, issuer: null, includeClosed: true }))
|
|
42
64
|
continue;
|
|
43
65
|
const offer = card.welcome_offer;
|
|
@@ -52,6 +74,25 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
52
74
|
const membershipFee = membershipCosts(catalog, [card], paid)[0]?.annualFee ?? 0;
|
|
53
75
|
const firstYearValue = roundCents(value - (firstYearFee ?? 0) - membershipFee);
|
|
54
76
|
const credits = allCreditsYear(catalog, card, today).yearly;
|
|
77
|
+
const addition = added.get(card.id);
|
|
78
|
+
const minimum = minimumSpend(offer.spend, offer.months, monthly);
|
|
79
|
+
const plan = addition
|
|
80
|
+
? (() => {
|
|
81
|
+
const reached = minimum === null || minimum.reachable;
|
|
82
|
+
const yearOne = roundCents(addition.extraRewards +
|
|
83
|
+
(reached ? value : 0) -
|
|
84
|
+
(firstYearFee ?? 0) -
|
|
85
|
+
addition.membershipFee);
|
|
86
|
+
const yearTwo = roundCents(addition.extraRewards - addition.annualFee - addition.membershipFee);
|
|
87
|
+
return {
|
|
88
|
+
extraRewards: addition.extraRewards,
|
|
89
|
+
minimumSpend: minimum,
|
|
90
|
+
yearOne,
|
|
91
|
+
yearTwo,
|
|
92
|
+
twoYears: roundCents(yearOne + yearTwo),
|
|
93
|
+
};
|
|
94
|
+
})()
|
|
95
|
+
: null;
|
|
55
96
|
offers.push({
|
|
56
97
|
cardId: card.id,
|
|
57
98
|
cardName: card.name,
|
|
@@ -74,19 +115,27 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
74
115
|
firstYearValue,
|
|
75
116
|
credits,
|
|
76
117
|
firstYearValueIfCreditsUsed: roundCents(firstYearValue + credits),
|
|
118
|
+
plan,
|
|
77
119
|
held: held.has(card.id),
|
|
78
120
|
lastVerified: card.last_verified,
|
|
79
121
|
verifiedBy: card.verified_by,
|
|
80
122
|
});
|
|
81
123
|
}
|
|
82
124
|
offers.sort((a, b) => Number(a.ineligible !== null) - Number(b.ineligible !== null) ||
|
|
125
|
+
(b.plan?.twoYears ?? -Infinity) - (a.plan?.twoYears ?? -Infinity) ||
|
|
83
126
|
b.firstYearValue - a.firstYearValue ||
|
|
84
127
|
a.cardId.localeCompare(b.cardId));
|
|
85
128
|
const notes = [
|
|
86
129
|
`Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
|
|
87
130
|
`Each offer is the public one on the issuer's page on the date shown, checked weekly. ${OFFERS_MAY_DIFFER} Issuers also limit who can get a bonus; each card's note says how.`,
|
|
88
|
-
|
|
131
|
+
`Ranked on the first two years together: what each card ${wallet.cards.length > 0 ? 'adds to your cards' : 'earns'} on ${spending.typical ? 'typical U.S. spending' : 'your spending'}${wallet.cards.length > 0 ? ' (it takes the purchases where it earns more)' : ''}, plus the welcome bonus in year one if the minimum spend fits in the window with all your spending on it, less each year's annual fee and any membership it needs. Year one counts the card at its standard rates.`,
|
|
132
|
+
"First-year value is the bonus alone at your point values, less the first year's fee and membership.",
|
|
89
133
|
CREDITS_APART,
|
|
134
|
+
...(skipped.size > 0
|
|
135
|
+
? [
|
|
136
|
+
`Leaves out cards from ${listed([...skipped].map((i) => catalog.issuers.find((x) => x.id === i)?.name ?? i))}, as you asked.`,
|
|
137
|
+
]
|
|
138
|
+
: []),
|
|
90
139
|
];
|
|
91
|
-
return { offers, withoutOffer, notes };
|
|
140
|
+
return { offers, withoutOffer, notes, typical: spending.typical };
|
|
92
141
|
}
|
package/dist/engine/periods.js
CHANGED
|
@@ -20,6 +20,13 @@ export function periodKey(period, date, anniversary) {
|
|
|
20
20
|
return String(date.slice(5) >= anniversary ? year : year - 1);
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
+
// MM-DD that is a real day of the year; 02-29 counts.
|
|
24
|
+
export function isAnniversary(text) {
|
|
25
|
+
if (!/^\d{2}-\d{2}$/.test(text))
|
|
26
|
+
return false;
|
|
27
|
+
const date = new Date(`2024-${text}T00:00:00Z`);
|
|
28
|
+
return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
|
|
29
|
+
}
|
|
23
30
|
export function daysBetween(from, to) {
|
|
24
31
|
return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000);
|
|
25
32
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ALL_PERSONAL_CARDS, matchesFilters } from "./explore.js";
|
|
2
|
+
import { usedCreditsYear } from "./credits.js";
|
|
3
|
+
import { daysBetween, isAnniversary } from "./periods.js";
|
|
4
|
+
import { projectedWallet, simulateSingles, simulateWallet, typicalSpendLines, } from "./simulate.js";
|
|
5
|
+
import { roundCents, shortCardName } from "./text.js";
|
|
6
|
+
// Node-free: earnrate.dev runs it in the browser.
|
|
7
|
+
// A fee card shows up this many days before its anniversary, and stays
|
|
8
|
+
// until this many after: the fee posts on the statement after it.
|
|
9
|
+
export const RENEWAL_BEFORE = 30;
|
|
10
|
+
export const RENEWAL_AFTER = 15;
|
|
11
|
+
// The card's anniversary as MM-DD: the saved one, or from the day it opened.
|
|
12
|
+
export function anniversaryOf(held) {
|
|
13
|
+
return (held.anniversary ?? (held.opened ?? held.bonus?.opened)?.slice(5) ?? null);
|
|
14
|
+
}
|
|
15
|
+
const openedOf = (held) => held.opened ?? held.bonus?.opened ?? null;
|
|
16
|
+
// A real date for MM-DD in `year`: Feb 29 falls on Feb 28 in other years.
|
|
17
|
+
function onYear(year, mmdd) {
|
|
18
|
+
const date = `${String(year)}-${mmdd}`;
|
|
19
|
+
if (mmdd !== '02-29')
|
|
20
|
+
return date;
|
|
21
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
22
|
+
return leap ? date : `${String(year)}-02-28`;
|
|
23
|
+
}
|
|
24
|
+
// The renewal date near `today`, from RENEWAL_BEFORE days ahead back to
|
|
25
|
+
// RENEWAL_AFTER days past; null outside that, without an anniversary, or
|
|
26
|
+
// before the account's first year is nearly over.
|
|
27
|
+
export function renewalDue(held, today) {
|
|
28
|
+
const mmdd = anniversaryOf(held);
|
|
29
|
+
if (mmdd === null || !isAnniversary(mmdd))
|
|
30
|
+
return null;
|
|
31
|
+
const opened = openedOf(held);
|
|
32
|
+
const year = Number(today.slice(0, 4));
|
|
33
|
+
for (const y of [year - 1, year, year + 1]) {
|
|
34
|
+
const due = onYear(y, mmdd);
|
|
35
|
+
const days = daysBetween(today, due);
|
|
36
|
+
if (days > RENEWAL_BEFORE || days < -RENEWAL_AFTER)
|
|
37
|
+
continue;
|
|
38
|
+
if (opened !== null && daysBetween(opened, due) < 300)
|
|
39
|
+
continue;
|
|
40
|
+
return due;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
// Whether a fee card still pays for itself, for the year after `due`.
|
|
45
|
+
export function renewalCheck(catalog, wallet, card, due, spending = {
|
|
46
|
+
lines: typicalSpendLines(catalog),
|
|
47
|
+
typical: true,
|
|
48
|
+
}) {
|
|
49
|
+
const held = wallet.cards.find((c) => c.id === card.id);
|
|
50
|
+
const projected = projectedWallet(wallet, catalog, due);
|
|
51
|
+
const ids = projected.cards.map((c) => c.id);
|
|
52
|
+
const withCard = simulateWallet(catalog, projected, spending.lines, due, ids);
|
|
53
|
+
const others = projected.cards.filter((c) => c.id !== card.id);
|
|
54
|
+
let without = null;
|
|
55
|
+
let against = null;
|
|
56
|
+
if (others.length > 0) {
|
|
57
|
+
without = simulateWallet(catalog, { ...projected, cards: others }, spending.lines, due, ids.filter((id) => id !== card.id));
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const noFee = catalog.cards.filter((c) => matchesFilters(c, {
|
|
61
|
+
...ALL_PERSONAL_CARDS,
|
|
62
|
+
business: card.business,
|
|
63
|
+
noAnnualFee: true,
|
|
64
|
+
}) &&
|
|
65
|
+
c.requires_membership === undefined &&
|
|
66
|
+
c.network !== 'store');
|
|
67
|
+
const [best] = simulateSingles(catalog, noFee, spending.lines, {
|
|
68
|
+
filters: { ...ALL_PERSONAL_CARDS, noAnnualFee: true },
|
|
69
|
+
memberships: wallet.memberships,
|
|
70
|
+
valuations: wallet.valuations,
|
|
71
|
+
start: due,
|
|
72
|
+
});
|
|
73
|
+
if (best) {
|
|
74
|
+
without = best;
|
|
75
|
+
const bestId = best.cards[0]?.cardId;
|
|
76
|
+
const bestCard = catalog.cards.find((c) => c.id === bestId);
|
|
77
|
+
against = bestCard ? shortCardName(bestCard.name) : null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// From the saved card: projectedWallet drops credit usage, which is how
|
|
81
|
+
// a credit that needs no enrolling is confirmed in use.
|
|
82
|
+
const credits = held ? usedCreditsYear(catalog, card, held, due).yearly : 0;
|
|
83
|
+
const rewardsAdded = roundCents(withCard.rewards - (without?.rewards ?? 0));
|
|
84
|
+
const fee = card.annual_fee ?? 0;
|
|
85
|
+
const opened = held ? openedOf(held) : null;
|
|
86
|
+
return {
|
|
87
|
+
cardId: card.id,
|
|
88
|
+
due,
|
|
89
|
+
fee,
|
|
90
|
+
firstYearFee: opened !== null &&
|
|
91
|
+
daysBetween(opened, due) < 400 &&
|
|
92
|
+
(card.first_year_fee ?? fee) < fee
|
|
93
|
+
? (card.first_year_fee ?? 0)
|
|
94
|
+
: null,
|
|
95
|
+
rewardsAdded,
|
|
96
|
+
credits,
|
|
97
|
+
net: roundCents(rewardsAdded + credits - fee),
|
|
98
|
+
against,
|
|
99
|
+
typical: spending.typical,
|
|
100
|
+
};
|
|
101
|
+
}
|
package/dist/engine/rule-text.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { findMerchant } from "../merchants/resolve.js";
|
|
2
|
-
import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES,
|
|
2
|
+
import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, choicePhrase, wholeDollars, words, } from "./text.js";
|
|
3
3
|
// What a rule or a pick covers, in words, for answers and wallet lists.
|
|
4
4
|
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
5
5
|
// lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
|
|
@@ -43,12 +43,7 @@ export function ruleParts(catalog, rule, card, { everyStore = false } = {}) {
|
|
|
43
43
|
const choice = card?.choices.find((c) => c.id === m.choice);
|
|
44
44
|
if (!choice)
|
|
45
45
|
return [`your ${choiceName(m.choice)} picks`];
|
|
46
|
-
|
|
47
|
-
? 'the category'
|
|
48
|
-
: `the ${String(choice.pick)} categories`;
|
|
49
|
-
return [
|
|
50
|
-
`${which} you pick${choice.period === 'until_changed' ? '' : ` ${PICK_PERIODS[choice.period]}`}`,
|
|
51
|
-
];
|
|
46
|
+
return [choicePhrase(choice)];
|
|
52
47
|
}
|
|
53
48
|
const merchants = m.merchants ?? [];
|
|
54
49
|
const categories = (m.categories ?? []).map((c) => categoryPhrase(catalog, c));
|
package/dist/engine/setup.js
CHANGED
|
@@ -4,7 +4,7 @@ import { milestoneStarts, milestoneWhat } from "./milestones.js";
|
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
5
|
import { rotationGap, rotationGapText } from "./rotation.js";
|
|
6
6
|
import { creditName, creditWhere, introWhat } from "./rule-text.js";
|
|
7
|
-
import { creditBack, creditTerms, ENROLL_FIRST, idList, listed, possessive, } from "./text.js";
|
|
7
|
+
import { choicePhrase, creditBack, creditTerms, ENROLL_FIRST, idList, listed, possessive, } from "./text.js";
|
|
8
8
|
// Rules that start within this many days are worth activating now.
|
|
9
9
|
const ACTIVATION_LOOKAHEAD_DAYS = 45;
|
|
10
10
|
const searchWords = (s) => s
|
|
@@ -89,6 +89,7 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
89
89
|
id: c.id,
|
|
90
90
|
pick: c.pick,
|
|
91
91
|
period: c.period,
|
|
92
|
+
automatic: c.automatic === true,
|
|
92
93
|
options: Object.keys(c.options),
|
|
93
94
|
default: c.default ?? null,
|
|
94
95
|
})),
|
|
@@ -172,8 +173,10 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
172
173
|
notes.push({
|
|
173
174
|
kind: 'pick',
|
|
174
175
|
text: choice.default && picked.has(choice.id)
|
|
175
|
-
? `${possessive(name)} ${choice.id} pick is assumed to be ${idList(choice.default)}, the default. Set it if a different one was picked (${String(choice.pick)} of: ${options}).`
|
|
176
|
-
:
|
|
176
|
+
? `${possessive(name)} ${choice.id} pick is assumed to be ${idList(choice.default)}, the default. Set it if ${choice.automatic ? 'you expect a different one to have the most spending' : 'a different one was picked'} (${String(choice.pick)} of: ${options}).`
|
|
177
|
+
: choice.automatic
|
|
178
|
+
? `${name} applies its bonus to ${choicePhrase(choice)}; set its ${choice.id} pick to the one you expect (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`
|
|
179
|
+
: `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
|
|
177
180
|
});
|
|
178
181
|
}
|
|
179
182
|
// With one tier of a group saved, the others aren't missing.
|
package/dist/engine/simulate.js
CHANGED
|
@@ -325,15 +325,16 @@ export function simulateCatalog(catalog, lines, options) {
|
|
|
325
325
|
return { singles, pairs: foldTies(pairs).slice(0, 10) };
|
|
326
326
|
}
|
|
327
327
|
// The person's own wallet, set up as it would run all year: their picks carry
|
|
328
|
-
// over, rotating categories get activated, caps start fresh.
|
|
329
|
-
|
|
328
|
+
// over, rotating categories get activated, caps start fresh. A card earnrate
|
|
329
|
+
// no longer lists is left out, since it can't be simulated.
|
|
330
|
+
export function projectedWallet(wallet, catalog, start) {
|
|
330
331
|
return {
|
|
331
332
|
...wallet,
|
|
332
|
-
cards: wallet.cards.
|
|
333
|
+
cards: wallet.cards.flatMap((held) => {
|
|
333
334
|
const card = catalog.cards.find((c) => c.id === held.id);
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
335
|
+
if (!card)
|
|
336
|
+
return [];
|
|
337
|
+
const ideal = idealHolding(card, start, held.chosen?.picks ?? {});
|
|
337
338
|
return {
|
|
338
339
|
...ideal,
|
|
339
340
|
...(held.checkout_label !== undefined
|
|
@@ -346,6 +347,10 @@ function projectedWallet(wallet, catalog, start) {
|
|
|
346
347
|
...(held.skipped_credits !== undefined
|
|
347
348
|
? { skipped_credits: held.skipped_credits }
|
|
348
349
|
: {}),
|
|
350
|
+
// Usage confirms a credit that needs no enrolling is in use.
|
|
351
|
+
...(held.credit_usage !== undefined
|
|
352
|
+
? { credit_usage: held.credit_usage }
|
|
353
|
+
: {}),
|
|
349
354
|
};
|
|
350
355
|
}),
|
|
351
356
|
};
|
|
@@ -357,7 +362,9 @@ export function simulateAdditions(catalog, wallet, lines, options) {
|
|
|
357
362
|
const held = new Set(heldIds);
|
|
358
363
|
const additions = [];
|
|
359
364
|
for (const card of catalog.cards) {
|
|
360
|
-
if (held.has(card.id) ||
|
|
365
|
+
if (held.has(card.id) ||
|
|
366
|
+
!matchesFilters(card, options.filters) ||
|
|
367
|
+
(wallet.skipped_issuers ?? []).includes(card.issuer))
|
|
361
368
|
continue;
|
|
362
369
|
const simulation = bestSimulation(catalog, projected.cards, card, lines, {
|
|
363
370
|
...options,
|
package/dist/engine/text.js
CHANGED
|
@@ -25,6 +25,8 @@ const dateWords = (date, options) => new Date(`${date}T12:00:00Z`).toLocaleDateS
|
|
|
25
25
|
export const shortDate = (date) => dateWords(date, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
26
26
|
export const longDate = (date) => dateWords(date, { month: 'long', day: 'numeric', year: 'numeric' });
|
|
27
27
|
export const monthYear = (date) => dateWords(date, { month: 'long', year: 'numeric' });
|
|
28
|
+
// "Q4 2026", from an ISO date.
|
|
29
|
+
export const quarterYear = (date) => `Q${String(Math.floor((Number(date.slice(5, 7)) - 1) / 3) + 1)} ${date.slice(0, 4)}`;
|
|
28
30
|
// Always cents ($4.00), for what one purchase earns. A loss reads −$4.00.
|
|
29
31
|
export const dollarsAndCents = (n) => `${n < 0 ? '−' : ''}$${Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
30
32
|
// Rounded to whole dollars ($2,800), for fees, caps and yearly totals.
|
|
@@ -205,6 +207,36 @@ export const PICK_PERIODS = {
|
|
|
205
207
|
calendar_year: 'each year',
|
|
206
208
|
until_changed: 'until you change it',
|
|
207
209
|
};
|
|
210
|
+
// "the 2 categories you pick each quarter", or for a card whose issuer
|
|
211
|
+
// applies the bonus itself, "the category you spend the most in each month".
|
|
212
|
+
// `oneOf` reads as one option of several: "one of the 2 categories …".
|
|
213
|
+
export function choicePhrase(c, oneOf = false) {
|
|
214
|
+
const which = c.pick === 1
|
|
215
|
+
? 'the category'
|
|
216
|
+
: `${oneOf ? 'one of ' : ''}the ${String(c.pick)} categories`;
|
|
217
|
+
const when = c.period === 'until_changed' ? '' : ` ${PICK_PERIODS[c.period]}`;
|
|
218
|
+
return c.automatic
|
|
219
|
+
? `${which} you spend the most in${when}`
|
|
220
|
+
: `${which} you pick${when}`;
|
|
221
|
+
}
|
|
222
|
+
// What earning a picked rate takes, for an answer's conditions: "Needs
|
|
223
|
+
// dining as its top-category pick (picked each month)", or for an automatic
|
|
224
|
+
// choice, "Counts when dining is the category you spend the most in that
|
|
225
|
+
// month".
|
|
226
|
+
export function pickCondition(c, options, name) {
|
|
227
|
+
if (c.automatic) {
|
|
228
|
+
const period = {
|
|
229
|
+
month: ' that month',
|
|
230
|
+
quarter: ' that quarter',
|
|
231
|
+
calendar_year: ' that year',
|
|
232
|
+
until_changed: '',
|
|
233
|
+
}[c.period];
|
|
234
|
+
return c.pick === 1
|
|
235
|
+
? `Counts when ${options} is the category you spend the most in${period}`
|
|
236
|
+
: `Counts when ${options} is one of the ${String(c.pick)} categories you spend the most in${period}`;
|
|
237
|
+
}
|
|
238
|
+
return `Needs ${options} as its ${name} pick (${c.period === 'until_changed' ? '' : 'picked '}${PICK_PERIODS[c.period]})`;
|
|
239
|
+
}
|
|
208
240
|
// Intro rates (a card's first 365 days): "first year", "first 90 days".
|
|
209
241
|
export const introPeriod = (days) => days === 365 ? 'first year' : `first ${String(days)} days`;
|
|
210
242
|
export const introAdjective = (days) => days === 365 ? 'first-year' : `${String(days)}-day`;
|
|
@@ -308,3 +340,35 @@ export const pairCreditsUsedText = (cards, yearly, dollars) => `If you'd use all
|
|
|
308
340
|
// "If you'd use all its statement credits ($424 a year: …), it nets $X a
|
|
309
341
|
// year." `adds` for a card beside the ones already held.
|
|
310
342
|
export const ifCreditsUsedText = (c, dollars, verb = 'nets', owner = 'its') => `If you'd use all ${owner} statement credits (${creditsYearText(c)}), it ${verb} ${wholeDollars(dollars)} a year.`;
|
|
343
|
+
// The renewal check on the to-do list. A card well ahead gets one short
|
|
344
|
+
// line; one that's close or behind gets the parts of the math.
|
|
345
|
+
export function renewalText(name, issuer, r) {
|
|
346
|
+
const fee = r.firstYearFee === null
|
|
347
|
+
? `${money(r.fee)} fee`
|
|
348
|
+
: `${money(r.fee)} fee, up from ${money(r.firstYearFee)} the first year`;
|
|
349
|
+
const when = `${name} renews around ${shortDate(r.due)} (${fee})`;
|
|
350
|
+
if (r.net >= r.fee)
|
|
351
|
+
return `${when}: it's paying for itself, about ${wholeDollars(r.net)} a year ahead after the fee.`;
|
|
352
|
+
const basis = r.typical ? 'At typical spending' : 'On your spending';
|
|
353
|
+
const over = r.against === null ? 'your other cards would' : `${r.against} would`;
|
|
354
|
+
const earns = Math.round(r.rewardsAdded) > 0
|
|
355
|
+
? `it earns about ${wholeDollars(r.rewardsAdded)} a year more than ${over}`
|
|
356
|
+
: Math.round(r.rewardsAdded) < 0
|
|
357
|
+
? `it earns about ${wholeDollars(-r.rewardsAdded)} a year less than ${over}`
|
|
358
|
+
: `it earns about the same as ${over}`;
|
|
359
|
+
const credits = r.credits > 0 ? `, plus ${wholeDollars(r.credits)} in credits you use` : '';
|
|
360
|
+
const result = r.net >= 0
|
|
361
|
+
? `about ${wholeDollars(r.net)} a year ahead after the fee.`
|
|
362
|
+
: `about ${wholeDollars(-r.net)} a year short of the fee. Check with ${issuer} before the fee posts.`;
|
|
363
|
+
return `${when}. ${basis}, ${earns}${credits}: ${result}`;
|
|
364
|
+
}
|
|
365
|
+
// The next-card planner's figures: "about $1,690 over the first two years
|
|
366
|
+
// ($1,290 the first year, then $394 a year)".
|
|
367
|
+
export const twoYearText = (p) => `about ${wholeDollars(p.twoYears)} over the first two years (${wholeDollars(p.yearOne)} the first year, then ${wholeDollars(p.yearTwo)} a year${p.yearTwo < 0 ? ' after its fee' : ''})`;
|
|
368
|
+
// Whether a welcome offer's minimum spend fits, at the person's spending.
|
|
369
|
+
export function minimumSpendText(spend, months, m, typical) {
|
|
370
|
+
const whose = typical ? 'typical spending' : 'your spending';
|
|
371
|
+
return m.reachable
|
|
372
|
+
? `The ${money(spend)} minimum takes about ${String(m.weeks)} weeks of all ${whose}.`
|
|
373
|
+
: `The ${money(spend)} minimum in ${String(months)} months is more than ${whose}, so the bonus isn't counted.`;
|
|
374
|
+
}
|
|
@@ -2,7 +2,7 @@ import { CARD_NUMBER_LABEL, labelHasCardNumber } from "../schema/card-number.js"
|
|
|
2
2
|
import { WALLET_VERSION, } from "../schema/wallet.js";
|
|
3
3
|
import { addMonths } from "./bonus.js";
|
|
4
4
|
import { capByKey } from "./evaluate.js";
|
|
5
|
-
import { choicePeriod, periodKey } from "./periods.js";
|
|
5
|
+
import { choicePeriod, isAnniversary, periodKey } from "./periods.js";
|
|
6
6
|
import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
|
|
7
7
|
import { CREDIT_PERIODS, idList, money } from "./text.js";
|
|
8
8
|
const failed = (error) => ({ ok: false, error });
|
|
@@ -15,13 +15,6 @@ function findCatalogCard(catalog, id, today) {
|
|
|
15
15
|
? `No card '${id}' in earnrate. Closest: ${idList(near)}.`
|
|
16
16
|
: `No card '${id}' in earnrate. Search the catalog to find its id.`;
|
|
17
17
|
}
|
|
18
|
-
function isAnniversary(text) {
|
|
19
|
-
const m = /^(\d{2})-(\d{2})$/.exec(text);
|
|
20
|
-
if (!m)
|
|
21
|
-
return false;
|
|
22
|
-
const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
|
|
23
|
-
return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
|
|
24
|
-
}
|
|
25
18
|
const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d) &&
|
|
26
19
|
new Date(`${d}T00:00:00Z`).toISOString().startsWith(d);
|
|
27
20
|
function pickKey(card, today) {
|
|
@@ -338,6 +331,21 @@ export function withMemberships(catalog, wallet, memberships) {
|
|
|
338
331
|
notes: [],
|
|
339
332
|
};
|
|
340
333
|
}
|
|
334
|
+
// Issuers to leave out of next-card answers. [] clears the list.
|
|
335
|
+
export function withSkippedIssuers(catalog, wallet, issuers) {
|
|
336
|
+
const known = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
|
|
337
|
+
const unknown = issuers.filter((i) => !known.includes(i));
|
|
338
|
+
if (unknown.length > 0)
|
|
339
|
+
return failed(`No card earnrate knows is from ${unknown.map((i) => `'${i}'`).join(', ')}. Issuers: ${idList(known)}.`);
|
|
340
|
+
const { skipped_issuers: _, ...rest } = wallet;
|
|
341
|
+
const list = [...new Set(issuers)];
|
|
342
|
+
return {
|
|
343
|
+
ok: true,
|
|
344
|
+
wallet: list.length > 0 ? { ...rest, skipped_issuers: list } : rest,
|
|
345
|
+
fields: {},
|
|
346
|
+
notes: [],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
341
349
|
// Point values in cents per point, by currency id; null goes back to
|
|
342
350
|
// earnrate's default for that currency.
|
|
343
351
|
export function withValuations(catalog, wallet, changes) {
|
|
@@ -240,6 +240,10 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
240
240
|
return {
|
|
241
241
|
memberships: wallet.memberships,
|
|
242
242
|
membershipNames: wallet.memberships.map((m) => membershipName(catalog, m)),
|
|
243
|
+
skippedIssuers: (wallet.skipped_issuers ?? []).map((id) => ({
|
|
244
|
+
id,
|
|
245
|
+
name: catalog.issuers.find((i) => i.id === id)?.name ?? id,
|
|
246
|
+
})),
|
|
243
247
|
pointValues: pointValues(catalog, wallet),
|
|
244
248
|
started: null,
|
|
245
249
|
cards,
|
package/dist/format/card-text.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Card names, fees and requirements as earnrate.dev and its static search
|
|
2
2
|
// pages show them. Node-free: the site bundles it.
|
|
3
3
|
import { membershipName, membershipText } from "../engine/memberships.js";
|
|
4
|
-
import { capSentence, earnText, PICK_PERIODS, shortCardName, wholeDollars, words, } from "../engine/text.js";
|
|
4
|
+
import { capSentence, earnText, PICK_PERIODS, pickCondition, shortCardName, wholeDollars, words, } from "../engine/text.js";
|
|
5
5
|
// An issuer's name as people know it, from data/issuers.yaml.
|
|
6
6
|
export const issuerName = (catalog, id) => catalog.issuers.find((i) => i.id === id)?.name ?? id;
|
|
7
7
|
export function cardText(catalog) {
|
|
@@ -27,7 +27,14 @@ export function cardText(catalog) {
|
|
|
27
27
|
function requirementLines(cardId, r, until = null) {
|
|
28
28
|
const lines = [];
|
|
29
29
|
if (r.pick)
|
|
30
|
-
lines.push(
|
|
30
|
+
lines.push(r.pick.automatic
|
|
31
|
+
? pickCondition({
|
|
32
|
+
pick: cards
|
|
33
|
+
.get(cardId)
|
|
34
|
+
?.choices.find((c) => c.id === r.pick?.choice)?.pick ?? 1,
|
|
35
|
+
...r.pick,
|
|
36
|
+
}, r.pick.options.map(words).join(' or '), '')
|
|
37
|
+
: `Pick ${r.pick.options.map(words).join(' or ')} as a bonus category (${PICK_PERIODS[r.pick.period]})`);
|
|
31
38
|
if (r.activation !== null)
|
|
32
39
|
lines.push(`Activate the bonus with ${issuerName(catalog, cards.get(cardId)?.issuer ?? '')} (free)`);
|
|
33
40
|
if (r.membership !== null)
|
package/dist/format/offers.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { offerTerms } from "../engine/offers.js";
|
|
2
|
-
import { capitalize, feeText, money, shortDate, wholeDollars, } from "../engine/text.js";
|
|
2
|
+
import { capitalize, feeText, minimumSpendText, money, shortDate, twoYearText, wholeDollars, } from "../engine/text.js";
|
|
3
3
|
const TOP = 10;
|
|
4
4
|
export function formatOffers(c, currencyName, all) {
|
|
5
5
|
if (c.offers.length === 0) {
|
|
@@ -11,8 +11,10 @@ export function formatOffers(c, currencyName, all) {
|
|
|
11
11
|
shown.forEach((o, i) => {
|
|
12
12
|
const weeks = (o.months * 52) / 12;
|
|
13
13
|
const pace = o.spend > 0 ? ` (about ${wholeDollars(o.spend / weeks)} a week)` : '';
|
|
14
|
-
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${wholeDollars(o.firstYearValue)} the first year${o.credits > 0 ?
|
|
15
|
-
lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
|
|
14
|
+
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} ${o.plan ? twoYearText(o.plan) : `about ${wholeDollars(o.firstYearValue)} the first year`}${o.credits > 0 ? `; its credits are ${wholeDollars(o.credits)} a year more if you'd use them all` : ''}`, ` ${offerTerms(o, currencyName(o.currency))}${pace}. ${capitalize(feeText(o))}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
|
|
15
|
+
lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ. The bonus alone is worth about ${wholeDollars(o.firstYearValue)} after the first year's fee.`);
|
|
16
|
+
if (o.plan?.minimumSpend)
|
|
17
|
+
lines.push(` ${minimumSpendText(o.spend, o.months, o.plan.minimumSpend, c.typical)}`);
|
|
16
18
|
if (o.ineligible)
|
|
17
19
|
lines.push(` ${o.ineligible}`);
|
|
18
20
|
if (o.note)
|
|
@@ -22,6 +22,9 @@ function compact(wallet) {
|
|
|
22
22
|
...(wallet.memberships.length > 0
|
|
23
23
|
? { memberships: wallet.memberships }
|
|
24
24
|
: {}),
|
|
25
|
+
...(wallet.skipped_issuers && wallet.skipped_issuers.length > 0
|
|
26
|
+
? { skipped_issuers: wallet.skipped_issuers }
|
|
27
|
+
: {}),
|
|
25
28
|
cards: wallet.cards.map((c) => Object.fromEntries(CARD_FIELDS.flatMap((k) => (isEmpty(c[k]) ? [] : [[k, c[k]]])))),
|
|
26
29
|
};
|
|
27
30
|
}
|
package/dist/format/wallet.js
CHANGED
|
@@ -64,7 +64,12 @@ export function formatWalletSummary(summary) {
|
|
|
64
64
|
const values = summary.pointValues.length > 0
|
|
65
65
|
? `Point values in the wallet: ${summary.pointValues.map((v) => `${v.name} at ${String(v.centsPerUnit)}¢${v.redemption === null ? '' : ` (redeemed ${v.redemption})`}`).join(', ')}. Others use earnrate's defaults.`
|
|
66
66
|
: "Point values: earnrate's defaults.";
|
|
67
|
-
|
|
67
|
+
const skipped = summary.skippedIssuers.length > 0
|
|
68
|
+
? [
|
|
69
|
+
`Next-card answers leave out: ${summary.skippedIssuers.map((i) => `${i.name} (${i.id})`).join(', ')}.`,
|
|
70
|
+
]
|
|
71
|
+
: [];
|
|
72
|
+
return [...cards, memberships, ...skipped, values].join('\n');
|
|
68
73
|
}
|
|
69
74
|
export function formatBooking(amount, name, // the card's short name
|
|
70
75
|
cap, bonus, milestones = [], credit = null, capLabel = null) {
|
package/dist/mcp/context.js
CHANGED
|
@@ -80,6 +80,9 @@ export function makeContext(deps) {
|
|
|
80
80
|
return {
|
|
81
81
|
valuations: wallet?.valuations ?? {},
|
|
82
82
|
memberships: wallet?.memberships ?? [],
|
|
83
|
+
...(wallet?.skipped_issuers
|
|
84
|
+
? { skipped_issuers: wallet.skipped_issuers }
|
|
85
|
+
: {}),
|
|
83
86
|
};
|
|
84
87
|
}
|
|
85
88
|
catch {
|
|
@@ -95,9 +98,14 @@ export function makeContext(deps) {
|
|
|
95
98
|
})),
|
|
96
99
|
memberships: memberships ?? (await saved()).memberships,
|
|
97
100
|
}, deps.today());
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
if (typeof named === 'string')
|
|
102
|
+
return named;
|
|
103
|
+
const { valuations, skipped_issuers } = await saved();
|
|
104
|
+
return {
|
|
105
|
+
...named,
|
|
106
|
+
valuations,
|
|
107
|
+
...(skipped_issuers ? { skipped_issuers } : {}),
|
|
108
|
+
};
|
|
101
109
|
}
|
|
102
110
|
const read = await readableWallet();
|
|
103
111
|
if (typeof read === 'string')
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -77,7 +77,7 @@ const cardFields = {
|
|
|
77
77
|
.string()
|
|
78
78
|
.regex(/^\d{2}-\d{2}$/)
|
|
79
79
|
.optional()
|
|
80
|
-
.describe("The account's anniversary as MM-DD
|
|
80
|
+
.describe("The account's anniversary as MM-DD: for cards whose cap resets on it, and for a fee card's renewal check (whether it still pays for itself before the fee comes due). Optional; the opening date stands in. Ask; don't guess."),
|
|
81
81
|
opened: IsoDate.nullable()
|
|
82
82
|
.optional()
|
|
83
83
|
.describe('Optional: the day the account opened, YYYY-MM-DD. Only for cards with a first-year rate (search_cards lists it), which applies only while a purchase falls in the window; without a date the card is ranked at its standard rates. Ask the user; never guess or assume a card is new. null removes it.'),
|
|
@@ -171,6 +171,7 @@ export function makeInputs(catalog) {
|
|
|
171
171
|
.optional()
|
|
172
172
|
.describe(`Instead of the user's saved point values (or earnrate's defaults): flat, points at their cash-out value, ${profileText('flat')}; or points-heavy, ${profileText('points-heavy')}, for people who transfer points to airlines and hotels.`),
|
|
173
173
|
};
|
|
174
|
+
const SkippedIssuers = z.array(z.enum(issuers));
|
|
174
175
|
const AllMemberships = Memberships.describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`);
|
|
175
176
|
return {
|
|
176
177
|
CategoryInput,
|
|
@@ -181,6 +182,7 @@ export function makeInputs(catalog) {
|
|
|
181
182
|
OfferFilters,
|
|
182
183
|
CatalogFilters,
|
|
183
184
|
AllMemberships,
|
|
185
|
+
SkippedIssuers,
|
|
184
186
|
};
|
|
185
187
|
}
|
|
186
188
|
// The catalog filters as exploreCards and simulate take them. Memberships or
|
package/dist/mcp/prompts.js
CHANGED
|
@@ -74,9 +74,9 @@ export function registerPrompts(server, deps, ctx) {
|
|
|
74
74
|
]));
|
|
75
75
|
server.registerPrompt('this-quarter', {
|
|
76
76
|
title: 'What should I do with my cards this quarter?',
|
|
77
|
-
description: 'Bonuses to activate, categories to pick, caps running low
|
|
77
|
+
description: 'Bonuses to activate, categories to pick, caps running low, welcome bonuses to finish and fee cards coming up for renewal, from your saved cards.',
|
|
78
78
|
}, () => ask('What should I do with my cards this quarter?', [
|
|
79
|
-
"Call earnrate's list_wallet: it starts with a to-do list. Walk through it, soonest first.",
|
|
79
|
+
"Call earnrate's list_wallet: it starts with a to-do list. Walk through it, soonest first. A renewal check says whether a fee card still pays for itself; give its math as stated, and don't recommend canceling or downgrading on your own.",
|
|
80
80
|
ctx.walletUse,
|
|
81
81
|
'If no cards are saved, say in a sentence or two what saving them adds (reminders to activate bonuses, pick categories, and watch caps and welcome bonuses), without pressing the user to do it, and offer the cheat sheet from every card instead.',
|
|
82
82
|
]));
|
|
@@ -92,7 +92,7 @@ export function registerPrompts(server, deps, ctx) {
|
|
|
92
92
|
: []),
|
|
93
93
|
'Find each card with search_cards and check the match with the user, then save it with add_card.',
|
|
94
94
|
ctx.walletUse,
|
|
95
|
-
|
|
95
|
+
"When a card needs category picks, a membership like Amazon Prime, activations or its anniversary, ask rather than guessing, and save the answers with update_card or set_memberships. For a card with an annual fee, offer to save its anniversary (or opening date) so earnrate can check before each renewal whether it still pays for itself; it's optional.",
|
|
96
96
|
'When done, call list_wallet and show its to-do list.',
|
|
97
97
|
]));
|
|
98
98
|
}
|