earnrate 0.2.0 → 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.
Files changed (47) hide show
  1. package/README.md +22 -20
  2. package/dist/brand.js +2 -3
  3. package/dist/cli/args.js +64 -51
  4. package/dist/cli/cheatsheet.js +9 -12
  5. package/dist/cli/explore.js +28 -27
  6. package/dist/cli/import.js +3 -8
  7. package/dist/cli/offers.js +7 -6
  8. package/dist/cli/run.js +17 -13
  9. package/dist/cli/wallet.js +7 -6
  10. package/dist/data/file-store.js +54 -15
  11. package/dist/data/load.js +1 -1
  12. package/dist/data/store.js +0 -16
  13. package/dist/data/wallet-edit.js +68 -2
  14. package/dist/engine/bonus.js +15 -12
  15. package/dist/engine/cheat-sheet.js +2 -2
  16. package/dist/engine/evaluate.js +34 -10
  17. package/dist/engine/explore.js +9 -22
  18. package/dist/engine/offers.js +8 -21
  19. package/dist/engine/periods.js +9 -0
  20. package/dist/engine/recommend.js +14 -53
  21. package/dist/engine/record.js +2 -7
  22. package/dist/engine/simulate.js +12 -19
  23. package/dist/engine/text.js +53 -0
  24. package/dist/engine/transient.js +5 -0
  25. package/dist/engine/valuation.js +1 -1
  26. package/dist/engine/wallet-change.js +9 -15
  27. package/dist/engine/wallet-summary.js +4 -8
  28. package/dist/format/card-text.js +79 -0
  29. package/dist/format/cheat-sheet.js +1 -1
  30. package/dist/format/explore.js +3 -13
  31. package/dist/format/offers.js +5 -14
  32. package/dist/{cli/format.js → format/recommendation.js} +2 -6
  33. package/dist/format/wallet.js +38 -0
  34. package/dist/import/analyze.js +17 -19
  35. package/dist/mcp/context.js +92 -0
  36. package/dist/mcp/inputs.js +148 -0
  37. package/dist/mcp/schemas.js +1 -1
  38. package/dist/mcp/server.js +14 -818
  39. package/dist/mcp/tools/account.js +71 -0
  40. package/dist/mcp/tools/answers.js +329 -0
  41. package/dist/mcp/tools/wallet.js +128 -0
  42. package/dist/merchants/from-env.js +1 -1
  43. package/dist/merchants/resolve.js +3 -3
  44. package/package.json +2 -2
  45. package/dist/data/wallet-setup.js +0 -60
  46. package/dist/data/wallet-write.js +0 -140
  47. /package/dist/{cli → format}/cards.js +0 -0
@@ -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 shared = card?.caps.find((c) => c.id === booking.capKey);
34
- const inline = card?.rules.find((r) => r.id === booking.capKey)?.cap;
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 = round(Math.max(0, progress.after + line.amount));
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: round(Math.max(0, booking.before + line.amount)),
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: round(spend),
185
- refunds: round(refunds),
186
- earned: round(earned),
187
- best: round(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: round(t.spend),
191
- earned: round(t.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: round(m.spend),
197
- earned: round(m.earned),
198
- best: round(m.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
+ }
@@ -0,0 +1,148 @@
1
+ import { z } from 'zod';
2
+ import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
3
+ import { VALUATION_PROFILES } from "../engine/profiles.js";
4
+ import { knownMemberships } from "../engine/setup.js";
5
+ import { IsoDate } from "../schema/common.js";
6
+ export const Merchant = z
7
+ .string()
8
+ .min(1)
9
+ .describe('Merchant name or web address as the user would say it or see it, e.g. "Whole Foods" or "delta.com".');
10
+ export const ChannelInput = z
11
+ .enum(['online', 'in_store'])
12
+ .optional()
13
+ .describe('How the purchase is made. Omit if unknown; the answer says what it assumed.');
14
+ export const DateInput = IsoDate.optional().describe('Purchase date, YYYY-MM-DD. Defaults to today.');
15
+ export const CardId = z
16
+ .string()
17
+ .min(1)
18
+ .describe('The card id from search_cards, e.g. chase-sapphire-preferred.');
19
+ // The filters every catalog-wide answer takes.
20
+ const OfferFilters = {
21
+ kind: z
22
+ .enum(['any', 'cash', 'points'])
23
+ .optional()
24
+ .describe('cash: cash back cards only. points: points and miles cards only.'),
25
+ noAnnualFee: z
26
+ .boolean()
27
+ .optional()
28
+ .describe('Only cards with no annual fee.'),
29
+ business: z
30
+ .boolean()
31
+ .optional()
32
+ .describe('Business cards instead of personal ones.'),
33
+ };
34
+ const NamedCard = z.object({
35
+ cardId: z.string().min(1).describe('A card id from search_cards.'),
36
+ picks: z
37
+ .record(z.string(), z.array(z.string()).min(1))
38
+ .optional()
39
+ .describe("Bonus categories the user picked, for cards that have them: choice id → option ids, from search_cards. Ask; don't guess."),
40
+ activated: z
41
+ .array(z.string())
42
+ .optional()
43
+ .describe('Bonuses the user has activated with the issuer, as rule ids from search_cards.'),
44
+ });
45
+ const cardFields = {
46
+ checkoutLabel: z
47
+ .string()
48
+ .min(1)
49
+ .optional()
50
+ .describe('How the card appears at checkout or in a browser wallet, e.g. "Amex Gold". Optional.'),
51
+ picks: z
52
+ .record(z.string(), z.array(z.string()).min(1))
53
+ .optional()
54
+ .describe('For cards where the cardholder picks bonus categories: choice id → option ids, from search_cards, e.g. {"five-percent": ["tv-internet-streaming", "fast-food"]}. Saved for the current period. Ask the user; don\'t guess.'),
55
+ anniversary: z
56
+ .string()
57
+ .regex(/^\d{2}-\d{2}$/)
58
+ .optional()
59
+ .describe("The account's anniversary as MM-DD, for cards whose cap resets on it."),
60
+ activated: z
61
+ .array(z.string())
62
+ .optional()
63
+ .describe('Rule ids the user has activated with the issuer, such as rotating categories. Replaces the current list.'),
64
+ bonus: z
65
+ .object({
66
+ opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
67
+ spent: z
68
+ .number()
69
+ .nonnegative()
70
+ .optional()
71
+ .describe('Dollars already spent toward the bonus. Default 0.'),
72
+ spend: z
73
+ .number()
74
+ .positive()
75
+ .optional()
76
+ .describe("Dollars of spending the bonus needs, only if the user's offer differs from the published one."),
77
+ by: IsoDate.optional().describe("The deadline, YYYY-MM-DD, only if the user's offer differs from the published one."),
78
+ })
79
+ .nullable()
80
+ .optional()
81
+ .describe("A welcome bonus the user is working toward, so purchases recorded on this card count toward it and answers can favor it. Ask when they opened the account; spending needed and the deadline default to the card's published offer. null stops tracking it."),
82
+ };
83
+ export const CardChange = z.object({ cardId: CardId, ...cardFields });
84
+ // Drops keys whose value is undefined, which optional zod fields produce and
85
+ // exactOptionalPropertyTypes won't accept.
86
+ export function defined(value) {
87
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
88
+ }
89
+ export function cardInput({ cardId, bonus, ...rest }) {
90
+ return {
91
+ id: cardId,
92
+ ...defined(rest),
93
+ ...(bonus === undefined ? {} : { bonus: bonus && defined(bonus) }),
94
+ };
95
+ }
96
+ // Inputs that list the catalog's categories, issuers and memberships.
97
+ export function makeInputs(catalog) {
98
+ const categoryIds = catalog.categories.map((c) => c.id);
99
+ const categoryList = catalog.categories
100
+ .map((c) => `${c.id} (${c.name})`)
101
+ .join(', ');
102
+ const membershipIds = knownMemberships(catalog);
103
+ const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
104
+ const CategoryInput = z
105
+ .enum(categoryIds)
106
+ .optional()
107
+ .describe(`Only for a merchant earnrate may not know, and only if you're confident what kind of business it is: its spending category, e.g. grocery.supermarket for a supermarket, dining for a restaurant, gas for a gas station. Ignored when earnrate knows the merchant. Leave it out if unsure. Categories: ${categoryList}.`);
108
+ const CatalogCategory = z
109
+ .enum(categoryIds)
110
+ .describe(`A spending category: ${categoryList}.`);
111
+ const Memberships = z.array(z.enum(membershipIds));
112
+ const NamedCards = z
113
+ .array(NamedCard)
114
+ .optional()
115
+ .describe("Cards the user named in this conversation, to answer for just those cards without saving anything. Leave out to use the user's saved wallet if they have one; with neither, the answer covers every card earnrate knows.");
116
+ const NamedMemberships = Memberships.optional().describe("Memberships the user has, such as amazon-prime, when they're named in the conversation rather than saved.");
117
+ const CatalogFilters = {
118
+ ...OfferFilters,
119
+ issuer: z.enum(issuers).optional().describe("Only this issuer's cards."),
120
+ memberships: Memberships.optional().describe("Memberships the user has, such as amazon-prime. Rates that need one they don't have are listed separately."),
121
+ valuation: z
122
+ .enum(['flat', 'points-heavy'])
123
+ .optional()
124
+ .describe('flat (default): points at their cash-out value, about 1¢. points-heavy: Amex MR and Chase UR at 1.7¢, for people who transfer points to airlines and hotels.'),
125
+ };
126
+ const AllMemberships = Memberships.describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`);
127
+ return {
128
+ CategoryInput,
129
+ CatalogCategory,
130
+ NamedCards,
131
+ NamedMemberships,
132
+ OfferFilters,
133
+ CatalogFilters,
134
+ AllMemberships,
135
+ };
136
+ }
137
+ // The catalog filters as exploreCards and simulate take them.
138
+ export const settings = (args) => ({
139
+ filters: {
140
+ ...ALL_PERSONAL_CARDS,
141
+ kind: args.kind ?? 'any',
142
+ noAnnualFee: args.noAnnualFee ?? false,
143
+ business: args.business ?? false,
144
+ issuer: args.issuer ?? null,
145
+ },
146
+ memberships: args.memberships ?? [],
147
+ valuations: args.valuation ? VALUATION_PROFILES[args.valuation] : {},
148
+ });
@@ -42,7 +42,7 @@ const RankedCardSchema = z.object({
42
42
  reasoning: z.array(z.string()),
43
43
  warnings: z.array(z.string()),
44
44
  });
45
- export const RecommendationSchema = z.object({
45
+ const RecommendationSchema = z.object({
46
46
  decision: z
47
47
  .object({
48
48
  cardId: z.string(),