earnrate 0.1.4 → 0.2.0
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 +40 -7
- package/data/CHANGELOG.md +8 -0
- package/data/VERSION +1 -1
- package/data/categories.yaml +22 -7
- package/dist/cli/args.js +9 -0
- package/dist/cli/cheatsheet.js +32 -28
- package/dist/cli/explore.js +23 -140
- package/dist/cli/format.js +7 -5
- package/dist/cli/offers.js +10 -53
- package/dist/cli/run.js +80 -14
- package/dist/cli/wallet.js +40 -0
- package/dist/data/file-store.js +56 -0
- package/dist/data/load.js +9 -1
- package/dist/data/store.js +108 -0
- package/dist/data/wallet-edit.js +25 -2
- package/dist/engine/cheat-sheet.js +57 -2
- package/dist/engine/explore.js +7 -6
- package/dist/engine/recommend.js +3 -0
- package/dist/engine/record.js +35 -0
- package/dist/engine/simulate.js +22 -1
- package/dist/engine/transient.js +21 -0
- package/dist/engine/wallet-change.js +28 -1
- package/dist/format/cheat-sheet.js +19 -0
- package/dist/format/explore.js +124 -0
- package/dist/format/offers.js +42 -0
- package/dist/format/wallet-yaml.js +45 -0
- package/dist/mcp/schemas.js +7 -1
- package/dist/mcp/serve.js +3 -1
- package/dist/mcp/server.js +304 -117
- package/dist/schema/catalog.js +1 -0
- package/package.json +7 -2
package/dist/engine/record.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
|
+
import { bonusStatus, countsTowardBonus } from "./bonus.js";
|
|
2
3
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
4
|
const roundCents = (n) => Math.round(n * 100) / 100;
|
|
4
5
|
// Books the spend against the cap of the highest-rate rule that matches, even
|
|
@@ -53,3 +54,37 @@ export function recordPurchase(catalog, wallet, purchase) {
|
|
|
53
54
|
const booking = capBooking(catalog, wallet, purchase);
|
|
54
55
|
return booking ? applyBooking(wallet, booking) : wallet;
|
|
55
56
|
}
|
|
57
|
+
// The purchase's progress toward the card's open welcome bonus, if it counts.
|
|
58
|
+
export function bonusBooking(catalog, wallet, purchase) {
|
|
59
|
+
const held = wallet.cards.find((c) => c.id === purchase.cardId);
|
|
60
|
+
const status = held ? bonusStatus(catalog, wallet, held, purchase.date) : null;
|
|
61
|
+
if (!status ||
|
|
62
|
+
status.spend === null ||
|
|
63
|
+
(status.remaining ?? 0) <= 0 ||
|
|
64
|
+
!countsTowardBonus(status, purchase.date))
|
|
65
|
+
return null;
|
|
66
|
+
return {
|
|
67
|
+
cardId: purchase.cardId,
|
|
68
|
+
before: status.spent,
|
|
69
|
+
after: roundCents(status.spent + purchase.amount),
|
|
70
|
+
spend: status.spend,
|
|
71
|
+
deadline: status.deadline,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export function applyBonusBooking(wallet, b) {
|
|
75
|
+
return {
|
|
76
|
+
...wallet,
|
|
77
|
+
cards: wallet.cards.map((c) => c.id === b.cardId && c.bonus
|
|
78
|
+
? { ...c, bonus: { ...c.bonus, spent: b.after } }
|
|
79
|
+
: c),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// Everything recording a purchase changes: its cap and its welcome bonus.
|
|
83
|
+
export function bookPurchase(catalog, wallet, purchase, merchant) {
|
|
84
|
+
const cap = capBooking(catalog, wallet, purchase, merchant);
|
|
85
|
+
const bonus = bonusBooking(catalog, wallet, purchase);
|
|
86
|
+
let next = cap ? applyBooking(wallet, cap) : wallet;
|
|
87
|
+
if (bonus)
|
|
88
|
+
next = applyBonusBooking(next, bonus);
|
|
89
|
+
return { wallet: next, cap, bonus };
|
|
90
|
+
}
|
package/dist/engine/simulate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
2
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
|
-
import { categoryResolution, choicePeriod, idealHolding, matchesFilters, } from "./explore.js";
|
|
3
|
+
import { categoryResolution, choicePeriod, findCategory, idealHolding, matchesFilters, } from "./explore.js";
|
|
4
4
|
import { periodKey } from "./periods.js";
|
|
5
5
|
import { applyBooking, capBooking } from "./record.js";
|
|
6
6
|
import { rotationGap } from "./rotation.js";
|
|
@@ -307,3 +307,24 @@ export function simulateAdditions(catalog, wallet, lines, options) {
|
|
|
307
307
|
additions.sort((a, b) => b.gain - a.gain || a.annualFee - b.annualFee);
|
|
308
308
|
return { current, additions };
|
|
309
309
|
}
|
|
310
|
+
// Rough monthly amounts for a U.S. household: the starting point when someone
|
|
311
|
+
// hasn't said what they spend. Answers built on them say so.
|
|
312
|
+
export const TYPICAL_MONTHLY_SPEND = [
|
|
313
|
+
{ what: 'Groceries', monthly: 500 },
|
|
314
|
+
{ what: 'Dining', monthly: 250 },
|
|
315
|
+
{ what: 'Gas', monthly: 150 },
|
|
316
|
+
{ what: 'Amazon', monthly: 100 },
|
|
317
|
+
{ what: 'Streaming', monthly: 40 },
|
|
318
|
+
{ what: 'Travel', monthly: 150 },
|
|
319
|
+
{ what: 'Everything else', monthly: 800 },
|
|
320
|
+
];
|
|
321
|
+
// A spending line from everyday words: a category when one matches
|
|
322
|
+
// ("groceries"), otherwise a merchant ("Amazon").
|
|
323
|
+
export function spendLineFor(catalog, what, monthly) {
|
|
324
|
+
const category = findCategory(catalog, what);
|
|
325
|
+
return { merchant: category === null ? what : null, category, monthly };
|
|
326
|
+
}
|
|
327
|
+
export function typicalSpendLines(catalog) {
|
|
328
|
+
return resolveSpendLines(catalog, TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly)));
|
|
329
|
+
}
|
|
330
|
+
export const TYPICAL_SPEND_NOTE = `No spending was given, so this uses rough typical monthly amounts for a U.S. household: ${TYPICAL_MONTHLY_SPEND.map((t) => `${t.what} $${String(t.monthly)}`).join(', ')}. Give your own amounts for a closer answer.`;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { addToWallet, withMemberships, } from "./wallet-change.js";
|
|
2
|
+
// A wallet for one answer, from cards the user named in the conversation.
|
|
3
|
+
// Nothing is saved; the same rules as adding cards to a saved wallet apply.
|
|
4
|
+
export function walletFromCards(catalog, named, today) {
|
|
5
|
+
let wallet = null;
|
|
6
|
+
for (const input of named.cards) {
|
|
7
|
+
const change = addToWallet(catalog, wallet, input, today);
|
|
8
|
+
if (!change.ok)
|
|
9
|
+
return change.error;
|
|
10
|
+
wallet = change.wallet;
|
|
11
|
+
}
|
|
12
|
+
if (!wallet)
|
|
13
|
+
return 'Name at least one card.';
|
|
14
|
+
if (named.memberships && named.memberships.length > 0) {
|
|
15
|
+
const change = withMemberships(catalog, wallet, named.memberships);
|
|
16
|
+
if (!change.ok)
|
|
17
|
+
return change.error;
|
|
18
|
+
wallet = change.wallet;
|
|
19
|
+
}
|
|
20
|
+
return wallet;
|
|
21
|
+
}
|
|
@@ -89,6 +89,31 @@ export function cardFields(card, held, input, today) {
|
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
+
if (input.bonus === null)
|
|
93
|
+
fields.bonus = null;
|
|
94
|
+
else if (input.bonus !== undefined) {
|
|
95
|
+
const b = input.bonus;
|
|
96
|
+
const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d) &&
|
|
97
|
+
new Date(`${d}T00:00:00Z`).toISOString().startsWith(d);
|
|
98
|
+
if (!isDate(b.opened))
|
|
99
|
+
problems.push(`"${b.opened}" isn't a date. Use YYYY-MM-DD.`);
|
|
100
|
+
else if (b.opened > today)
|
|
101
|
+
problems.push("The account's opening date is in the future.");
|
|
102
|
+
if (b.spent !== undefined && !(b.spent >= 0))
|
|
103
|
+
problems.push('Spending toward the bonus must be $0 or more.');
|
|
104
|
+
if (b.spend !== undefined && !(b.spend > 0))
|
|
105
|
+
problems.push('The spending needed must be more than $0.');
|
|
106
|
+
if (b.by !== undefined && (!isDate(b.by) || b.by < b.opened))
|
|
107
|
+
problems.push(`"${b.by}" isn't a deadline after the opening date.`);
|
|
108
|
+
if (!card.welcome_offer && (b.spend === undefined || b.by === undefined))
|
|
109
|
+
problems.push(`earnrate has no published welcome offer for ${card.id}, so give the spending needed and the deadline.`);
|
|
110
|
+
fields.bonus = {
|
|
111
|
+
opened: b.opened,
|
|
112
|
+
spent: b.spent ?? held?.bonus?.spent ?? 0,
|
|
113
|
+
...(b.spend === undefined ? {} : { spend: b.spend }),
|
|
114
|
+
...(b.by === undefined ? {} : { by: b.by }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
92
117
|
return problems.length > 0 ? problems.join('\n') : fields;
|
|
93
118
|
}
|
|
94
119
|
export const newHolding = (id) => ({
|
|
@@ -96,14 +121,16 @@ export const newHolding = (id) => ({
|
|
|
96
121
|
cap_usage: {},
|
|
97
122
|
activated: [],
|
|
98
123
|
});
|
|
99
|
-
export const withFields = (held, f) => ({
|
|
124
|
+
export const withFields = ({ bonus, ...held }, f) => ({
|
|
100
125
|
...held,
|
|
126
|
+
...(bonus && f.bonus === undefined ? { bonus } : {}),
|
|
101
127
|
...(f.checkout_label === undefined
|
|
102
128
|
? {}
|
|
103
129
|
: { checkout_label: f.checkout_label }),
|
|
104
130
|
...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
|
|
105
131
|
...(f.chosen === undefined ? {} : { chosen: f.chosen }),
|
|
106
132
|
...(f.activated === undefined ? {} : { activated: f.activated }),
|
|
133
|
+
...(f.bonus ? { bonus: f.bonus } : {}),
|
|
107
134
|
});
|
|
108
135
|
// `wallet` is null when there isn't one yet; adding a card starts it.
|
|
109
136
|
export function addToWallet(catalog, wallet, input, today) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const pct = (n) => `${String(Number(n.toFixed(2)))}%`;
|
|
2
|
+
export function formatCheatSheet(sheet) {
|
|
3
|
+
const labelWidth = Math.max(...sheet.rows.map((r) => r.label.length));
|
|
4
|
+
const cardWidth = Math.max(...sheet.rows.map((r) => (r.cardName ?? 'none of your cards').length));
|
|
5
|
+
const lines = [
|
|
6
|
+
sheet.scope === 'wallet'
|
|
7
|
+
? `Your cheat sheet for ${sheet.quarter}`
|
|
8
|
+
: `The best card for each kind of purchase, ${sheet.quarter}`,
|
|
9
|
+
'',
|
|
10
|
+
];
|
|
11
|
+
for (const row of sheet.rows) {
|
|
12
|
+
lines.push(` ${row.label.padEnd(labelWidth)} ${(row.cardName ?? 'none of your cards').padEnd(cardWidth)} ${row.effectiveReturnPct === null ? '' : pct(row.effectiveReturnPct).padStart(5)}`.trimEnd());
|
|
13
|
+
if (row.note)
|
|
14
|
+
lines.push(` ${' '.repeat(labelWidth)} ${sheet.scope === 'wallet' ? '! ' : ''}${row.note}`);
|
|
15
|
+
}
|
|
16
|
+
if (sheet.notes.length > 0)
|
|
17
|
+
lines.push('', ...sheet.notes);
|
|
18
|
+
return `${lines.join('\n')}\n`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
2
|
+
const wholeUsd = (n) => usd(Math.round(n));
|
|
3
|
+
const pct = (n) => `${String(Number(n.toFixed(2)))}%`;
|
|
4
|
+
function feeText(e) {
|
|
5
|
+
if (e.annualFee === null)
|
|
6
|
+
return 'annual fee not on file';
|
|
7
|
+
if (e.annualFee === 0)
|
|
8
|
+
return 'no annual fee';
|
|
9
|
+
if (e.firstYearFee !== null && e.firstYearFee !== e.annualFee)
|
|
10
|
+
return `${usd(e.annualFee)} annual fee (${e.firstYearFee === 0 ? 'waived' : usd(e.firstYearFee)} the first year)`;
|
|
11
|
+
return `${usd(e.annualFee)} annual fee`;
|
|
12
|
+
}
|
|
13
|
+
const TOP_BEST = 10;
|
|
14
|
+
export function formatExploration(r, all) {
|
|
15
|
+
const m = r.merchant;
|
|
16
|
+
const title = m.source === 'category'
|
|
17
|
+
? m.resolvedName.toLowerCase()
|
|
18
|
+
: m.source === 'none'
|
|
19
|
+
? `"${m.input}" (not recognized)`
|
|
20
|
+
: m.resolvedName;
|
|
21
|
+
if (r.ranked.length === 0)
|
|
22
|
+
return `No cards match at ${title}.\n`;
|
|
23
|
+
const shown = all ? r.ranked : r.ranked.slice(0, TOP_BEST);
|
|
24
|
+
const width = Math.max(...shown.map((e) => e.cardName.length));
|
|
25
|
+
const lines = [`Best cards for ${title}:`, ''];
|
|
26
|
+
shown.forEach((e, i) => {
|
|
27
|
+
const earned = e.earned === null ? '' : ` ${usd(e.earned)}`;
|
|
28
|
+
lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
|
|
29
|
+
for (const c of e.conditions)
|
|
30
|
+
lines.push(` ${c}`);
|
|
31
|
+
const upgrade = e.withMembership[0];
|
|
32
|
+
if (upgrade)
|
|
33
|
+
lines.push(` With ${upgrade.membership}: ${pct(upgrade.effectiveReturnPct)}.`);
|
|
34
|
+
for (const w of e.warnings)
|
|
35
|
+
lines.push(` ! ${w}`);
|
|
36
|
+
});
|
|
37
|
+
if (r.ranked.length > shown.length)
|
|
38
|
+
lines.push(` + ${String(r.ranked.length - shown.length)} more (--all lists them)`);
|
|
39
|
+
for (const w of r.warnings)
|
|
40
|
+
lines.push('', `! ${w}`);
|
|
41
|
+
lines.push('', ...r.assumptions);
|
|
42
|
+
return `${lines.join('\n')}\n`;
|
|
43
|
+
}
|
|
44
|
+
function picksText(picks) {
|
|
45
|
+
const parts = Object.entries(picks).map(([choice, options]) => `${choice}: ${options.join(', ')}`);
|
|
46
|
+
return parts.length === 0 ? '' : ` (picks ${parts.join('; ')})`;
|
|
47
|
+
}
|
|
48
|
+
function cardNamer(catalog) {
|
|
49
|
+
return (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
50
|
+
}
|
|
51
|
+
function splitText(sim, name) {
|
|
52
|
+
const byCard = new Map();
|
|
53
|
+
for (const line of sim.lines) {
|
|
54
|
+
const first = line.cards[0];
|
|
55
|
+
if (first === undefined)
|
|
56
|
+
continue;
|
|
57
|
+
byCard.set(first, [...(byCard.get(first) ?? []), line.label]);
|
|
58
|
+
}
|
|
59
|
+
return [...byCard].map(([id, labels]) => `${labels.join(', ')} → ${name(id)}`);
|
|
60
|
+
}
|
|
61
|
+
const TOP_SIMULATED = 5;
|
|
62
|
+
export function formatCatalogSimulation(r, catalog, all) {
|
|
63
|
+
const name = cardNamer(catalog);
|
|
64
|
+
const first = r.singles[0];
|
|
65
|
+
if (!first)
|
|
66
|
+
return 'No cards match.\n';
|
|
67
|
+
const top = all ? 20 : TOP_SIMULATED;
|
|
68
|
+
const lines = [
|
|
69
|
+
`A year of this spending: ${wholeUsd(first.spend)}.`,
|
|
70
|
+
'',
|
|
71
|
+
'Best single cards (rewards minus annual fee, per year):',
|
|
72
|
+
];
|
|
73
|
+
r.singles.slice(0, top).forEach((s, i) => {
|
|
74
|
+
const card = s.cards[0];
|
|
75
|
+
if (!card)
|
|
76
|
+
return;
|
|
77
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeUsd(s.net)} (${wholeUsd(s.rewards)} rewards, ${wholeUsd(s.annualFees)} fee)${picksText(card.picks)}`);
|
|
78
|
+
});
|
|
79
|
+
const pairs = r.pairs.slice(0, top);
|
|
80
|
+
if (pairs.length > 0) {
|
|
81
|
+
lines.push('', 'Best two-card pairs:');
|
|
82
|
+
pairs.forEach((s, i) => {
|
|
83
|
+
lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeUsd(s.net)}`);
|
|
84
|
+
if (s.alternatives.length > 0)
|
|
85
|
+
lines.push(` Same result with ${s.alternatives.map(name).join(' or ')} as the second card.`);
|
|
86
|
+
for (const split of splitText(s, name))
|
|
87
|
+
lines.push(` ${split}`);
|
|
88
|
+
});
|
|
89
|
+
const bestPair = r.pairs[0];
|
|
90
|
+
if (bestPair && bestPair.net <= first.net)
|
|
91
|
+
lines.push('', `One card is enough here: no pair beats ${name(first.cards[0]?.cardId ?? '')}.`);
|
|
92
|
+
}
|
|
93
|
+
const notes = new Set([
|
|
94
|
+
...r.singles.slice(0, top).flatMap((s) => s.notes),
|
|
95
|
+
...pairs.flatMap((s) => s.notes),
|
|
96
|
+
]);
|
|
97
|
+
if (notes.size > 0)
|
|
98
|
+
lines.push('', ...notes);
|
|
99
|
+
lines.push('', "Assumes each card's best picks, activations done each quarter, and no welcome bonuses.");
|
|
100
|
+
return `${lines.join('\n')}\n`;
|
|
101
|
+
}
|
|
102
|
+
export function formatAdditions(r, catalog, all) {
|
|
103
|
+
const name = cardNamer(catalog);
|
|
104
|
+
const lines = [
|
|
105
|
+
`Your wallet earns about ${wholeUsd(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeUsd(r.current.annualFees)} in annual fees` : ''}.`,
|
|
106
|
+
];
|
|
107
|
+
for (const split of splitText(r.current, name))
|
|
108
|
+
lines.push(` ${split}`);
|
|
109
|
+
const gains = r.additions.filter((a) => a.gain > 0);
|
|
110
|
+
if (gains.length === 0) {
|
|
111
|
+
lines.push('', 'No card would add more than its annual fee costs.');
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
lines.push('', 'Cards that would add the most (after their annual fee):');
|
|
115
|
+
gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
|
|
116
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeUsd(a.gain)} a year (+${wholeUsd(a.extraRewards)} rewards, ${wholeUsd(a.annualFee)} fee)${picksText(a.picks)}`);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const notes = new Set(r.current.notes);
|
|
120
|
+
if (notes.size > 0)
|
|
121
|
+
lines.push('', ...notes);
|
|
122
|
+
lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh.');
|
|
123
|
+
return `${lines.join('\n')}\n`;
|
|
124
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const TOP = 10;
|
|
2
|
+
const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
3
|
+
function reward(o, currencyName) {
|
|
4
|
+
if (o.dollars !== null)
|
|
5
|
+
return usd(o.dollars);
|
|
6
|
+
return `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
|
|
7
|
+
}
|
|
8
|
+
function feeText(o) {
|
|
9
|
+
if (o.annualFee === null)
|
|
10
|
+
return 'annual fee not on file';
|
|
11
|
+
if (o.annualFee === 0)
|
|
12
|
+
return 'no annual fee';
|
|
13
|
+
if (o.firstYearFee !== null && o.firstYearFee !== o.annualFee)
|
|
14
|
+
return `${usd(o.annualFee)} annual fee (${o.firstYearFee === 0 ? 'waived' : usd(o.firstYearFee)} the first year)`;
|
|
15
|
+
return `${usd(o.annualFee)} annual fee`;
|
|
16
|
+
}
|
|
17
|
+
export function formatOffers(c, currencyName, all) {
|
|
18
|
+
if (c.offers.length === 0) {
|
|
19
|
+
return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
|
|
20
|
+
}
|
|
21
|
+
const shown = all ? c.offers : c.offers.slice(0, TOP);
|
|
22
|
+
const width = Math.max(...shown.map((o) => o.cardName.length));
|
|
23
|
+
const lines = [];
|
|
24
|
+
shown.forEach((o, i) => {
|
|
25
|
+
const weeks = (o.months * 52) / 12;
|
|
26
|
+
const pace = o.spend > 0 ? ` (about ${usd(Math.round(o.spend / weeks))} a week)` : '';
|
|
27
|
+
const spend = o.spend > 0
|
|
28
|
+
? `after ${usd(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
|
|
29
|
+
: 'with no spending required';
|
|
30
|
+
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${usd(Math.round(o.firstYearValue))} the first year`, ` ${reward(o, currencyName(o.currency))} ${spend}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.held ? ' In your wallet.' : ''}`);
|
|
31
|
+
if (o.validTo)
|
|
32
|
+
lines.push(` Offer ends ${o.validTo}.`);
|
|
33
|
+
if (o.note)
|
|
34
|
+
lines.push(` ${o.note}`);
|
|
35
|
+
});
|
|
36
|
+
if (c.offers.length > shown.length)
|
|
37
|
+
lines.push(` + ${String(c.offers.length - shown.length)} more (--all lists them)`);
|
|
38
|
+
if (c.withoutOffer.length > 0)
|
|
39
|
+
lines.push('', `${String(c.withoutOffer.length)} matching card(s) have no published offer on file.`);
|
|
40
|
+
lines.push('', ...c.notes);
|
|
41
|
+
return `${lines.join('\n')}\n`;
|
|
42
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { parse, stringify } from 'yaml';
|
|
2
|
+
import { walletProblems } from "../data/integrity.js";
|
|
3
|
+
import { WalletSchema } from "../schema/wallet.js";
|
|
4
|
+
// A wallet as the YAML file the CLI reads, for moving it between the
|
|
5
|
+
// website, the remote server and the command line. Node-free.
|
|
6
|
+
function compact(wallet) {
|
|
7
|
+
return {
|
|
8
|
+
...(Object.keys(wallet.valuations).length > 0
|
|
9
|
+
? { valuations: wallet.valuations }
|
|
10
|
+
: {}),
|
|
11
|
+
...(wallet.memberships.length > 0
|
|
12
|
+
? { memberships: wallet.memberships }
|
|
13
|
+
: {}),
|
|
14
|
+
cards: wallet.cards.map((c) => ({
|
|
15
|
+
id: c.id,
|
|
16
|
+
...(c.checkout_label === undefined
|
|
17
|
+
? {}
|
|
18
|
+
: { checkout_label: c.checkout_label }),
|
|
19
|
+
...(c.anniversary === undefined ? {} : { anniversary: c.anniversary }),
|
|
20
|
+
...(Object.keys(c.cap_usage).length > 0
|
|
21
|
+
? { cap_usage: c.cap_usage }
|
|
22
|
+
: {}),
|
|
23
|
+
...(c.chosen === undefined ? {} : { chosen: c.chosen }),
|
|
24
|
+
...(c.activated.length > 0 ? { activated: c.activated } : {}),
|
|
25
|
+
...(c.bonus === undefined ? {} : { bonus: c.bonus }),
|
|
26
|
+
})),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function walletYaml(wallet) {
|
|
30
|
+
return `# Your earnrate wallet: the cards you hold, by id. Never card numbers.\n# For the command line, save it as ~/.earnrate/wallet.yaml\n\n${stringify(compact(wallet))}`;
|
|
31
|
+
}
|
|
32
|
+
export function parseWalletYaml(text, catalog) {
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = parse(text);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return "That isn't a wallet: it isn't valid YAML.";
|
|
39
|
+
}
|
|
40
|
+
const result = WalletSchema.safeParse(raw);
|
|
41
|
+
if (!result.success)
|
|
42
|
+
return "That isn't an earnrate wallet.";
|
|
43
|
+
const problems = walletProblems(result.data, catalog);
|
|
44
|
+
return problems.length > 0 ? problems.join(' ') : result.data;
|
|
45
|
+
}
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -17,7 +17,7 @@ export const MerchantResolutionSchema = z.object({
|
|
|
17
17
|
.nullable(),
|
|
18
18
|
channel: Channel.nullable(),
|
|
19
19
|
confidence: Confidence,
|
|
20
|
-
source: z.enum(['override', 'seed', 'llm', 'caller', 'none']),
|
|
20
|
+
source: z.enum(['override', 'seed', 'llm', 'caller', 'category', 'none']),
|
|
21
21
|
bookedVia: z.array(z.string()),
|
|
22
22
|
inference: z
|
|
23
23
|
.object({
|
|
@@ -277,6 +277,7 @@ const SimulationSchema = z.object({
|
|
|
277
277
|
notes: z.array(z.string()),
|
|
278
278
|
});
|
|
279
279
|
export const SpendingSimulationSchema = z.object({
|
|
280
|
+
notes: z.array(z.string()),
|
|
280
281
|
singles: z.array(SimulationSchema),
|
|
281
282
|
pairs: z.array(SimulationSchema.extend({ alternatives: z.array(z.string()) })),
|
|
282
283
|
current: SimulationSchema.nullable(),
|
|
@@ -289,6 +290,7 @@ export const SpendingSimulationSchema = z.object({
|
|
|
289
290
|
})),
|
|
290
291
|
});
|
|
291
292
|
export const CheatSheetSchema = z.object({
|
|
293
|
+
scope: z.enum(['wallet', 'all cards']),
|
|
292
294
|
quarter: z.string(),
|
|
293
295
|
date: z.string(),
|
|
294
296
|
rows: z.array(z.object({
|
|
@@ -301,3 +303,7 @@ export const CheatSheetSchema = z.object({
|
|
|
301
303
|
})),
|
|
302
304
|
notes: z.array(z.string()),
|
|
303
305
|
});
|
|
306
|
+
export const PurchaseAnswerSchema = RecommendationSchema.extend({
|
|
307
|
+
// Every card earnrate knows, ranked: set when no cards were given.
|
|
308
|
+
allCards: ExplorationSchema.nullable(),
|
|
309
|
+
});
|
package/dist/mcp/serve.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
2
|
+
import { FileWalletStore } from "../data/file-store.js";
|
|
2
3
|
import { loadCatalog, walletPath } from "../data/load.js";
|
|
3
4
|
import { localToday as today } from "../engine/periods.js";
|
|
4
5
|
import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
|
|
@@ -21,7 +22,8 @@ export async function serveMcp(env) {
|
|
|
21
22
|
}
|
|
22
23
|
serveStdio(() => createEarnrateServer({
|
|
23
24
|
catalog,
|
|
24
|
-
|
|
25
|
+
store: new FileWalletStore(catalog, walletPath(env)),
|
|
26
|
+
savedWallets: 'file',
|
|
25
27
|
today,
|
|
26
28
|
llm,
|
|
27
29
|
version,
|