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.
@@ -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
+ }
@@ -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
+ }
@@ -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
- walletFile: walletPath(env),
25
+ store: new FileWalletStore(catalog, walletPath(env)),
26
+ savedWallets: 'file',
25
27
  today,
26
28
  llm,
27
29
  version,