earnrate 0.2.6 → 0.2.8
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 +11 -7
- package/data/CHANGELOG.md +25 -0
- package/data/VERSION +1 -1
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +0 -1
- package/data/cards/chase-freedom-flex.yaml +0 -1
- package/data/cards/chase-prime-visa.yaml +12 -1
- package/dist/brand.js +1 -0
- package/dist/cli/import.js +3 -5
- package/dist/cli/missed.js +65 -0
- package/dist/cli/run.js +15 -0
- package/dist/cli/todo.js +62 -0
- package/dist/cli/wallet.js +1 -1
- package/dist/data/atomic-write.js +13 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +3 -6
- package/dist/engine/cheat-sheet.js +10 -1
- package/dist/engine/checklist.js +152 -0
- package/dist/engine/evaluate.js +14 -7
- package/dist/engine/explore.js +2 -2
- package/dist/engine/recommend.js +11 -5
- package/dist/engine/rule-text.js +47 -0
- package/dist/engine/setup.js +1 -0
- package/dist/engine/wallet-change.js +3 -3
- package/dist/engine/wallet-summary.js +39 -60
- package/dist/format/wallet-yaml.js +5 -1
- package/dist/format/wallet.js +6 -0
- package/dist/mcp/context.js +8 -4
- package/dist/mcp/schemas.js +8 -0
- package/dist/mcp/serve.js +4 -0
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/tools/wallet.js +10 -5
- package/dist/merchants/cache.js +3 -5
- package/dist/merchants/fallback.js +8 -0
- package/dist/merchants/missed.js +77 -0
- package/dist/schema/card-number.js +31 -0
- package/dist/schema/wallet.js +10 -2
- package/package.json +1 -1
package/dist/engine/evaluate.js
CHANGED
|
@@ -147,14 +147,14 @@ function capState(rule, card, held, date) {
|
|
|
147
147
|
const { period, spent } = capUsed(cap, key, held, date);
|
|
148
148
|
return { key, cap, period, remaining: Math.max(0, cap.amount - spent) };
|
|
149
149
|
}
|
|
150
|
-
function rateAfterCap(rate, cap, amount) {
|
|
150
|
+
function rateAfterCap(rate, cap, afterCap, amount) {
|
|
151
151
|
if (!cap)
|
|
152
152
|
return rate;
|
|
153
153
|
if (amount !== null && amount > 0) {
|
|
154
154
|
const inCap = Math.min(amount, cap.remaining);
|
|
155
|
-
return (inCap * rate + (amount - inCap) *
|
|
155
|
+
return (inCap * rate + (amount - inCap) * afterCap) / amount;
|
|
156
156
|
}
|
|
157
|
-
return cap.remaining > 0 ? rate :
|
|
157
|
+
return cap.remaining > 0 ? rate : afterCap;
|
|
158
158
|
}
|
|
159
159
|
// On equal rates an uncapped rule wins, so spend that can earn the same
|
|
160
160
|
// without a cap doesn't use one up. Otherwise the earlier rule stays.
|
|
@@ -170,24 +170,31 @@ export function evaluateCard(card, held, ctx) {
|
|
|
170
170
|
nominalRate: card.base_rate,
|
|
171
171
|
rate: card.base_rate,
|
|
172
172
|
cap: null,
|
|
173
|
+
afterCap: null,
|
|
173
174
|
};
|
|
174
175
|
let best = base;
|
|
175
176
|
let nominal = base;
|
|
176
177
|
const blocked = [];
|
|
178
|
+
const applying = [];
|
|
177
179
|
for (const rule of card.rules) {
|
|
178
180
|
const status = applicability(card, rule, ctx, held, picked);
|
|
179
181
|
if (status === 'no')
|
|
180
182
|
continue;
|
|
181
|
-
if (status === 'needs-activation')
|
|
183
|
+
if (status === 'needs-activation')
|
|
182
184
|
blocked.push(rule);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
+
else
|
|
186
|
+
applying.push(rule);
|
|
187
|
+
}
|
|
188
|
+
const uncapped = Math.max(card.base_rate, ...applying.filter((r) => r.cap === undefined).map((r) => r.rate));
|
|
189
|
+
for (const rule of applying) {
|
|
185
190
|
const cap = capState(rule, card, held, ctx.date);
|
|
191
|
+
const afterCap = cap ? Math.max(cap.cap.then_rate, uncapped) : null;
|
|
186
192
|
const earning = {
|
|
187
193
|
rule,
|
|
188
194
|
nominalRate: rule.rate,
|
|
189
|
-
rate: rateAfterCap(rule.rate, cap, ctx.amount),
|
|
195
|
+
rate: rateAfterCap(rule.rate, cap, afterCap ?? rule.rate, ctx.amount),
|
|
190
196
|
cap,
|
|
197
|
+
afterCap,
|
|
191
198
|
};
|
|
192
199
|
if (beats(earning, earning.rate, best, best.rate))
|
|
193
200
|
best = earning;
|
package/dist/engine/explore.js
CHANGED
|
@@ -5,7 +5,7 @@ import { choicePeriod, periodKey } from "./periods.js";
|
|
|
5
5
|
import { recommend } from "./recommend.js";
|
|
6
6
|
import { rotationGap } from "./rotation.js";
|
|
7
7
|
import { membershipsUsed } from "./setup.js";
|
|
8
|
-
import { optionName } from "./
|
|
8
|
+
import { optionName } from "./rule-text.js";
|
|
9
9
|
import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, listed, money, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
10
10
|
import { cardValuation } from "./valuation.js";
|
|
11
11
|
import { EPSILON } from "./compare.js";
|
|
@@ -99,7 +99,7 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
|
|
|
99
99
|
? {
|
|
100
100
|
amount: cap.amount,
|
|
101
101
|
period: cap.period,
|
|
102
|
-
thenRate: cap.then_rate,
|
|
102
|
+
thenRate: best.afterCap ?? cap.then_rate,
|
|
103
103
|
currency: cardValuation(catalog, wallet, card).currency,
|
|
104
104
|
}
|
|
105
105
|
: null,
|
package/dist/engine/recommend.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
|
|
2
2
|
import { bonusStatus } from "./bonus.js";
|
|
3
3
|
import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
|
+
import { membershipName } from "./memberships.js";
|
|
4
5
|
import { daysBetween } from "./periods.js";
|
|
6
|
+
import { ruleWhat } from "./rule-text.js";
|
|
5
7
|
import { rotationGap } from "./rotation.js";
|
|
6
8
|
import { EPSILON, isClose } from "./compare.js";
|
|
7
9
|
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
@@ -84,7 +86,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
84
86
|
const currency = valuation.currency;
|
|
85
87
|
if (best.rule) {
|
|
86
88
|
const note = best.rule.note ? ` ${best.rule.note}` : '';
|
|
87
|
-
lines.push(`${earnText(catalog, best.nominalRate, currency)}
|
|
89
|
+
lines.push(`${earnText(catalog, best.nominalRate, currency)} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
|
|
88
90
|
}
|
|
89
91
|
else {
|
|
90
92
|
lines.push(`${earnText(catalog, card.base_rate, currency)}, its base rate. No bonus category applies here.`);
|
|
@@ -92,7 +94,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
92
94
|
const cap = best.cap;
|
|
93
95
|
if (cap && amount !== null && amount > cap.remaining) {
|
|
94
96
|
const over = amount - cap.remaining;
|
|
95
|
-
lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
|
|
97
|
+
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.`);
|
|
96
98
|
}
|
|
97
99
|
if (valuation.pooledFrom !== null) {
|
|
98
100
|
const partners = card.pools_into?.when_wallet_has ?? [];
|
|
@@ -121,17 +123,17 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
121
123
|
warnings.push(`Add this card's anniversary (MM-DD) to your wallet so its ${capLabel(best)} can be tracked.`);
|
|
122
124
|
}
|
|
123
125
|
else if (purchase.amount !== null && purchase.amount > remaining) {
|
|
124
|
-
warnings.push(`This purchase crosses the ${capLabel(best)} on
|
|
126
|
+
warnings.push(`This purchase crosses the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)}: only ${money(remaining)} is left for ${period}.`);
|
|
125
127
|
}
|
|
126
128
|
else if (remaining < cap.amount * NEAR_CAP_SHARE) {
|
|
127
|
-
warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on
|
|
129
|
+
warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)} for ${period}.`);
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
132
|
if (nominal.rule &&
|
|
131
133
|
nominal.cap &&
|
|
132
134
|
nominal !== best &&
|
|
133
135
|
nominal.cap.remaining === 0) {
|
|
134
|
-
warnings.push(`The ${capLabel(nominal)} on
|
|
136
|
+
warnings.push(`The ${capLabel(nominal)} on ${ruleWhat(catalog, nominal.rule)} is used up for ${nominal.cap.period ?? 'this period'}, so it earns ${earnText(catalog, nominal.afterCap ?? nominal.cap.cap.then_rate, valuation.currency)} until it resets.`);
|
|
135
137
|
}
|
|
136
138
|
if (best.rule?.unverified !== undefined) {
|
|
137
139
|
warnings.push(`This rate isn't confirmed against the issuer's terms yet: ${best.rule.unverified}`);
|
|
@@ -144,6 +146,10 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
144
146
|
warnings.push(`Your chosen categories are set for ${evaluation.staleChoice}, not the current period, so chosen-category bonuses don't apply until you update them.`);
|
|
145
147
|
}
|
|
146
148
|
for (const rule of evaluation.needsActivation) {
|
|
149
|
+
if (rule.activate_by !== undefined && rule.activate_by < purchase.date) {
|
|
150
|
+
warnings.push(`The bonus here (${earnText(catalog, rule.rate, valuation.currency)}) needed activating by ${shortDate(rule.activate_by)}.`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
147
153
|
warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
|
|
148
154
|
}
|
|
149
155
|
return warnings;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { findMerchant } from "../merchants/resolve.js";
|
|
2
|
+
import { categoryPhrase, choiceName, listed, words } from "./text.js";
|
|
3
|
+
// What a rule or a pick covers, in words, for answers and wallet lists.
|
|
4
|
+
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
5
|
+
// lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
|
|
6
|
+
const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
|
|
7
|
+
function merchantName(catalog, id) {
|
|
8
|
+
const merchant = findMerchant(catalog, id);
|
|
9
|
+
const name = merchant?.name ?? words(id);
|
|
10
|
+
return merchant?.kind === 'booking'
|
|
11
|
+
? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
|
|
12
|
+
: name;
|
|
13
|
+
}
|
|
14
|
+
export function optionName(catalog, card, choiceId, option) {
|
|
15
|
+
const o = card.choices.find((c) => c.id === choiceId)?.options[option];
|
|
16
|
+
if (!o)
|
|
17
|
+
return words(option);
|
|
18
|
+
const parts = [
|
|
19
|
+
...o.categories.map((c) => categoryPhrase(catalog, c)),
|
|
20
|
+
...o.merchants.map((m) => merchantName(catalog, m)),
|
|
21
|
+
];
|
|
22
|
+
if (o.online)
|
|
23
|
+
parts.push('online shopping');
|
|
24
|
+
return parts.length > 0 ? listed(parts) : words(option);
|
|
25
|
+
}
|
|
26
|
+
// What a rule covers, as a list of phrases.
|
|
27
|
+
export function ruleParts(catalog, rule) {
|
|
28
|
+
if (rule.match.any)
|
|
29
|
+
return ['everything else'];
|
|
30
|
+
if (rule.match.choice !== undefined)
|
|
31
|
+
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
32
|
+
const merchants = rule.match.merchants ?? [];
|
|
33
|
+
// A long list of one kind of store reads as "select streaming services".
|
|
34
|
+
const kinds = new Set(merchants.map((m) => findMerchant(catalog, m)?.category));
|
|
35
|
+
const [kind] = kinds;
|
|
36
|
+
const merchantParts = merchants.length > 4 && kinds.size === 1 && kind !== undefined
|
|
37
|
+
? [`select ${categoryPhrase(catalog, kind)}`]
|
|
38
|
+
: merchants.map((m) => merchantName(catalog, m));
|
|
39
|
+
const parts = [
|
|
40
|
+
...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
41
|
+
...merchantParts,
|
|
42
|
+
];
|
|
43
|
+
return parts.length > 0 ? parts : [words(rule.id)];
|
|
44
|
+
}
|
|
45
|
+
export function ruleWhat(catalog, rule) {
|
|
46
|
+
return listed(ruleParts(catalog, rule));
|
|
47
|
+
}
|
package/dist/engine/setup.js
CHANGED
|
@@ -45,6 +45,7 @@ export function activationRules(card, today) {
|
|
|
45
45
|
return card.rules
|
|
46
46
|
.filter((r) => r.requires?.activation === true &&
|
|
47
47
|
(r.valid_to === undefined || r.valid_to >= today) &&
|
|
48
|
+
(r.activate_by === undefined || r.activate_by >= today) &&
|
|
48
49
|
(r.valid_from === undefined ||
|
|
49
50
|
daysBetween(today, r.valid_from) <= ACTIVATION_LOOKAHEAD_DAYS))
|
|
50
51
|
.map((r) => r.id);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CARD_NUMBER_LABEL, labelHasCardNumber } from "../schema/card-number.js";
|
|
1
2
|
import { WALLET_VERSION, } from "../schema/wallet.js";
|
|
2
3
|
import { addMonths } from "./bonus.js";
|
|
3
4
|
import { capByKey } from "./evaluate.js";
|
|
@@ -33,9 +34,8 @@ export function cardFields(card, held, input, today) {
|
|
|
33
34
|
else if (input.checkoutLabel !== undefined) {
|
|
34
35
|
if (input.checkoutLabel.trim() === '')
|
|
35
36
|
problems.push('The checkout label is empty.');
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
problems.push('The checkout label looks like it has a card number in it. earnrate never stores card numbers; use a name like "Amex Gold".');
|
|
37
|
+
if (labelHasCardNumber(input.checkoutLabel))
|
|
38
|
+
problems.push(CARD_NUMBER_LABEL);
|
|
39
39
|
fields.checkout_label = input.checkoutLabel.trim();
|
|
40
40
|
}
|
|
41
41
|
if (input.anniversary !== undefined) {
|
|
@@ -1,66 +1,19 @@
|
|
|
1
|
-
import { findMerchant } from "../merchants/resolve.js";
|
|
2
1
|
import { bonusStatus } from "./bonus.js";
|
|
3
2
|
import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
|
|
4
3
|
import { membershipName } from "./memberships.js";
|
|
4
|
+
import { optionName, ruleParts, ruleWhat } from "./rule-text.js";
|
|
5
5
|
import { daysBetween, inEffect, periodEnd } from "./periods.js";
|
|
6
6
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
7
7
|
import { rotationGap } from "./rotation.js";
|
|
8
8
|
import { activationRules } from "./setup.js";
|
|
9
|
-
import { CAP_PERIODS,
|
|
9
|
+
import { CAP_PERIODS, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
|
|
10
10
|
import { cardValuation, currencyValuation } from "./valuation.js";
|
|
11
|
-
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
12
|
-
// lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
|
|
13
|
-
const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
|
|
14
|
-
function merchantName(catalog, id) {
|
|
15
|
-
const merchant = findMerchant(catalog, id);
|
|
16
|
-
const name = merchant?.name ?? words(id);
|
|
17
|
-
return merchant?.kind === 'booking'
|
|
18
|
-
? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
|
|
19
|
-
: name;
|
|
20
|
-
}
|
|
21
|
-
export function optionName(catalog, card, choiceId, option) {
|
|
22
|
-
const o = card.choices.find((c) => c.id === choiceId)?.options[option];
|
|
23
|
-
if (!o)
|
|
24
|
-
return words(option);
|
|
25
|
-
const parts = [
|
|
26
|
-
...o.categories.map((c) => categoryPhrase(catalog, c)),
|
|
27
|
-
...o.merchants.map((m) => merchantName(catalog, m)),
|
|
28
|
-
];
|
|
29
|
-
if (o.online)
|
|
30
|
-
parts.push('online shopping');
|
|
31
|
-
return parts.length > 0 ? listed(parts) : words(option);
|
|
32
|
-
}
|
|
33
|
-
// What a rule covers, as a list of phrases.
|
|
34
|
-
function ruleParts(catalog, rule) {
|
|
35
|
-
if (rule.match.choice !== undefined)
|
|
36
|
-
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
37
|
-
const merchants = rule.match.merchants ?? [];
|
|
38
|
-
// A long list of one kind of store reads as "select streaming services".
|
|
39
|
-
const kinds = new Set(merchants.map((m) => findMerchant(catalog, m)?.category));
|
|
40
|
-
const [kind] = kinds;
|
|
41
|
-
const merchantParts = merchants.length > 4 && kinds.size === 1 && kind !== undefined
|
|
42
|
-
? [`select ${categoryPhrase(catalog, kind)}`]
|
|
43
|
-
: merchants.map((m) => merchantName(catalog, m));
|
|
44
|
-
const parts = [
|
|
45
|
-
...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
46
|
-
...merchantParts,
|
|
47
|
-
];
|
|
48
|
-
return parts.length > 0 ? parts : [words(rule.id)];
|
|
49
|
-
}
|
|
50
|
-
function ruleWhat(catalog, rule) {
|
|
51
|
-
return listed(ruleParts(catalog, rule));
|
|
52
|
-
}
|
|
53
11
|
const matchKeys = (rule) => rule.match.choice !== undefined
|
|
54
12
|
? []
|
|
55
13
|
: [
|
|
56
14
|
...(rule.match.categories ?? []).map((c) => `category:${c}`),
|
|
57
15
|
...(rule.match.merchants ?? []).map((m) => `merchant:${m}`),
|
|
58
16
|
];
|
|
59
|
-
// Every bonus rate the cardholder gets now, best first, so none reads as the
|
|
60
|
-
// base rate. Rates behind a membership the wallet lacks are left out, and so
|
|
61
|
-
// is a lower tier that an uncapped, always-on higher rate fully covers (Prime
|
|
62
|
-
// Visa's 3% without Prime, once Prime is held). Rules at the same rate share
|
|
63
|
-
// a line.
|
|
64
17
|
// "first $1,500 a quarter, combined": how much of a rate's spending earns it.
|
|
65
18
|
function capLimit(card, shown, rule) {
|
|
66
19
|
if (rule.cap === undefined)
|
|
@@ -74,6 +27,11 @@ function capLimit(card, shown, rule) {
|
|
|
74
27
|
shown.filter((r) => r.cap === rule.cap).length > 1;
|
|
75
28
|
return `first ${wholeDollars(cap.amount)} ${CAP_PERIODS[cap.period]}${shared ? ', combined' : ''}`;
|
|
76
29
|
}
|
|
30
|
+
// Every bonus rate the cardholder gets now, best first, so none reads as the
|
|
31
|
+
// base rate. Rates behind a membership the wallet lacks are left out, and so
|
|
32
|
+
// is a lower tier that an uncapped, always-on higher rate fully covers (Prime
|
|
33
|
+
// Visa's 3% without Prime, once Prime is held). Rules at the same rate share
|
|
34
|
+
// a line.
|
|
77
35
|
function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
78
36
|
const shown = card.rules
|
|
79
37
|
.filter((r) => inEffect(r, today) &&
|
|
@@ -91,6 +49,16 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
91
49
|
.flatMap(matchKeys);
|
|
92
50
|
if (keys.length > 0 && keys.every((k) => higher.includes(k)))
|
|
93
51
|
continue;
|
|
52
|
+
// A membership tier's higher rate on the same purchases replaces the
|
|
53
|
+
// card's own (BofA Rewards tiers), cap or not.
|
|
54
|
+
const replaced = shown.some((h) => h !== r &&
|
|
55
|
+
h.rate > r.rate &&
|
|
56
|
+
h.requires?.membership !== undefined &&
|
|
57
|
+
r.requires?.membership === undefined &&
|
|
58
|
+
JSON.stringify(h.match) === JSON.stringify(r.match) &&
|
|
59
|
+
JSON.stringify(h.exclude) === JSON.stringify(r.exclude));
|
|
60
|
+
if (replaced)
|
|
61
|
+
continue;
|
|
94
62
|
const pending = r.requires?.activation && !isActivated(card, held, r.id);
|
|
95
63
|
const key = [
|
|
96
64
|
`${earnText(catalog, r.rate, currency)} on`,
|
|
@@ -109,10 +77,14 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
109
77
|
const after = `${limit}${activate}`;
|
|
110
78
|
return `${head} ${listed(whats)}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
|
|
111
79
|
}),
|
|
112
|
-
|
|
80
|
+
...(shown.some((r) => r.match.any && r.rate > card.base_rate)
|
|
81
|
+
? []
|
|
82
|
+
: [`${earnText(catalog, card.base_rate, currency)} on everything else`]),
|
|
113
83
|
];
|
|
114
84
|
}
|
|
115
|
-
|
|
85
|
+
// Bonuses current or starting soon that the card hasn't activated, one per
|
|
86
|
+
// activation group (one activation covers a quarter's categories).
|
|
87
|
+
export function activationsDue(catalog, card, held, today) {
|
|
116
88
|
const seen = new Set();
|
|
117
89
|
const out = [];
|
|
118
90
|
for (const id of activationRules(card, today)) {
|
|
@@ -123,18 +95,25 @@ function toActivate(catalog, card, held, today) {
|
|
|
123
95
|
seen.add(g);
|
|
124
96
|
const rules = card.rules.filter((r) => group.includes(r.id));
|
|
125
97
|
const first = rules[0];
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
? ''
|
|
133
|
-
: ` (${deadline.slice(2)})`;
|
|
134
|
-
out.push(`${rules.map((r) => ruleWhat(catalog, r)).join(', ')}${when}`);
|
|
98
|
+
out.push({
|
|
99
|
+
what: rules.map((r) => ruleWhat(catalog, r)).join(', '),
|
|
100
|
+
from: first?.valid_from ?? null,
|
|
101
|
+
to: first?.valid_to ?? null,
|
|
102
|
+
activateBy: first?.activate_by ?? null,
|
|
103
|
+
});
|
|
135
104
|
}
|
|
136
105
|
return out;
|
|
137
106
|
}
|
|
107
|
+
function toActivate(catalog, card, held, today) {
|
|
108
|
+
return activationsDue(catalog, card, held, today).map((a) => {
|
|
109
|
+
const deadline = a.activateBy === null ? '' : `activate by ${shortDate(a.activateBy)}`;
|
|
110
|
+
const dates = a.from !== null && a.to !== null
|
|
111
|
+
? `${shortDate(a.from)} to ${shortDate(a.to)}`
|
|
112
|
+
: '';
|
|
113
|
+
const when = [dates, deadline].filter((x) => x !== '').join(', ');
|
|
114
|
+
return `${a.what}${when === '' ? '' : ` (${when})`}`;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
138
117
|
function capSummaries(catalog, card, held, today) {
|
|
139
118
|
const caps = new Map();
|
|
140
119
|
for (const shared of card.caps)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Document, parse, visit } from 'yaml';
|
|
2
2
|
import { walletProblems } from "../data/integrity.js";
|
|
3
|
+
import { CARD_NUMBER_LABEL, CARD_NUMBER_REDEMPTION, } from "../schema/card-number.js";
|
|
3
4
|
import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
|
|
4
5
|
// A wallet as the YAML file the CLI reads, for moving it between the
|
|
5
6
|
// website, the remote server and the command line. Node-free.
|
|
@@ -54,7 +55,10 @@ export function parseWalletYaml(text, catalog) {
|
|
|
54
55
|
}
|
|
55
56
|
const result = WalletSchema.safeParse(raw);
|
|
56
57
|
if (!result.success)
|
|
57
|
-
return
|
|
58
|
+
return (result.error.issues
|
|
59
|
+
.map((i) => i.message)
|
|
60
|
+
.find((m) => m === CARD_NUMBER_LABEL || m === CARD_NUMBER_REDEMPTION) ??
|
|
61
|
+
"That isn't an earnrate wallet.");
|
|
58
62
|
const problems = walletProblems(result.data, catalog);
|
|
59
63
|
return problems.length > 0 ? problems.join(' ') : result.data;
|
|
60
64
|
}
|
package/dist/format/wallet.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { money, NETWORK_NAMES, shortDate } from "../engine/text.js";
|
|
2
|
+
// "To do now", above the card details; nothing when all's done.
|
|
3
|
+
export function formatChecklist(items) {
|
|
4
|
+
return items.length === 0
|
|
5
|
+
? ''
|
|
6
|
+
: `To do now:\n${items.map((i) => `- ${i.text}`).join('\n')}\n\n`;
|
|
7
|
+
}
|
|
2
8
|
export function formatWalletSummary(summary) {
|
|
3
9
|
const cards = summary.cards.map((c) => {
|
|
4
10
|
const label = c.checkoutLabel
|
package/dist/mcp/context.js
CHANGED
|
@@ -61,10 +61,14 @@ export function makeContext(deps) {
|
|
|
61
61
|
// neither ever overrides a merchant earnrate already knows.
|
|
62
62
|
async function resolve(merchant, channel, category) {
|
|
63
63
|
const known = resolvePlace(catalog, merchant, channel);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
const resolved = known.source !== 'none' || category !== null || !deps.llm
|
|
65
|
+
? { merchant: withCallerCategory(known, category), notices: [] }
|
|
66
|
+
: await deps.llm.resolve(catalog, merchant, channel);
|
|
67
|
+
const source = resolved.merchant.source;
|
|
68
|
+
if (deps.missed &&
|
|
69
|
+
(source === 'none' || source === 'caller' || source === 'llm'))
|
|
70
|
+
await deps.missed.note(merchant, source === 'none' ? null : resolved.merchant.category, deps.today());
|
|
71
|
+
return resolved;
|
|
68
72
|
}
|
|
69
73
|
// Cards named in the conversation come first, then the saved wallet; with
|
|
70
74
|
// neither (null), answers cover every card earnrate knows.
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -86,6 +86,14 @@ export const WalletSummarySchema = z.object({
|
|
|
86
86
|
redemption: z.string().nullable(),
|
|
87
87
|
})),
|
|
88
88
|
started: z.string().nullable(),
|
|
89
|
+
checklist: z
|
|
90
|
+
.array(z.object({
|
|
91
|
+
kind: z.enum(['activate', 'pick', 'cap', 'bonus', 'rotation']),
|
|
92
|
+
cardId: z.string(),
|
|
93
|
+
text: z.string(),
|
|
94
|
+
due: z.string().nullable(),
|
|
95
|
+
}))
|
|
96
|
+
.optional(),
|
|
89
97
|
cards: z.array(z.object({
|
|
90
98
|
cardId: z.string(),
|
|
91
99
|
cardName: z.string(),
|
package/dist/mcp/serve.js
CHANGED
|
@@ -3,12 +3,15 @@ import { FileWalletStore } from "../data/file-store.js";
|
|
|
3
3
|
import { loadCatalog, walletPath } from "../data/load.js";
|
|
4
4
|
import { localToday as today } from "../engine/periods.js";
|
|
5
5
|
import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
|
|
6
|
+
import { missedPath, missLogAt } from "../merchants/missed.js";
|
|
6
7
|
import { packageVersion } from "../version.js";
|
|
7
8
|
import { createEarnrateServer } from "./server.js";
|
|
8
9
|
// stdout carries the protocol, so diagnostics go to stderr only.
|
|
9
10
|
export async function serveMcp(env) {
|
|
10
11
|
const version = await packageVersion();
|
|
11
12
|
const catalog = await loadCatalog();
|
|
13
|
+
const missedFile = missedPath(env);
|
|
14
|
+
const missed = missedFile === null ? null : missLogAt(missedFile);
|
|
12
15
|
let llm = null;
|
|
13
16
|
if (llmEnabled(env)) {
|
|
14
17
|
const opened = await openLlmSession({ env, today });
|
|
@@ -26,6 +29,7 @@ export async function serveMcp(env) {
|
|
|
26
29
|
savedWallets: 'file',
|
|
27
30
|
today,
|
|
28
31
|
llm,
|
|
32
|
+
missed,
|
|
29
33
|
version,
|
|
30
34
|
}));
|
|
31
35
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -39,7 +39,7 @@ const WHICH_WALLET = {
|
|
|
39
39
|
};
|
|
40
40
|
const instructions = (savedWallets) => `earnrate answers which credit card earns the most for a purchase, from each issuer's own terms. Anyone can use it without entering anything: with no cards given, best_card_for_purchase, best_cards_for, cheat_sheet, simulate_spending and compare_welcome_offers answer from every card earnrate knows, and that's a complete answer. Never require the user's cards, and don't ask what they spend unless the answer depends on it; simulate_spending assumes typical amounts when none are given and says so.
|
|
41
41
|
|
|
42
|
-
Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. After answering, offer once to save those cards so earnrate remembers them, and save them with add_card only if the user says yes. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing.
|
|
42
|
+
Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. After answering, offer once to save those cards so earnrate remembers them, and save them with add_card only if the user says yes. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing. When the user asks what to do with their cards (this quarter, or what they're forgetting), call list_wallet: it starts with a to-do list.
|
|
43
43
|
|
|
44
44
|
The wallet lists cards by product id, never card numbers; never ask for card numbers, expiry dates or security codes.
|
|
45
45
|
|
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -3,8 +3,11 @@ import { membershipsFor, needsAnniversary } from "../../engine/setup.js";
|
|
|
3
3
|
import { choiceName, currencyLabel, listed, money, shortDate, words, } from "../../engine/text.js";
|
|
4
4
|
import { membershipName } from "../../engine/memberships.js";
|
|
5
5
|
import { profileText, VALUATION_PROFILES } from "../../engine/profiles.js";
|
|
6
|
-
import { optionName
|
|
7
|
-
import {
|
|
6
|
+
import { optionName } from "../../engine/rule-text.js";
|
|
7
|
+
import { walletSummary } from "../../engine/wallet-summary.js";
|
|
8
|
+
import { checklist } from "../../engine/checklist.js";
|
|
9
|
+
import { cardText } from "../../format/card-text.js";
|
|
10
|
+
import { formatBooking, formatChecklist, formatWalletSummary, } from "../../format/wallet.js";
|
|
8
11
|
import { fail } from "../context.js";
|
|
9
12
|
import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
|
|
10
13
|
import { RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "../schemas.js";
|
|
@@ -52,7 +55,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
52
55
|
const { catalog, store } = deps;
|
|
53
56
|
server.registerTool('list_wallet', {
|
|
54
57
|
title: 'List wallet',
|
|
55
|
-
description: `
|
|
58
|
+
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); 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, 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}`,
|
|
56
59
|
outputSchema: WalletSummarySchema,
|
|
57
60
|
annotations: {
|
|
58
61
|
title: 'List wallet',
|
|
@@ -64,9 +67,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
64
67
|
if (typeof read === 'string')
|
|
65
68
|
return fail(read);
|
|
66
69
|
const started = deps.savedWallets === 'account' ? await deps.store.started() : null;
|
|
70
|
+
const base = walletSummary(catalog, read.wallet, deps.today());
|
|
67
71
|
const summary = {
|
|
68
|
-
...
|
|
72
|
+
...base,
|
|
69
73
|
started,
|
|
74
|
+
checklist: checklist(catalog, read.wallet, deps.today(), base, cardText(catalog).shortName),
|
|
70
75
|
};
|
|
71
76
|
const when = started === null
|
|
72
77
|
? `This is ${ctx.walletName}.\n`
|
|
@@ -75,7 +80,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
75
80
|
content: [
|
|
76
81
|
{
|
|
77
82
|
type: 'text',
|
|
78
|
-
text: `${when}${read.start === null ? '' : `${read.start}\n`}${formatWalletSummary(summary)}`,
|
|
83
|
+
text: `${when}${read.start === null ? '' : `${read.start}\n`}${formatChecklist(summary.checklist)}${formatWalletSummary(summary)}`,
|
|
79
84
|
},
|
|
80
85
|
],
|
|
81
86
|
structuredContent: summary,
|
package/dist/merchants/cache.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { writeFileAtomic } from "../data/atomic-write.js";
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { z } from 'zod';
|
|
@@ -71,10 +72,7 @@ export async function openMerchantCache(file) {
|
|
|
71
72
|
merchants = { ...merchants, [key]: toEntry(value) };
|
|
72
73
|
if (!writable)
|
|
73
74
|
return;
|
|
74
|
-
await
|
|
75
|
-
const tmp = `${file}.${String(process.pid)}.tmp`;
|
|
76
|
-
await writeFile(tmp, `${JSON.stringify({ version: 1, merchants }, null, 2)}\n`);
|
|
77
|
-
await rename(tmp, file);
|
|
75
|
+
await writeFileAtomic(file, `${JSON.stringify({ version: 1, merchants }, null, 2)}\n`);
|
|
78
76
|
},
|
|
79
77
|
},
|
|
80
78
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LLM_MAX_CALLS_ENV_VAR } from "../brand.js";
|
|
2
|
+
import { hasCardNumber } from "../schema/card-number.js";
|
|
2
3
|
import { describeClassifierError } from "./anthropic.js";
|
|
3
4
|
import { normalizeMerchantName, resolveMerchant } from "./resolve.js";
|
|
4
5
|
export const DEFAULT_MAX_CALLS = 20;
|
|
@@ -42,6 +43,13 @@ export function createLlmSession(options) {
|
|
|
42
43
|
const known = resolveMerchant(catalog, input, channel);
|
|
43
44
|
if (known.source !== 'none')
|
|
44
45
|
return { merchant: known, notices: [] };
|
|
46
|
+
if (hasCardNumber(input))
|
|
47
|
+
return {
|
|
48
|
+
merchant: known,
|
|
49
|
+
notices: [
|
|
50
|
+
'Skipped the Claude lookup: that looks like a card number, and earnrate never sends one anywhere.',
|
|
51
|
+
],
|
|
52
|
+
};
|
|
45
53
|
const key = normalizeMerchantName(input);
|
|
46
54
|
const saved = options.cache.get(key);
|
|
47
55
|
if (saved && usable(catalog, saved)) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { writeFileAtomic } from "../data/atomic-write.js";
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { CONFIG_DIR, MISSED_ENV_VAR } from "../brand.js";
|
|
7
|
+
import { hasCardNumber } from "../schema/card-number.js";
|
|
8
|
+
import { normalizeMerchantName } from "./resolve.js";
|
|
9
|
+
const MissedFileSchema = z.object({
|
|
10
|
+
version: z.literal(1),
|
|
11
|
+
merchants: z.record(z.string(), z.object({
|
|
12
|
+
name: z.string(),
|
|
13
|
+
count: z.number().int().positive(),
|
|
14
|
+
last_seen: z.string(),
|
|
15
|
+
categories: z.array(z.string()).default([]),
|
|
16
|
+
})),
|
|
17
|
+
});
|
|
18
|
+
const MAX = 500;
|
|
19
|
+
// $EARNRATE_MISSED_MERCHANTS names another file, or "off" keeps no log.
|
|
20
|
+
export function missedPath(env = process.env) {
|
|
21
|
+
const given = env[MISSED_ENV_VAR]?.trim();
|
|
22
|
+
if (given === 'off')
|
|
23
|
+
return null;
|
|
24
|
+
return given || path.join(homedir(), CONFIG_DIR, 'missed-merchants.json');
|
|
25
|
+
}
|
|
26
|
+
export async function readMissed(file) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = MissedFileSchema.safeParse(JSON.parse(await readFile(file, 'utf8')));
|
|
29
|
+
return parsed.success ? Object.values(parsed.data.merchants) : [];
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Writes are queued, so concurrent tool calls never lose a count. A file that
|
|
36
|
+
// can't be read as a log is left alone.
|
|
37
|
+
export function missLogAt(file) {
|
|
38
|
+
let queue = Promise.resolve();
|
|
39
|
+
return {
|
|
40
|
+
note(name, category, today) {
|
|
41
|
+
const key = normalizeMerchantName(name);
|
|
42
|
+
if (key === '' || hasCardNumber(name))
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
const run = queue.then(async () => {
|
|
45
|
+
let merchants = {};
|
|
46
|
+
try {
|
|
47
|
+
const parsed = MissedFileSchema.safeParse(JSON.parse(await readFile(file, 'utf8')));
|
|
48
|
+
if (!parsed.success)
|
|
49
|
+
return;
|
|
50
|
+
merchants = parsed.data.merchants;
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (!(e instanceof Error && 'code' in e && e.code === 'ENOENT'))
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const seen = merchants[key];
|
|
57
|
+
merchants[key] = {
|
|
58
|
+
name: name.trim(),
|
|
59
|
+
count: (seen?.count ?? 0) + 1,
|
|
60
|
+
last_seen: today,
|
|
61
|
+
categories: [
|
|
62
|
+
...new Set([
|
|
63
|
+
...(seen?.categories ?? []),
|
|
64
|
+
...(category === null ? [] : [category]),
|
|
65
|
+
]),
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
const kept = Object.entries(merchants)
|
|
69
|
+
.sort(([, a], [, b]) => b.last_seen.localeCompare(a.last_seen))
|
|
70
|
+
.slice(0, MAX);
|
|
71
|
+
await writeFileAtomic(file, `${JSON.stringify({ version: 1, merchants: Object.fromEntries(kept) }, null, 2)}\n`);
|
|
72
|
+
});
|
|
73
|
+
queue = run.catch(() => undefined);
|
|
74
|
+
return run.catch(() => undefined);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|