earnrate 0.2.13 → 0.2.15
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 +10 -10
- package/data/CHANGELOG.md +28 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-delta-skymiles-gold.yaml +7 -8
- package/data/cards/amex-delta-skymiles-platinum.yaml +7 -8
- package/data/cards/amex-delta-skymiles-reserve.yaml +9 -9
- package/data/cards/amex-marriott-bonvoy-business.yaml +7 -8
- package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
- package/data/cards/discover-it-cash-back.yaml +9 -6
- package/data/cards/venmo-credit-card.yaml +1 -1
- package/data/categories.yaml +10 -0
- package/data/merchants/seed.yaml +3 -1
- package/dist/cli/import.js +14 -11
- package/dist/cli/todo.js +3 -2
- package/dist/cli/wallet.js +50 -17
- package/dist/engine/cheat-sheet.js +15 -11
- package/dist/engine/checklist.js +23 -25
- package/dist/engine/credits.js +2 -2
- package/dist/engine/evaluate.js +1 -1
- package/dist/engine/explore.js +4 -4
- package/dist/engine/intro.js +1 -5
- package/dist/engine/memberships.js +2 -6
- package/dist/engine/offers.js +3 -5
- package/dist/engine/periods.js +5 -0
- package/dist/engine/profiles.js +2 -1
- package/dist/engine/recommend.js +46 -33
- package/dist/engine/record.js +2 -1
- package/dist/engine/rotation.js +2 -1
- package/dist/engine/rule-text.js +98 -33
- package/dist/engine/setup.js +7 -3
- package/dist/engine/simulate.js +4 -4
- package/dist/engine/text.js +29 -8
- package/dist/engine/wallet-change.js +5 -5
- package/dist/engine/wallet-summary.js +24 -21
- package/dist/format/card-text.js +2 -3
- package/dist/format/cards.js +7 -3
- package/dist/format/explore.js +2 -2
- package/dist/format/wallet-yaml.js +13 -17
- package/dist/format/wallet.js +14 -10
- package/dist/import/analyze.js +13 -1
- package/dist/mcp/context.js +2 -3
- package/dist/mcp/inputs.js +3 -3
- package/dist/mcp/schemas.js +2 -0
- package/dist/mcp/server.js +0 -2
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +10 -5
- package/dist/mcp/tools/wallet.js +10 -6
- package/dist/schema/card.js +6 -10
- package/dist/schema/merchant.js +3 -0
- package/package.json +2 -1
|
@@ -4,12 +4,12 @@ import { introUntil, introWindows, openedOn } from "./intro.js";
|
|
|
4
4
|
import { membershipName } from "./memberships.js";
|
|
5
5
|
import { milestoneSummaries } from "./milestones.js";
|
|
6
6
|
import { creditStates } from "./credits.js";
|
|
7
|
-
import { creditWhere, optionName, ruleParts } from "./rule-text.js";
|
|
7
|
+
import { creditWhere, optionName, ruleParts, rulePreposition, } from "./rule-text.js";
|
|
8
8
|
import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
|
|
9
9
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
10
10
|
import { rotationGap } from "./rotation.js";
|
|
11
11
|
import { activationRules } from "./setup.js";
|
|
12
|
-
import { CAP_PERIODS,
|
|
12
|
+
import { CAP_PERIODS, creditBack, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, earnText, listed, PAYMENT_PHRASES, shortDate, wholeDollars, words, } from "./text.js";
|
|
13
13
|
import { cardValuation, currencyValuation } from "./valuation.js";
|
|
14
14
|
const matchKeys = (rule) => rule.match.choice !== undefined
|
|
15
15
|
? []
|
|
@@ -30,11 +30,9 @@ function capLimit(card, shown, rule) {
|
|
|
30
30
|
shown.filter((r) => r.cap === rule.cap).length > 1;
|
|
31
31
|
return `first ${wholeDollars(cap.amount)} ${CAP_PERIODS[cap.period]}${shared ? ', combined' : ''}`;
|
|
32
32
|
}
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// Visa's 3% without Prime, once Prime is held). Rules at the same rate share
|
|
37
|
-
// a line.
|
|
33
|
+
// Best first. Leaves out rates behind a membership the wallet lacks and a
|
|
34
|
+
// lower tier an uncapped, always-on higher rate fully covers (Prime Visa's 3%
|
|
35
|
+
// without Prime, once Prime is held). Rules at the same rate share a line.
|
|
38
36
|
function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
39
37
|
const shown = card.rules
|
|
40
38
|
.filter((r) => inEffect(r, today) &&
|
|
@@ -65,22 +63,28 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
65
63
|
const pending = r.requires?.activation && !isActivated(card, held, r.id);
|
|
66
64
|
const payment = r.requires?.payment;
|
|
67
65
|
const key = [
|
|
68
|
-
|
|
66
|
+
earnText(catalog, r.rate, currency),
|
|
69
67
|
capLimit(card, shown, r),
|
|
70
68
|
pending ? ', once activated' : '',
|
|
71
69
|
payment === undefined ? '' : ` ${PAYMENT_PHRASES[payment]}`,
|
|
72
70
|
].join('|');
|
|
73
|
-
const
|
|
74
|
-
|
|
71
|
+
const line = lines.get(key) ?? { at: [], on: [] };
|
|
72
|
+
const whats = line[rulePreposition(catalog, [r])];
|
|
73
|
+
for (const part of ruleParts(catalog, r, card))
|
|
75
74
|
if (!whats.includes(part))
|
|
76
75
|
whats.push(part);
|
|
77
|
-
lines.set(key,
|
|
76
|
+
lines.set(key, line);
|
|
78
77
|
}
|
|
79
78
|
return [
|
|
80
|
-
...[...lines].map(([key,
|
|
79
|
+
...[...lines].map(([key, { at, on }]) => {
|
|
81
80
|
const [head = '', limit = '', activate = '', paid = ''] = key.split('|');
|
|
82
81
|
const after = `${limit}${activate}`;
|
|
83
|
-
|
|
82
|
+
// "at gas stations and on transit" when one line mixes the two.
|
|
83
|
+
const where = [
|
|
84
|
+
...(at.length > 0 ? [`at ${listed(at)}`] : []),
|
|
85
|
+
...(on.length > 0 ? [`on ${listed(on)}`] : []),
|
|
86
|
+
].join(at.length > 1 ? ', and ' : ' and ');
|
|
87
|
+
return `${head} ${where}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
|
|
84
88
|
}),
|
|
85
89
|
...(shown.some((r) => r.match.any &&
|
|
86
90
|
r.geography !== 'foreign_only' &&
|
|
@@ -103,13 +107,11 @@ export function activationsDue(catalog, card, held, today) {
|
|
|
103
107
|
seen.add(g);
|
|
104
108
|
const rules = card.rules.filter((r) => group.includes(r.id));
|
|
105
109
|
const first = rules[0];
|
|
106
|
-
const categories = rules.flatMap((r) => r.match.categories ?? []);
|
|
107
|
-
const servicesOnly = categories.length > 0 &&
|
|
108
|
-
rules.every((r) => (r.match.merchants ?? []).length === 0) &&
|
|
109
|
-
categories.every((c) => categoryReadsOn(catalog, c));
|
|
110
110
|
out.push({
|
|
111
|
-
what: listed([
|
|
112
|
-
|
|
111
|
+
what: listed([
|
|
112
|
+
...new Set(rules.flatMap((r) => ruleParts(catalog, r, card))),
|
|
113
|
+
]),
|
|
114
|
+
preposition: rulePreposition(catalog, rules),
|
|
113
115
|
from: first?.valid_from ?? null,
|
|
114
116
|
to: first?.valid_to ?? null,
|
|
115
117
|
activateBy: first?.activate_by ?? null,
|
|
@@ -154,8 +156,9 @@ function capSummaries(catalog, card, held, today) {
|
|
|
154
156
|
const shared = card.caps.find((c) => c.id === key);
|
|
155
157
|
const label = shared?.rotating
|
|
156
158
|
? 'the rotating bonus categories'
|
|
157
|
-
: listed([
|
|
158
|
-
|
|
159
|
+
: listed([
|
|
160
|
+
...new Set(covered.flatMap((r) => ruleParts(catalog, r, card))),
|
|
161
|
+
]) || words(key);
|
|
159
162
|
return {
|
|
160
163
|
key,
|
|
161
164
|
label,
|
package/dist/format/card-text.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { earnText } from "../engine/text.js";
|
|
2
1
|
// Card names, fees and requirements as earnrate.dev and its static search
|
|
3
2
|
// pages show them. Node-free: the site bundles it.
|
|
4
3
|
import { membershipName, membershipText } from "../engine/memberships.js";
|
|
5
|
-
import {
|
|
4
|
+
import { capSentence, earnText, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
|
|
6
5
|
// An issuer's name as people know it, from data/issuers.yaml.
|
|
7
6
|
export const issuerName = (catalog, id) => catalog.issuers.find((i) => i.id === id)?.name ?? id;
|
|
8
7
|
export function cardText(catalog) {
|
|
@@ -42,7 +41,7 @@ export function cardText(catalog) {
|
|
|
42
41
|
if (until !== null)
|
|
43
42
|
lines.push(`This rate runs through ${until}`);
|
|
44
43
|
if (r.cap)
|
|
45
|
-
lines.push(
|
|
44
|
+
lines.push(capSentence(r.cap, earnText(catalog, r.cap.thenRate, r.cap.currency)));
|
|
46
45
|
return lines;
|
|
47
46
|
}
|
|
48
47
|
const memberName = (id) => membershipName(catalog, id);
|
package/dist/format/cards.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { membershipName } from "../engine/memberships.js";
|
|
1
2
|
import { foreignFeeText, money, NETWORK_NAMES } from "../engine/text.js";
|
|
2
|
-
export function formatCardInfo(card) {
|
|
3
|
+
export function formatCardInfo(catalog, card) {
|
|
3
4
|
const fee = card.annualFee === null
|
|
4
5
|
? ''
|
|
5
6
|
: card.annualFee === 0
|
|
6
7
|
? ', no annual fee'
|
|
7
|
-
: `, ${money(card.annualFee)} a year`;
|
|
8
|
+
: `, ${money(card.annualFee)} a year${card.firstYearFee === null ? '' : `, ${card.firstYearFee === 0 ? 'waived' : money(card.firstYearFee)} the first year`}`;
|
|
8
9
|
const needs = [
|
|
9
10
|
...card.choices.map((c) => `${c.id}: pick ${String(c.pick)} of ${c.options.join(', ')}${c.default ? ` (default ${c.default.join(', ')})` : ''}`),
|
|
10
11
|
...(card.requiresMembership
|
|
@@ -13,7 +14,9 @@ export function formatCardInfo(card) {
|
|
|
13
14
|
]
|
|
14
15
|
: []),
|
|
15
16
|
...(card.memberships.length > 0
|
|
16
|
-
? [
|
|
17
|
+
? [
|
|
18
|
+
`rates that need a membership: ${card.memberships.map((id) => `${membershipName(catalog, id)} (membership id ${id})`).join(', ')}`,
|
|
19
|
+
]
|
|
17
20
|
: []),
|
|
18
21
|
...(card.activation.length > 0
|
|
19
22
|
? [`activate with the issuer: ${card.activation.join(', ')}`]
|
|
@@ -21,6 +24,7 @@ export function formatCardInfo(card) {
|
|
|
21
24
|
...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
|
|
22
25
|
...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
|
|
23
26
|
...card.introRates.map((i) => `new accounts: ${i.text}; needs the opening date`),
|
|
27
|
+
...card.credits.map((c) => `statement credit ${c.id}: ${c.text}`),
|
|
24
28
|
...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
|
|
25
29
|
foreignFeeText(card.foreignTransactionFee).toLowerCase(),
|
|
26
30
|
];
|
package/dist/format/explore.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { membershipName, membershipNote, } from "../engine/memberships.js";
|
|
2
|
-
import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
|
|
2
|
+
import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, listed, } from "../engine/text.js";
|
|
3
3
|
const membershipFees = (memberships) => memberships
|
|
4
4
|
.map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
|
|
5
5
|
.join('');
|
|
@@ -51,7 +51,7 @@ export function formatExploration(r, catalog, all) {
|
|
|
51
51
|
return `${lines.join('\n')}\n`;
|
|
52
52
|
}
|
|
53
53
|
function picksText(picks) {
|
|
54
|
-
const parts = Object.entries(picks).map(([choice, options]) => `${options.map(words)
|
|
54
|
+
const parts = Object.entries(picks).map(([choice, options]) => `${listed(options.map(words))} as its ${choiceName(choice)} ${options.length === 1 ? 'pick' : 'picks'}`);
|
|
55
55
|
return parts.length === 0 ? '' : ` (with ${parts.join('; ')})`;
|
|
56
56
|
}
|
|
57
57
|
function cardNamer(catalog) {
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { Document, parse, visit } from 'yaml';
|
|
2
2
|
import { walletProblems } from "../data/integrity.js";
|
|
3
3
|
import { CARD_NUMBER_LABEL, CARD_NUMBER_REDEMPTION, } from "../schema/card-number.js";
|
|
4
|
-
import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
|
|
4
|
+
import { WALLET_VERSION, WalletCardSchema, WalletSchema, } from "../schema/wallet.js";
|
|
5
5
|
// A wallet as the YAML file the CLI reads, for moving it between the
|
|
6
6
|
// website, the remote server and the command line. Node-free.
|
|
7
|
+
// Every field the schema defines, in its order, so a new wallet field is
|
|
8
|
+
// exported without anyone remembering to add it here.
|
|
9
|
+
const CARD_FIELDS = Object.keys(WalletCardSchema.shape);
|
|
10
|
+
const isEmpty = (value) => value === undefined ||
|
|
11
|
+
(Array.isArray(value)
|
|
12
|
+
? value.length === 0
|
|
13
|
+
: typeof value === 'object' &&
|
|
14
|
+
value !== null &&
|
|
15
|
+
Object.keys(value).length === 0);
|
|
7
16
|
function compact(wallet) {
|
|
8
17
|
return {
|
|
9
18
|
version: wallet.version ?? WALLET_VERSION,
|
|
@@ -13,24 +22,11 @@ function compact(wallet) {
|
|
|
13
22
|
...(wallet.memberships.length > 0
|
|
14
23
|
? { memberships: wallet.memberships }
|
|
15
24
|
: {}),
|
|
16
|
-
cards: wallet.cards.map((c) => (
|
|
17
|
-
id: c.id,
|
|
18
|
-
...(c.checkout_label === undefined
|
|
19
|
-
? {}
|
|
20
|
-
: { checkout_label: c.checkout_label }),
|
|
21
|
-
...(c.anniversary === undefined ? {} : { anniversary: c.anniversary }),
|
|
22
|
-
...(c.opened === undefined ? {} : { opened: c.opened }),
|
|
23
|
-
...(Object.keys(c.cap_usage).length > 0
|
|
24
|
-
? { cap_usage: c.cap_usage }
|
|
25
|
-
: {}),
|
|
26
|
-
...(c.chosen === undefined ? {} : { chosen: c.chosen }),
|
|
27
|
-
...(c.activated.length > 0 ? { activated: c.activated } : {}),
|
|
28
|
-
...(c.bonus === undefined ? {} : { bonus: c.bonus }),
|
|
29
|
-
})),
|
|
25
|
+
cards: wallet.cards.map((c) => Object.fromEntries(CARD_FIELDS.flatMap((k) => (isEmpty(c[k]) ? [] : [[k, c[k]]])))),
|
|
30
26
|
};
|
|
31
27
|
}
|
|
32
|
-
// Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare
|
|
33
|
-
// date, not a string.
|
|
28
|
+
// Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare
|
|
29
|
+
// 2026-10-15 as a date, not a string.
|
|
34
30
|
function quotedYaml(value) {
|
|
35
31
|
const doc = new Document(value);
|
|
36
32
|
visit(doc, {
|
package/dist/format/wallet.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CAP_PERIODS, introAdjective, introSince, money, NETWORK_NAMES, periodPhrase, roundCents, shortDate, } from "../engine/text.js";
|
|
1
|
+
import { CAP_PERIODS, introAdjective, introSince, money, NETWORK_NAMES, periodPhrase, roundCents, shortDate, possessive, } from "../engine/text.js";
|
|
2
2
|
// "To do now", above the card details; nothing when all's done.
|
|
3
3
|
export function formatChecklist(items) {
|
|
4
4
|
return items.length === 0
|
|
@@ -48,10 +48,10 @@ export function formatWalletSummary(summary) {
|
|
|
48
48
|
else if (c.intro?.state === 'no-date')
|
|
49
49
|
lines.push(` Has ${introAdjective(c.intro.days)} rates for new accounts; give its opening date if it opened ${introSince(c.intro.days)}.`);
|
|
50
50
|
else if (c.opened !== null)
|
|
51
|
-
lines.push(` Opened ${c.opened}.`);
|
|
51
|
+
lines.push(` Opened ${shortDate(c.opened)}.`);
|
|
52
52
|
const b = c.bonus;
|
|
53
53
|
if (b?.state === 'open' && b.spend !== null)
|
|
54
|
-
lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline
|
|
54
|
+
lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline === null ? 'by its deadline' : shortDate(b.deadline)}.`);
|
|
55
55
|
else if (b)
|
|
56
56
|
lines.push(` Welcome bonus: ${b.state}.`);
|
|
57
57
|
return lines.join('\n');
|
|
@@ -64,29 +64,33 @@ export function formatWalletSummary(summary) {
|
|
|
64
64
|
: "Point values: earnrate's defaults.";
|
|
65
65
|
return [...cards, memberships, values].join('\n');
|
|
66
66
|
}
|
|
67
|
-
export function formatBooking(amount,
|
|
67
|
+
export function formatBooking(amount, name, // the card's short name
|
|
68
|
+
cap, bonus, milestones = [], credit = null, capLabel = null) {
|
|
68
69
|
const messages = [];
|
|
69
70
|
if (credit)
|
|
70
|
-
messages.push(`Used ${money(roundCents(credit.after - credit.before))} of ${
|
|
71
|
+
messages.push(`Used ${money(roundCents(credit.after - credit.before))} of ${possessive(name)} ${credit.name} credit: ${money(credit.after)} of ${money(credit.amount)} ${periodPhrase(credit.periodKind, credit.period)}, ${money(roundCents(Math.max(0, credit.amount - credit.after)))} left.`);
|
|
71
72
|
if (cap) {
|
|
72
73
|
const left = Math.max(0, cap.capAmount - cap.after);
|
|
73
|
-
messages.push(`Recorded ${money(amount)} on ${
|
|
74
|
+
messages.push(`Recorded ${money(amount)} on ${name} toward its ${capLabel ?? cap.capKey} cap: ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} ${periodPhrase(cap.periodKind, cap.period)}, ${money(left)} left.`);
|
|
74
75
|
}
|
|
75
76
|
if (bonus) {
|
|
76
77
|
const left = Math.max(0, bonus.spend - bonus.after);
|
|
77
78
|
messages.push(left === 0
|
|
78
|
-
? `Welcome bonus spend met on ${
|
|
79
|
-
: `Welcome bonus on ${
|
|
79
|
+
? `Welcome bonus spend met on ${name}: ${money(bonus.after)} of ${money(bonus.spend)}.`
|
|
80
|
+
: `Welcome bonus on ${name}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline === null ? 'the deadline' : shortDate(bonus.deadline)}.`);
|
|
80
81
|
}
|
|
81
82
|
// Every milestone counts the same purchases, so one line covers them.
|
|
82
83
|
const first = milestones[0];
|
|
83
84
|
if (first) {
|
|
84
85
|
const reached = milestones.filter((m) => m.before < m.spend && m.after >= m.spend);
|
|
85
86
|
const next = milestones.find((m) => m.after < m.spend);
|
|
87
|
+
const period = first.periodKind === 'cardmember_year' ? 'cardmember year' : 'year';
|
|
86
88
|
messages.push([
|
|
87
|
-
|
|
89
|
+
first.started
|
|
90
|
+
? `Started tracking spend on ${name} this ${period} with this purchase: ${money(first.after)}. Spending on it earlier this ${period} isn't counted; give the total so far (milestoneSpent) to include it.`
|
|
91
|
+
: `Spend on ${name} this ${period}: ${money(first.after)}.`,
|
|
88
92
|
...reached.map((m) => `That reaches its ${money(m.spend)} spend bonus.`),
|
|
89
|
-
...(next
|
|
93
|
+
...(next && !first.started
|
|
90
94
|
? [
|
|
91
95
|
`${money(next.spend - next.after)} to go for the ${money(next.spend)} spend bonus.`,
|
|
92
96
|
]
|
package/dist/import/analyze.js
CHANGED
|
@@ -4,7 +4,7 @@ import { capByKey, categoryMatches } from "../engine/evaluate.js";
|
|
|
4
4
|
import { milestoneProgress } from "../engine/milestones.js";
|
|
5
5
|
import { periodKey } from "../engine/periods.js";
|
|
6
6
|
import { recommend } from "../engine/recommend.js";
|
|
7
|
-
import { applyBooking, capBooking } from "../engine/record.js";
|
|
7
|
+
import { applyBooking, applyCreditBooking, capBooking, creditBooking, } from "../engine/record.js";
|
|
8
8
|
import { roundCents } from "../engine/text.js";
|
|
9
9
|
import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
|
|
10
10
|
import { cleanDescriptor } from "./statement.js";
|
|
@@ -55,6 +55,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
55
55
|
const missed = new Map();
|
|
56
56
|
const coding = new Map();
|
|
57
57
|
const bookings = [];
|
|
58
|
+
const credits = [];
|
|
58
59
|
let outsideCurrentPeriod = 0;
|
|
59
60
|
let alreadyImported = 0;
|
|
60
61
|
let purchases = 0;
|
|
@@ -145,6 +146,16 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
145
146
|
outsideCurrentPeriod++;
|
|
146
147
|
}
|
|
147
148
|
}
|
|
149
|
+
if (line.amount > 0 && !alreadyRecorded(entry, 'credit')) {
|
|
150
|
+
const credit = creditBooking(catalog, current, recorded, merchant);
|
|
151
|
+
const held = current.cards.find((c) => c.id === cardId);
|
|
152
|
+
if (credit &&
|
|
153
|
+
periodKey(credit.periodKind, today, held?.anniversary ?? null) ===
|
|
154
|
+
credit.period) {
|
|
155
|
+
credits.push({ ...entry, booking: credit });
|
|
156
|
+
current = applyCreditBooking(current, credit);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
148
159
|
if (line.amount < 0) {
|
|
149
160
|
refunds += -line.amount;
|
|
150
161
|
continue;
|
|
@@ -244,6 +255,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
244
255
|
.sort((a, b) => b.best - b.earned - (a.best - a.earned)),
|
|
245
256
|
coding: [...coding.values()].sort((a, b) => b.count - a.count),
|
|
246
257
|
bookings,
|
|
258
|
+
credits,
|
|
247
259
|
outsideCurrentPeriod,
|
|
248
260
|
alreadyImported,
|
|
249
261
|
bonuses: [...bonuses.values()].filter((b) => b.entries.length > 0),
|
package/dist/mcp/context.js
CHANGED
|
@@ -72,9 +72,8 @@ export function makeContext(deps) {
|
|
|
72
72
|
}
|
|
73
73
|
// Cards named in the conversation come first, then the saved wallet; with
|
|
74
74
|
// neither (null), answers cover every card earnrate knows.
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// none.
|
|
75
|
+
// Saved point values and memberships apply to every answer, not just ones
|
|
76
|
+
// from saved cards. A wallet that can't be read has none.
|
|
78
77
|
async function saved() {
|
|
79
78
|
try {
|
|
80
79
|
const wallet = await deps.store.load();
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -80,15 +80,15 @@ const cardFields = {
|
|
|
80
80
|
milestoneSpent: z
|
|
81
81
|
.record(z.string(), z.number().nonnegative())
|
|
82
82
|
.optional()
|
|
83
|
-
.describe('Dollars already spent on the card this year, for its spend
|
|
83
|
+
.describe('Dollars already spent on the card this year, for its spend bonuses (bonuses after a yearly spending total, as search_cards lists them): milestone id → dollars, e.g. {"spend-15k": 9000}. Every purchase on the card counts, so give each milestone the same total. Ask; recorded purchases add to it from then on.'),
|
|
84
84
|
enrolled: z
|
|
85
85
|
.array(z.string())
|
|
86
86
|
.optional()
|
|
87
|
-
.describe('
|
|
87
|
+
.describe('Statement credit ids the user has enrolled in with the issuer (search_cards and list_wallet list each card\'s credits, e.g. "lululemon" on Amex Platinum). A credit that needs enrolling counts in answers only once it\'s here. Replaces the current list. Ask; never assume.'),
|
|
88
88
|
creditUsed: z
|
|
89
89
|
.record(z.string(), z.number().nonnegative())
|
|
90
90
|
.optional()
|
|
91
|
-
.describe('Dollars of a
|
|
91
|
+
.describe('Dollars of a statement credit already received this period: credit id → dollars, e.g. {"lululemon": 40}. Ask; recorded purchases add to it from then on.'),
|
|
92
92
|
bonus: z
|
|
93
93
|
.object({
|
|
94
94
|
opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -245,6 +245,7 @@ export const RecordResultSchema = z.object({
|
|
|
245
245
|
spend: z.number(),
|
|
246
246
|
before: z.number(),
|
|
247
247
|
after: z.number(),
|
|
248
|
+
started: z.boolean(),
|
|
248
249
|
})),
|
|
249
250
|
credit: z
|
|
250
251
|
.object({
|
|
@@ -297,6 +298,7 @@ export const CardSearchSchema = z.object({
|
|
|
297
298
|
network: CardNetwork,
|
|
298
299
|
currency: z.string(),
|
|
299
300
|
annualFee: z.number().nullable(),
|
|
301
|
+
firstYearFee: z.number().nullable(),
|
|
300
302
|
foreignTransactionFee: z.number().nullable(),
|
|
301
303
|
business: z.boolean(),
|
|
302
304
|
onlyAt: z.array(z.string()).nullable(),
|
package/dist/mcp/server.js
CHANGED
|
@@ -6,7 +6,6 @@ import { registerPrompts } from "./prompts.js";
|
|
|
6
6
|
import { registerAccountTools } from "./tools/account.js";
|
|
7
7
|
import { registerAnswerTools } from "./tools/answers.js";
|
|
8
8
|
import { registerWalletTools } from "./tools/wallet.js";
|
|
9
|
-
// The tools that answer without a saved wallet.
|
|
10
9
|
export const ANSWER_TOOLS = [
|
|
11
10
|
'best_card_for_purchase',
|
|
12
11
|
'best_cards_for',
|
|
@@ -16,7 +15,6 @@ export const ANSWER_TOOLS = [
|
|
|
16
15
|
'resolve_merchant',
|
|
17
16
|
'search_cards',
|
|
18
17
|
];
|
|
19
|
-
// The tools that read or change a saved wallet.
|
|
20
18
|
export const SAVED_WALLET_TOOLS = [
|
|
21
19
|
'list_wallet',
|
|
22
20
|
'record_purchase',
|
|
@@ -20,7 +20,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
20
20
|
];
|
|
21
21
|
server.registerTool('export_wallet', {
|
|
22
22
|
title: 'Export the wallet',
|
|
23
|
-
description: 'The saved wallet as a wallet.yaml file
|
|
23
|
+
description: 'The saved wallet as a wallet.yaml file: card ids, checkout labels, picks, activations, opening dates and anniversaries, memberships, point values, cap usage, welcome-bonus progress, yearly spending toward spend bonuses, and statement credits enrolled in and used. earnrate.dev imports it, and the earnrate command line reads it saved as ~/.earnrate/wallet.yaml. Never card numbers.',
|
|
24
24
|
inputSchema: z.object({}),
|
|
25
25
|
outputSchema: z.object({ yaml: z.string() }),
|
|
26
26
|
annotations: {
|
|
@@ -34,7 +34,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
34
34
|
const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, } = inputs;
|
|
35
35
|
server.registerTool('best_card_for_purchase', {
|
|
36
36
|
title: 'Best card for a purchase',
|
|
37
|
-
description: "Which credit card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. For a business earnrate may not know, like a local restaurant, pass `category` when you can tell what kind of business it is. When the user is paying outside the U.S. or in another currency, pass `country`: abroad, each card is ranked after its foreign transaction fee. Pass `payment` only when the user says how they're paying, like Apple Pay or PayPal checkout. A
|
|
37
|
+
description: "Which credit card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. For a business earnrate may not know, like a local restaurant, pass `category` when you can tell what kind of business it is. When the user is paying outside the U.S. or in another currency, pass `country`: abroad, each card is ranked after its foreign transaction fee. Pass `payment` only when the user says how they're paying, like Apple Pay or PayPal checkout. A statement credit (a card's `credit`, like Amex Platinum's lululemon credit) counts toward the pick only once it's enrolled in the wallet and has dollars left this period, and only when the amount is given; say its dollars apart from the % back. With no cards, `allCards.credits` lists cards with a credit at the store; present those first. Never needs or returns card numbers.",
|
|
38
38
|
inputSchema: z.object({
|
|
39
39
|
merchant: Merchant,
|
|
40
40
|
amount: z
|
|
@@ -166,6 +166,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
166
166
|
.optional()
|
|
167
167
|
.describe('Include cards already in the wallet.'),
|
|
168
168
|
cards: NamedCards,
|
|
169
|
+
memberships: NamedMemberships,
|
|
169
170
|
}),
|
|
170
171
|
outputSchema: OfferComparisonSchema,
|
|
171
172
|
annotations: {
|
|
@@ -175,10 +176,13 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
175
176
|
openWorldHint: false,
|
|
176
177
|
},
|
|
177
178
|
}, async (args) => {
|
|
178
|
-
const wallet = await ctx.cardsFor(args.cards,
|
|
179
|
+
const wallet = await ctx.cardsFor(args.cards, args.memberships);
|
|
179
180
|
if (typeof wallet === 'string')
|
|
180
181
|
return fail(wallet);
|
|
181
|
-
const
|
|
182
|
+
const found = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
|
|
183
|
+
const base = args.memberships
|
|
184
|
+
? { ...found, memberships: args.memberships }
|
|
185
|
+
: found;
|
|
182
186
|
const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
|
|
183
187
|
kind: args.kind ?? 'any',
|
|
184
188
|
noAnnualFee: args.noAnnualFee ?? false,
|
|
@@ -391,7 +395,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
391
395
|
});
|
|
392
396
|
server.registerTool('search_cards', {
|
|
393
397
|
title: 'Search cards',
|
|
394
|
-
description: 'Finds credit cards in earnrate\'s catalog by name or issuer, e.g. "sapphire", "amex gold" or "bofa". Returns each card\'s id (for add_card), network, annual fee, whether it\'s already in the wallet,
|
|
398
|
+
description: 'Finds credit cards in earnrate\'s catalog by name or issuer, e.g. "sapphire", "amex gold" or "bofa". Returns each card\'s id (for add_card), network, annual fee (and any first-year waiver), foreign transaction fee, whether it\'s already in the wallet, what it needs set up (category picks and their options, memberships, activations), its yearly spend bonuses, first-year rates and statement credits, each with the id update_card takes. Leave the query out to list every card. US cards only.',
|
|
395
399
|
inputSchema: z.object({
|
|
396
400
|
query: z
|
|
397
401
|
.string()
|
|
@@ -417,7 +421,8 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
417
421
|
content: [
|
|
418
422
|
{
|
|
419
423
|
type: 'text',
|
|
420
|
-
text: header +
|
|
424
|
+
text: header +
|
|
425
|
+
result.cards.map((c) => formatCardInfo(catalog, c)).join('\n'),
|
|
421
426
|
},
|
|
422
427
|
],
|
|
423
428
|
structuredContent: result,
|
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -63,12 +63,11 @@ function changes(catalog, args) {
|
|
|
63
63
|
out.push(`welcome bonus tracked from ${args.bonus.opened}, ${money(args.bonus.spent ?? 0)} spent so far`);
|
|
64
64
|
return out;
|
|
65
65
|
}
|
|
66
|
-
// The tools that read or change a saved wallet.
|
|
67
66
|
export function registerWalletTools(server, deps, ctx, inputs) {
|
|
68
67
|
const { catalog, store } = deps;
|
|
69
68
|
server.registerTool('list_wallet', {
|
|
70
69
|
title: 'List wallet',
|
|
71
|
-
description: `Starts with a to-do list for the user's cards right now (bonuses to activate and their deadlines, categories to pick or confirm, caps nearly used up, welcome bonuses to finish and the weekly pace, yearly spend bonuses at least 75% of the way,
|
|
70
|
+
description: `Starts with a to-do list for the user's cards right now (bonuses to activate and their deadlines, categories to pick or confirm, caps nearly used up, welcome bonuses to finish and the weekly pace, yearly spend bonuses at least 75% of the way, statement credits to enroll in or use before they reset); use it when the user asks what to do with their cards this quarter or what they're forgetting. Then lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, progress toward yearly spend bonuses, statement credits (enrolled or not, and what's left this period), which stores a store card works at, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
|
|
72
71
|
outputSchema: WalletSummarySchema,
|
|
73
72
|
annotations: {
|
|
74
73
|
title: 'List wallet',
|
|
@@ -102,7 +101,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
102
101
|
});
|
|
103
102
|
server.registerTool('record_purchase', {
|
|
104
103
|
title: 'Record a purchase',
|
|
105
|
-
description: `Records a purchase the user has already made, so spending caps, welcome-bonus progress, yearly spend
|
|
104
|
+
description: `Records a purchase the user has already made, so spending caps, welcome-bonus progress, yearly spend bonuses and statement credits used stay accurate for future recommendations. It updates the user's saved wallet. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap, no open welcome bonus, no spend bonus and no enrolled statement credit are acknowledged and nothing is written. For a merchant earnrate doesn't recognize it asks for category first, so caps aren't missed. ${ctx.walletUse}`,
|
|
106
105
|
inputSchema: z.object({
|
|
107
106
|
cardId: z
|
|
108
107
|
.string()
|
|
@@ -142,6 +141,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
142
141
|
if (!result.ok)
|
|
143
142
|
return fail(`Couldn't record it: ${result.error}`);
|
|
144
143
|
const { cap: booking, bonus, milestones, credit } = result;
|
|
144
|
+
const capLabel = booking
|
|
145
|
+
? (walletSummary(catalog, w, purchase.date)
|
|
146
|
+
.cards.find((c) => c.cardId === cardId)
|
|
147
|
+
?.caps.find((c) => c.key === booking.capKey)?.label ?? null)
|
|
148
|
+
: null;
|
|
145
149
|
const output = booking || bonus || milestones.length > 0 || credit
|
|
146
150
|
? {
|
|
147
151
|
recorded: true,
|
|
@@ -149,7 +153,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
149
153
|
bonus,
|
|
150
154
|
milestones,
|
|
151
155
|
credit,
|
|
152
|
-
message: `${formatBooking(amount, cardId, booking, bonus, milestones, credit)} Saved in ${ctx.walletName}.`,
|
|
156
|
+
message: `${formatBooking(amount, cardText(catalog).shortName(cardId), booking, bonus, milestones, credit, capLabel)} Saved in ${ctx.walletName}.`,
|
|
153
157
|
}
|
|
154
158
|
: {
|
|
155
159
|
recorded: false,
|
|
@@ -157,7 +161,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
157
161
|
bonus: null,
|
|
158
162
|
milestones: [],
|
|
159
163
|
credit: null,
|
|
160
|
-
message: `Nothing to track: ${cardId} has no spending cap, open welcome bonus, spend
|
|
164
|
+
message: `Nothing to track: ${cardText(catalog).shortName(cardId)} has no spending cap, open welcome bonus, spend bonus or enrolled statement credit for this purchase, so the wallet wasn't changed.`,
|
|
161
165
|
};
|
|
162
166
|
return {
|
|
163
167
|
content: [
|
|
@@ -191,7 +195,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
191
195
|
});
|
|
192
196
|
server.registerTool('update_card', {
|
|
193
197
|
title: 'Update a card',
|
|
194
|
-
description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, opening date (optional; for first-year rates), activated categories, cap spending so far (capSpent), spending this year toward its spend
|
|
198
|
+
description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, opening date (optional; for first-year rates), activated categories, cap spending so far (capSpent), spending this year toward its spend bonuses (milestoneSpent), statement credits enrolled in (enrolled) and used this period (creditUsed), or a welcome bonus to track. Picks replace the saved ones in each choice group you set (other groups are kept) for the current period. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+. ${ctx.walletUse}`,
|
|
195
199
|
inputSchema: CardChange,
|
|
196
200
|
outputSchema: WalletChangeSchema,
|
|
197
201
|
annotations: {
|
package/dist/schema/card.js
CHANGED
|
@@ -157,11 +157,9 @@ export const ChoiceSchema = z.strictObject({
|
|
|
157
157
|
})),
|
|
158
158
|
])),
|
|
159
159
|
});
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// only those rules' rates, inside their caps; past a cap they earn what they
|
|
164
|
-
// always do. Without, it changes every rate on the card, past caps included.
|
|
160
|
+
// Counted from the opening day. With `rules`, it changes only those rules'
|
|
161
|
+
// rates, inside their caps; past a cap they earn what they always do.
|
|
162
|
+
// Without, it changes every rate on the card, past caps included.
|
|
165
163
|
export const IntroRateSchema = z
|
|
166
164
|
.strictObject({
|
|
167
165
|
id: KebabId,
|
|
@@ -172,11 +170,9 @@ export const IntroRateSchema = z
|
|
|
172
170
|
note: z.string().optional(),
|
|
173
171
|
})
|
|
174
172
|
.refine((i) => (i.multiply === undefined) !== (i.add === undefined), 'an intro rate needs multiply or add, not both');
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// earns the card's rate on its whole amount, and the credit comes on top.
|
|
179
|
-
// `half_year` runs January to June and July to December.
|
|
173
|
+
// Separate from earn rates: the purchase still earns the card's rate on its
|
|
174
|
+
// whole amount, and the credit comes on top. `half_year` runs January to June
|
|
175
|
+
// and July to December.
|
|
180
176
|
export const CreditPeriod = z.enum([...CapPeriod.options, 'half_year']);
|
|
181
177
|
export const CreditSchema = z.strictObject({
|
|
182
178
|
id: KebabId,
|
package/dist/schema/merchant.js
CHANGED
|
@@ -34,6 +34,9 @@ export const MerchantSchema = z.strictObject({
|
|
|
34
34
|
// Costco Travel), or a booking made through one (booking: "Hotel through
|
|
35
35
|
// Amex Travel").
|
|
36
36
|
kind: z.enum(['portal', 'booking']).optional(),
|
|
37
|
+
// "on Walmart+ membership" rather than "at": a membership or subscription,
|
|
38
|
+
// not a store.
|
|
39
|
+
preposition: z.literal('on').optional(),
|
|
37
40
|
note: z.string().optional(),
|
|
38
41
|
});
|
|
39
42
|
export const MerchantsFileSchema = z.array(MerchantSchema);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "earnrate",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
4
4
|
"description": "Which credit card earns the most at a merchant, from your cards or every card, and why",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"credit cards",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"monitor:deploy": "WRANGLER_SEND_METRICS=false wrangler deploy --config monitor/wrangler.jsonc",
|
|
55
55
|
"site:build": "vite build site && node scripts/build-pages.ts",
|
|
56
56
|
"site:dev": "vite site",
|
|
57
|
+
"site:preview": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy --config wrangler.preview.jsonc",
|
|
57
58
|
"site:deploy": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy && node scripts/indexnow.ts",
|
|
58
59
|
"test": "vitest run",
|
|
59
60
|
"test:watch": "vitest",
|