earnrate 0.1.4 → 0.2.1
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 +52 -17
- package/data/CHANGELOG.md +8 -0
- package/data/VERSION +1 -1
- package/data/categories.yaml +22 -7
- package/dist/brand.js +2 -3
- package/dist/cli/args.js +67 -45
- package/dist/cli/cheatsheet.js +36 -35
- package/dist/cli/explore.js +47 -163
- package/dist/cli/import.js +3 -8
- package/dist/cli/offers.js +14 -56
- package/dist/cli/run.js +90 -20
- package/dist/cli/wallet.js +47 -6
- package/dist/data/file-store.js +95 -0
- package/dist/data/load.js +10 -2
- package/dist/data/store.js +92 -0
- package/dist/data/wallet-edit.js +93 -4
- package/dist/engine/bonus.js +15 -12
- package/dist/engine/cheat-sheet.js +59 -4
- package/dist/engine/evaluate.js +34 -10
- package/dist/engine/explore.js +16 -28
- package/dist/engine/offers.js +8 -21
- package/dist/engine/periods.js +9 -0
- package/dist/engine/recommend.js +17 -53
- package/dist/engine/record.js +34 -4
- package/dist/engine/simulate.js +33 -19
- package/dist/engine/text.js +53 -0
- package/dist/engine/transient.js +26 -0
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +36 -15
- package/dist/engine/wallet-summary.js +4 -8
- package/dist/format/card-text.js +79 -0
- package/dist/format/cheat-sheet.js +19 -0
- package/dist/format/explore.js +114 -0
- package/dist/format/offers.js +33 -0
- package/dist/{cli/format.js → format/recommendation.js} +9 -11
- package/dist/format/wallet-yaml.js +45 -0
- package/dist/format/wallet.js +38 -0
- package/dist/import/analyze.js +17 -19
- package/dist/mcp/context.js +92 -0
- package/dist/mcp/inputs.js +148 -0
- package/dist/mcp/schemas.js +8 -2
- package/dist/mcp/serve.js +3 -1
- package/dist/mcp/server.js +43 -660
- package/dist/mcp/tools/account.js +71 -0
- package/dist/mcp/tools/answers.js +329 -0
- package/dist/mcp/tools/wallet.js +128 -0
- package/dist/merchants/from-env.js +1 -1
- package/dist/merchants/resolve.js +3 -3
- package/dist/schema/catalog.js +1 -0
- package/package.json +8 -3
- package/dist/data/wallet-setup.js +0 -60
- package/dist/data/wallet-write.js +0 -140
- /package/dist/{cli → format}/cards.js +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { bonusStatus } from "./bonus.js";
|
|
2
|
-
import { pickedCategories } from "./evaluate.js";
|
|
3
|
-
import { daysBetween
|
|
4
|
-
import { rotationGap } from "./rotation.js";
|
|
2
|
+
import { capUsed, pickedCategories } from "./evaluate.js";
|
|
3
|
+
import { daysBetween } from "./periods.js";
|
|
5
4
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
5
|
+
import { rotationGap } from "./rotation.js";
|
|
6
6
|
import { cardValuation } from "./valuation.js";
|
|
7
7
|
function capSummaries(card, held, today) {
|
|
8
8
|
const caps = new Map();
|
|
@@ -19,11 +19,7 @@ function capSummaries(card, held, today) {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
return [...caps].map(([key, { cap, rules }]) => {
|
|
22
|
-
const currentPeriod =
|
|
23
|
-
const usage = held.cap_usage[key];
|
|
24
|
-
const spent = currentPeriod !== null && usage?.period === currentPeriod
|
|
25
|
-
? usage.spent
|
|
26
|
-
: 0;
|
|
22
|
+
const { period: currentPeriod, spent } = capUsed(cap, key, held, today);
|
|
27
23
|
return {
|
|
28
24
|
key,
|
|
29
25
|
rules,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { earnText } from "../engine/text.js";
|
|
2
|
+
// Card names, fees and requirements as earnrate.dev and its static search
|
|
3
|
+
// pages show them. Node-free: the site bundles it.
|
|
4
|
+
const ISSUERS = {
|
|
5
|
+
amex: 'American Express',
|
|
6
|
+
'bank-of-america': 'Bank of America',
|
|
7
|
+
'capital-one': 'Capital One',
|
|
8
|
+
chase: 'Chase',
|
|
9
|
+
citi: 'Citi',
|
|
10
|
+
column: 'Bilt',
|
|
11
|
+
sofi: 'SoFi',
|
|
12
|
+
'td-bank': 'TD Bank',
|
|
13
|
+
usbank: 'U.S. Bank',
|
|
14
|
+
'wells-fargo': 'Wells Fargo',
|
|
15
|
+
};
|
|
16
|
+
export const MEMBERSHIPS = {
|
|
17
|
+
'amazon-prime': 'Amazon Prime',
|
|
18
|
+
'bank-of-america-account': 'Bank of America checking or savings',
|
|
19
|
+
'bofa-rewards-member': 'BofA Rewards Member',
|
|
20
|
+
'bofa-rewards-preferred-plus': 'BofA Rewards Preferred Plus',
|
|
21
|
+
'bofa-rewards-preferred-honors': 'BofA Rewards Preferred Honors',
|
|
22
|
+
'bofa-rewards-premier': 'BofA Rewards Premier',
|
|
23
|
+
};
|
|
24
|
+
export const PICK_PERIODS = {
|
|
25
|
+
month: 'each month',
|
|
26
|
+
quarter: 'each quarter',
|
|
27
|
+
calendar_year: 'each year',
|
|
28
|
+
until_changed: 'until you change it',
|
|
29
|
+
};
|
|
30
|
+
export const CAP_PERIODS = {
|
|
31
|
+
month: 'a month',
|
|
32
|
+
quarter: 'a quarter',
|
|
33
|
+
calendar_year: 'a year',
|
|
34
|
+
cardmember_year: 'a card year',
|
|
35
|
+
};
|
|
36
|
+
export const words = (id) => id.replace(/[-_.]/g, ' ');
|
|
37
|
+
// Whole dollars, for fees and caps.
|
|
38
|
+
export const usd = (n) => `${n < 0 ? '−' : ''}$${Math.abs(Math.round(n)).toLocaleString('en-US')}`;
|
|
39
|
+
export const issuerName = (id) => ISSUERS[id] ?? id;
|
|
40
|
+
export const membershipName = (id) => MEMBERSHIPS[id] ?? words(id);
|
|
41
|
+
export function cardText(catalog) {
|
|
42
|
+
const cards = new Map(catalog.cards.map((c) => [c.id, c]));
|
|
43
|
+
function shortName(id) {
|
|
44
|
+
const name = cards.get(id)?.name ?? id;
|
|
45
|
+
return name
|
|
46
|
+
.replace(/^American Express /, 'Amex ')
|
|
47
|
+
.replace(/^The Platinum Card from American Express$/, 'Amex Platinum')
|
|
48
|
+
.replace(/ (Visa Signature |Visa Infinite )?(Credit )?Card$/, '')
|
|
49
|
+
.replace(/ Card by /, ' by ');
|
|
50
|
+
}
|
|
51
|
+
function feeLabel(id) {
|
|
52
|
+
const card = cards.get(id);
|
|
53
|
+
const fee = card?.annual_fee;
|
|
54
|
+
if (fee === undefined)
|
|
55
|
+
return 'Annual fee not on file';
|
|
56
|
+
if (fee === 0)
|
|
57
|
+
return 'No annual fee';
|
|
58
|
+
const first = card?.first_year_fee;
|
|
59
|
+
if (first !== undefined && first !== fee)
|
|
60
|
+
return `${usd(fee)} a year (${first === 0 ? 'waived' : usd(first)} the first year)`;
|
|
61
|
+
return `${usd(fee)} a year`;
|
|
62
|
+
}
|
|
63
|
+
// `until` is the last day a time-limited rate applies, already formatted.
|
|
64
|
+
function requirementLines(cardId, r, until = null) {
|
|
65
|
+
const lines = [];
|
|
66
|
+
if (r.pick)
|
|
67
|
+
lines.push(`Pick ${r.pick.options.map(words).join(' or ')} as a bonus category (${PICK_PERIODS[r.pick.period]})`);
|
|
68
|
+
if (r.activation !== null)
|
|
69
|
+
lines.push(`Activate the bonus with ${issuerName(cards.get(cardId)?.issuer ?? '')} (free)`);
|
|
70
|
+
if (r.membership !== null)
|
|
71
|
+
lines.push(`With ${membershipName(r.membership)}`);
|
|
72
|
+
if (until !== null)
|
|
73
|
+
lines.push(`This rate runs through ${until}`);
|
|
74
|
+
if (r.cap)
|
|
75
|
+
lines.push(`Up to ${usd(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period] ?? ''}, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}`);
|
|
76
|
+
return lines;
|
|
77
|
+
}
|
|
78
|
+
return { cards, shortName, feeLabel, requirementLines };
|
|
79
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { pct } from "../engine/text.js";
|
|
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,114 @@
|
|
|
1
|
+
import { feeText, money, pct } from "../engine/text.js";
|
|
2
|
+
const wholeUsd = (n) => money(Math.round(n));
|
|
3
|
+
const TOP_BEST = 10;
|
|
4
|
+
export function formatExploration(r, all) {
|
|
5
|
+
const m = r.merchant;
|
|
6
|
+
const title = m.source === 'category'
|
|
7
|
+
? m.resolvedName.toLowerCase()
|
|
8
|
+
: m.source === 'none'
|
|
9
|
+
? `"${m.input}" (not recognized)`
|
|
10
|
+
: m.resolvedName;
|
|
11
|
+
if (r.ranked.length === 0)
|
|
12
|
+
return `No cards match at ${title}.\n`;
|
|
13
|
+
const shown = all ? r.ranked : r.ranked.slice(0, TOP_BEST);
|
|
14
|
+
const width = Math.max(...shown.map((e) => e.cardName.length));
|
|
15
|
+
const lines = [`Best cards for ${title}:`, ''];
|
|
16
|
+
shown.forEach((e, i) => {
|
|
17
|
+
const earned = e.earned === null ? '' : ` ${money(e.earned)}`;
|
|
18
|
+
lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
|
|
19
|
+
for (const c of e.conditions)
|
|
20
|
+
lines.push(` ${c}`);
|
|
21
|
+
const upgrade = e.withMembership[0];
|
|
22
|
+
if (upgrade)
|
|
23
|
+
lines.push(` With ${upgrade.membership}: ${pct(upgrade.effectiveReturnPct)}.`);
|
|
24
|
+
for (const w of e.warnings)
|
|
25
|
+
lines.push(` ! ${w}`);
|
|
26
|
+
});
|
|
27
|
+
if (r.ranked.length > shown.length)
|
|
28
|
+
lines.push(` + ${String(r.ranked.length - shown.length)} more (--all lists them)`);
|
|
29
|
+
for (const w of r.warnings)
|
|
30
|
+
lines.push('', `! ${w}`);
|
|
31
|
+
lines.push('', ...r.assumptions);
|
|
32
|
+
return `${lines.join('\n')}\n`;
|
|
33
|
+
}
|
|
34
|
+
function picksText(picks) {
|
|
35
|
+
const parts = Object.entries(picks).map(([choice, options]) => `${choice}: ${options.join(', ')}`);
|
|
36
|
+
return parts.length === 0 ? '' : ` (picks ${parts.join('; ')})`;
|
|
37
|
+
}
|
|
38
|
+
function cardNamer(catalog) {
|
|
39
|
+
return (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
40
|
+
}
|
|
41
|
+
function splitText(sim, name) {
|
|
42
|
+
const byCard = new Map();
|
|
43
|
+
for (const line of sim.lines) {
|
|
44
|
+
const first = line.cards[0];
|
|
45
|
+
if (first === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
byCard.set(first, [...(byCard.get(first) ?? []), line.label]);
|
|
48
|
+
}
|
|
49
|
+
return [...byCard].map(([id, labels]) => `${labels.join(', ')} → ${name(id)}`);
|
|
50
|
+
}
|
|
51
|
+
const TOP_SIMULATED = 5;
|
|
52
|
+
export function formatCatalogSimulation(r, catalog, all) {
|
|
53
|
+
const name = cardNamer(catalog);
|
|
54
|
+
const first = r.singles[0];
|
|
55
|
+
if (!first)
|
|
56
|
+
return 'No cards match.\n';
|
|
57
|
+
const top = all ? 20 : TOP_SIMULATED;
|
|
58
|
+
const lines = [
|
|
59
|
+
`A year of this spending: ${wholeUsd(first.spend)}.`,
|
|
60
|
+
'',
|
|
61
|
+
'Best single cards (rewards minus annual fee, per year):',
|
|
62
|
+
];
|
|
63
|
+
r.singles.slice(0, top).forEach((s, i) => {
|
|
64
|
+
const card = s.cards[0];
|
|
65
|
+
if (!card)
|
|
66
|
+
return;
|
|
67
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeUsd(s.net)} (${wholeUsd(s.rewards)} rewards, ${wholeUsd(s.annualFees)} fee)${picksText(card.picks)}`);
|
|
68
|
+
});
|
|
69
|
+
const pairs = r.pairs.slice(0, top);
|
|
70
|
+
if (pairs.length > 0) {
|
|
71
|
+
lines.push('', 'Best two-card pairs:');
|
|
72
|
+
pairs.forEach((s, i) => {
|
|
73
|
+
lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeUsd(s.net)}`);
|
|
74
|
+
if (s.alternatives.length > 0)
|
|
75
|
+
lines.push(` Same result with ${s.alternatives.map(name).join(' or ')} as the second card.`);
|
|
76
|
+
for (const split of splitText(s, name))
|
|
77
|
+
lines.push(` ${split}`);
|
|
78
|
+
});
|
|
79
|
+
const bestPair = r.pairs[0];
|
|
80
|
+
if (bestPair && bestPair.net <= first.net)
|
|
81
|
+
lines.push('', `One card is enough here: no pair beats ${name(first.cards[0]?.cardId ?? '')}.`);
|
|
82
|
+
}
|
|
83
|
+
const notes = new Set([
|
|
84
|
+
...r.singles.slice(0, top).flatMap((s) => s.notes),
|
|
85
|
+
...pairs.flatMap((s) => s.notes),
|
|
86
|
+
]);
|
|
87
|
+
if (notes.size > 0)
|
|
88
|
+
lines.push('', ...notes);
|
|
89
|
+
lines.push('', "Assumes each card's best picks, activations done each quarter, and no welcome bonuses.");
|
|
90
|
+
return `${lines.join('\n')}\n`;
|
|
91
|
+
}
|
|
92
|
+
export function formatAdditions(r, catalog, all) {
|
|
93
|
+
const name = cardNamer(catalog);
|
|
94
|
+
const lines = [
|
|
95
|
+
`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` : ''}.`,
|
|
96
|
+
];
|
|
97
|
+
for (const split of splitText(r.current, name))
|
|
98
|
+
lines.push(` ${split}`);
|
|
99
|
+
const gains = r.additions.filter((a) => a.gain > 0);
|
|
100
|
+
if (gains.length === 0) {
|
|
101
|
+
lines.push('', 'No card would add more than its annual fee costs.');
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
lines.push('', 'Cards that would add the most (after their annual fee):');
|
|
105
|
+
gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
|
|
106
|
+
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)}`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const notes = new Set(r.current.notes);
|
|
110
|
+
if (notes.size > 0)
|
|
111
|
+
lines.push('', ...notes);
|
|
112
|
+
lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh.');
|
|
113
|
+
return `${lines.join('\n')}\n`;
|
|
114
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { feeText, money } from "../engine/text.js";
|
|
2
|
+
const TOP = 10;
|
|
3
|
+
function reward(o, currencyName) {
|
|
4
|
+
if (o.dollars !== null)
|
|
5
|
+
return money(o.dollars);
|
|
6
|
+
return `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
|
|
7
|
+
}
|
|
8
|
+
export function formatOffers(c, currencyName, all) {
|
|
9
|
+
if (c.offers.length === 0) {
|
|
10
|
+
return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
|
|
11
|
+
}
|
|
12
|
+
const shown = all ? c.offers : c.offers.slice(0, TOP);
|
|
13
|
+
const width = Math.max(...shown.map((o) => o.cardName.length));
|
|
14
|
+
const lines = [];
|
|
15
|
+
shown.forEach((o, i) => {
|
|
16
|
+
const weeks = (o.months * 52) / 12;
|
|
17
|
+
const pace = o.spend > 0 ? ` (about ${money(Math.round(o.spend / weeks))} a week)` : '';
|
|
18
|
+
const spend = o.spend > 0
|
|
19
|
+
? `after ${money(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
|
|
20
|
+
: 'with no spending required';
|
|
21
|
+
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${money(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.' : ''}`);
|
|
22
|
+
if (o.validTo)
|
|
23
|
+
lines.push(` Offer ends ${o.validTo}.`);
|
|
24
|
+
if (o.note)
|
|
25
|
+
lines.push(` ${o.note}`);
|
|
26
|
+
});
|
|
27
|
+
if (c.offers.length > shown.length)
|
|
28
|
+
lines.push(` + ${String(c.offers.length - shown.length)} more (--all lists them)`);
|
|
29
|
+
if (c.withoutOffer.length > 0)
|
|
30
|
+
lines.push('', `${String(c.withoutOffer.length)} matching card(s) have no published offer on file.`);
|
|
31
|
+
lines.push('', ...c.notes);
|
|
32
|
+
return `${lines.join('\n')}\n`;
|
|
33
|
+
}
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
+
import { money, pct } from "../engine/text.js";
|
|
1
2
|
export const plain = (_format, text) => text;
|
|
2
3
|
const TOP = 3;
|
|
3
|
-
const pct = (n) => `${String(Number(n.toFixed(2)))}%`;
|
|
4
4
|
const cents = (n) => `$${n.toFixed(2)}`;
|
|
5
|
-
const amountText = (n) => `$${n.toLocaleString('en-US', {
|
|
6
|
-
minimumFractionDigits: Number.isInteger(n) ? 0 : 2,
|
|
7
|
-
maximumFractionDigits: 2,
|
|
8
|
-
})}`;
|
|
9
5
|
const label = (c) => c.checkoutLabel ?? c.cardName;
|
|
10
6
|
function header(rec, purchase) {
|
|
11
7
|
const m = rec.merchant;
|
|
@@ -14,14 +10,16 @@ function header(rec, purchase) {
|
|
|
14
10
|
? `"${m.resolvedName}" (not recognized)`
|
|
15
11
|
: m.source === 'caller'
|
|
16
12
|
? `${m.resolvedName} (category given)`
|
|
17
|
-
: m.source === '
|
|
18
|
-
? m.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
: m.source === 'category'
|
|
14
|
+
? m.resolvedName
|
|
15
|
+
: m.source === 'llm'
|
|
16
|
+
? m.category === 'other'
|
|
17
|
+
? `"${m.resolvedName}" (Claude couldn't place it)`
|
|
18
|
+
: `${m.resolvedName} (classified by Claude)`
|
|
19
|
+
: m.resolvedName,
|
|
22
20
|
];
|
|
23
21
|
if (purchase.amount !== null)
|
|
24
|
-
parts.push(
|
|
22
|
+
parts.push(money(purchase.amount));
|
|
25
23
|
if (m.channel)
|
|
26
24
|
parts.push(m.channel === 'online' ? 'online' : 'in-store');
|
|
27
25
|
return parts.join(' · ');
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { money } from "../engine/text.js";
|
|
2
|
+
export function formatWalletSummary(summary) {
|
|
3
|
+
return summary.cards
|
|
4
|
+
.map((c) => {
|
|
5
|
+
const caps = c.caps
|
|
6
|
+
.filter((cap) => cap.remaining !== null)
|
|
7
|
+
.map((cap) => `${cap.key} ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''}`);
|
|
8
|
+
const chosen = c.chosen
|
|
9
|
+
? `; chosen categories for ${c.chosen.period}${c.chosen.current ? '' : ' (out of date)'}`
|
|
10
|
+
: '';
|
|
11
|
+
const onlyAt = c.onlyAt ? `; works only at ${c.onlyAt.join(', ')}` : '';
|
|
12
|
+
const rotation = c.rotationGap === null
|
|
13
|
+
? ''
|
|
14
|
+
: `; rotating categories for ${c.rotationGap} not in earnrate yet`;
|
|
15
|
+
const b = c.bonus;
|
|
16
|
+
const bonus = b?.state === 'open' && b.spend !== null
|
|
17
|
+
? `; welcome bonus ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}`
|
|
18
|
+
: b
|
|
19
|
+
? `; welcome bonus ${b.state}`
|
|
20
|
+
: '';
|
|
21
|
+
return `- ${c.checkoutLabel ?? c.cardName} (${c.cardId}): ${c.currency} at ${String(c.centsPerUnit)}¢${caps.length > 0 ? `; ${caps.join(', ')}` : ''}${chosen}${rotation}${onlyAt}${bonus}`;
|
|
22
|
+
})
|
|
23
|
+
.join('\n');
|
|
24
|
+
}
|
|
25
|
+
export function formatBooking(amount, cardId, cap, bonus) {
|
|
26
|
+
const messages = [];
|
|
27
|
+
if (cap) {
|
|
28
|
+
const left = Math.max(0, cap.capAmount - cap.after);
|
|
29
|
+
messages.push(`Recorded ${money(amount)} on ${cardId} against "${cap.capKey}": ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} for ${cap.period}, ${money(left)} left.`);
|
|
30
|
+
}
|
|
31
|
+
if (bonus) {
|
|
32
|
+
const left = Math.max(0, bonus.spend - bonus.after);
|
|
33
|
+
messages.push(left === 0
|
|
34
|
+
? `Welcome bonus spend met on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)}.`
|
|
35
|
+
: `Welcome bonus on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
|
|
36
|
+
}
|
|
37
|
+
return messages.join(' ');
|
|
38
|
+
}
|
package/dist/import/analyze.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
|
|
2
|
-
import { categoryMatches } from "../engine/evaluate.js";
|
|
2
|
+
import { capByKey, categoryMatches } from "../engine/evaluate.js";
|
|
3
3
|
import { periodKey } from "../engine/periods.js";
|
|
4
|
-
import { applyBooking, capBooking } from "../engine/record.js";
|
|
5
4
|
import { recommend } from "../engine/recommend.js";
|
|
5
|
+
import { applyBooking, capBooking } from "../engine/record.js";
|
|
6
|
+
import { roundCents } from "../engine/text.js";
|
|
6
7
|
import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
|
|
7
8
|
import { cleanDescriptor } from "./statement.js";
|
|
8
|
-
const round = (n) => Math.round(n * 100) / 100;
|
|
9
9
|
function resolveLine(catalog, line) {
|
|
10
10
|
const cleaned = cleanDescriptor(line.description);
|
|
11
11
|
// "NAME-CHEAP.COM* LV0I6X": the part before the asterisk names the merchant.
|
|
@@ -30,12 +30,10 @@ function resolveLine(catalog, line) {
|
|
|
30
30
|
function capPeriodKey(catalog, wallet, booking, date) {
|
|
31
31
|
const card = catalog.cards.find((c) => c.id === booking.cardId);
|
|
32
32
|
const held = wallet.cards.find((c) => c.id === booking.cardId);
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
const period = shared?.period ?? (typeof inline === 'object' ? inline.period : undefined);
|
|
36
|
-
if (!period)
|
|
33
|
+
const cap = card && capByKey(card, booking.capKey);
|
|
34
|
+
if (!cap)
|
|
37
35
|
return null;
|
|
38
|
-
return periodKey(period, date, held?.anniversary ?? null);
|
|
36
|
+
return periodKey(cap.period, date, held?.anniversary ?? null);
|
|
39
37
|
}
|
|
40
38
|
// Replays the statements twice: once on the cards actually used, once on
|
|
41
39
|
// earnrate's picks, each with its own running cap usage, so a cap that fills
|
|
@@ -76,7 +74,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
76
74
|
if (progress &&
|
|
77
75
|
countsTowardBonus(progress, line.date) &&
|
|
78
76
|
!alreadyRecorded(entry, 'bonus')) {
|
|
79
|
-
progress.after =
|
|
77
|
+
progress.after = roundCents(Math.max(0, progress.after + line.amount));
|
|
80
78
|
progress.entries.push(entry);
|
|
81
79
|
}
|
|
82
80
|
const { name, merchant, known } = resolveLine(catalog, line);
|
|
@@ -96,7 +94,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
96
94
|
const signed = line.amount < 0
|
|
97
95
|
? {
|
|
98
96
|
...booking,
|
|
99
|
-
after:
|
|
97
|
+
after: roundCents(Math.max(0, booking.before + line.amount)),
|
|
100
98
|
}
|
|
101
99
|
: booking;
|
|
102
100
|
bookings.push({ ...entry, booking: signed });
|
|
@@ -181,21 +179,21 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
181
179
|
from: sorted[0]?.line.date ?? '',
|
|
182
180
|
to: sorted.at(-1)?.line.date ?? '',
|
|
183
181
|
purchases,
|
|
184
|
-
spend:
|
|
185
|
-
refunds:
|
|
186
|
-
earned:
|
|
187
|
-
best:
|
|
182
|
+
spend: roundCents(spend),
|
|
183
|
+
refunds: roundCents(refunds),
|
|
184
|
+
earned: roundCents(earned),
|
|
185
|
+
best: roundCents(best),
|
|
188
186
|
byCard: [...byCard.values()].map((t) => ({
|
|
189
187
|
...t,
|
|
190
|
-
spend:
|
|
191
|
-
earned:
|
|
188
|
+
spend: roundCents(t.spend),
|
|
189
|
+
earned: roundCents(t.earned),
|
|
192
190
|
})),
|
|
193
191
|
missed: [...missed.values()]
|
|
194
192
|
.map((m) => ({
|
|
195
193
|
...m,
|
|
196
|
-
spend:
|
|
197
|
-
earned:
|
|
198
|
-
best:
|
|
194
|
+
spend: roundCents(m.spend),
|
|
195
|
+
earned: roundCents(m.earned),
|
|
196
|
+
best: roundCents(m.best),
|
|
199
197
|
}))
|
|
200
198
|
.sort((a, b) => b.best - b.earned - (a.best - a.earned)),
|
|
201
199
|
coding: [...coding.values()].sort((a, b) => b.count - a.count),
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { walletProblems } from "../data/integrity.js";
|
|
2
|
+
import { EMPTY_WALLET, walletFromCards } from "../engine/transient.js";
|
|
3
|
+
import { formatNotes } from "../format/cards.js";
|
|
4
|
+
import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
|
|
5
|
+
import { defined } from "./inputs.js";
|
|
6
|
+
export const fail = (text) => ({
|
|
7
|
+
content: [{ type: 'text', text }],
|
|
8
|
+
isError: true,
|
|
9
|
+
});
|
|
10
|
+
export const ALL_CARDS_NOTE = "The user hasn't named their cards, so this covers every card earnrate knows. That's a complete answer. If they want the best of their own cards, they can name them (pass them as `cards`); saving a wallet is optional.";
|
|
11
|
+
// What every tool shares: the saved wallet, merchant resolution, and which
|
|
12
|
+
// cards an answer is about.
|
|
13
|
+
export function makeContext(deps) {
|
|
14
|
+
const { catalog } = deps;
|
|
15
|
+
const theWallet = deps.savedWallets === 'file'
|
|
16
|
+
? `the wallet at ${deps.store.where}`
|
|
17
|
+
: 'the saved wallet';
|
|
18
|
+
const noWallet = `There's no saved wallet yet${deps.savedWallets === 'file' ? ` (${deps.store.where})` : ''}. Answers don't need one: they cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.`;
|
|
19
|
+
// Read on every call, so edits to the wallet apply without a restart.
|
|
20
|
+
async function wallet() {
|
|
21
|
+
let loaded;
|
|
22
|
+
try {
|
|
23
|
+
const found = await deps.store.load();
|
|
24
|
+
if (found === null)
|
|
25
|
+
return noWallet;
|
|
26
|
+
loaded = found;
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
return `Couldn't read ${theWallet}: ${e instanceof Error ? e.message : String(e)}`;
|
|
30
|
+
}
|
|
31
|
+
const problems = walletProblems(loaded, catalog);
|
|
32
|
+
return problems.length > 0
|
|
33
|
+
? `${theWallet[0]?.toUpperCase() ?? ''}${theWallet.slice(1)} has problems:\n${problems.map((p) => `- ${p}`).join('\n')}`
|
|
34
|
+
: loaded;
|
|
35
|
+
}
|
|
36
|
+
// No saved wallet is the normal state, not a failure: it reads as an
|
|
37
|
+
// empty one, with a note on how to start one.
|
|
38
|
+
async function readableWallet() {
|
|
39
|
+
const w = await wallet();
|
|
40
|
+
if (typeof w !== 'string')
|
|
41
|
+
return { wallet: w, start: null };
|
|
42
|
+
return w === noWallet ? { wallet: EMPTY_WALLET, start: w } : w;
|
|
43
|
+
}
|
|
44
|
+
// The caller's category is free and comes before the paid Claude fallback;
|
|
45
|
+
// neither ever overrides a merchant earnrate already knows.
|
|
46
|
+
async function resolve(merchant, channel, category) {
|
|
47
|
+
const known = resolveMerchant(catalog, merchant, channel);
|
|
48
|
+
if (known.source !== 'none' || category !== null || !deps.llm) {
|
|
49
|
+
return { merchant: withCallerCategory(known, category), notices: [] };
|
|
50
|
+
}
|
|
51
|
+
return deps.llm.resolve(catalog, merchant, channel);
|
|
52
|
+
}
|
|
53
|
+
// Cards named in the conversation come first, then the saved wallet; with
|
|
54
|
+
// neither (null), answers cover every card earnrate knows.
|
|
55
|
+
async function cardsFor(cards, memberships) {
|
|
56
|
+
if (cards && cards.length > 0)
|
|
57
|
+
return walletFromCards(catalog, {
|
|
58
|
+
cards: cards.map(({ cardId, ...rest }) => ({
|
|
59
|
+
id: cardId,
|
|
60
|
+
...defined(rest),
|
|
61
|
+
})),
|
|
62
|
+
...(memberships ? { memberships } : {}),
|
|
63
|
+
}, deps.today());
|
|
64
|
+
const read = await readableWallet();
|
|
65
|
+
if (typeof read === 'string')
|
|
66
|
+
return read;
|
|
67
|
+
return read.start === null ? read.wallet : null;
|
|
68
|
+
}
|
|
69
|
+
// A card change reports what that card still needs; other changes don't.
|
|
70
|
+
function changed(result, message, { notes = true } = {}) {
|
|
71
|
+
if (!result.ok)
|
|
72
|
+
return fail(result.error);
|
|
73
|
+
const output = {
|
|
74
|
+
message,
|
|
75
|
+
created: result.created,
|
|
76
|
+
cardIds: result.wallet.cards.map((c) => c.id),
|
|
77
|
+
memberships: result.wallet.memberships,
|
|
78
|
+
notes: result.notes,
|
|
79
|
+
};
|
|
80
|
+
const text = [
|
|
81
|
+
message,
|
|
82
|
+
`Wallet: ${output.cardIds.join(', ')}.`,
|
|
83
|
+
...(notes ? [formatNotes(result.notes)] : []),
|
|
84
|
+
].join('\n');
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: 'text', text }],
|
|
87
|
+
structuredContent: output,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const cardName = (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
91
|
+
return { wallet, readableWallet, resolve, cardsFor, changed, cardName };
|
|
92
|
+
}
|