earnrate 0.2.8 → 0.2.10

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 (99) hide show
  1. package/README.md +31 -5
  2. package/data/CHANGELOG.md +145 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-business-plus.yaml +4 -0
  5. package/data/cards/amex-blue-cash-everyday.yaml +4 -0
  6. package/data/cards/amex-blue-cash-preferred.yaml +38 -3
  7. package/data/cards/amex-gold.yaml +4 -0
  8. package/data/cards/amex-platinum.yaml +4 -0
  9. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +4 -0
  10. package/data/cards/bank-of-america-atmos-rewards-business.yaml +4 -0
  11. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +24 -1
  12. package/data/cards/bank-of-america-customized-cash-rewards.yaml +4 -0
  13. package/data/cards/bilt-obsidian.yaml +4 -0
  14. package/data/cards/capital-one-kohls-card.yaml +2 -0
  15. package/data/cards/capital-one-kohls-rewards-visa.yaml +2 -0
  16. package/data/cards/capital-one-savor.yaml +4 -0
  17. package/data/cards/capital-one-venture-x.yaml +4 -0
  18. package/data/cards/capital-one-venture.yaml +4 -0
  19. package/data/cards/chase-aeroplan.yaml +5 -0
  20. package/data/cards/chase-freedom-flex.yaml +4 -0
  21. package/data/cards/chase-freedom-unlimited.yaml +4 -0
  22. package/data/cards/chase-ihg-one-rewards-business.yaml +29 -1
  23. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +37 -2
  24. package/data/cards/chase-ihg-one-rewards-premier.yaml +29 -1
  25. package/data/cards/chase-ihg-one-rewards.yaml +57 -7
  26. package/data/cards/chase-ink-business-cash.yaml +4 -0
  27. package/data/cards/chase-ink-business-preferred.yaml +4 -0
  28. package/data/cards/chase-prime-visa.yaml +4 -0
  29. package/data/cards/chase-sapphire-preferred.yaml +4 -0
  30. package/data/cards/chase-sapphire-reserve.yaml +4 -0
  31. package/data/cards/chase-southwest-performance-business.yaml +4 -0
  32. package/data/cards/chase-southwest-plus.yaml +4 -0
  33. package/data/cards/chase-southwest-premier-business.yaml +4 -0
  34. package/data/cards/chase-southwest-premier.yaml +4 -0
  35. package/data/cards/chase-southwest-priority.yaml +4 -0
  36. package/data/cards/chase-united-business.yaml +5 -0
  37. package/data/cards/chase-united-club-business.yaml +5 -0
  38. package/data/cards/chase-united-club.yaml +5 -0
  39. package/data/cards/chase-united-explorer.yaml +5 -0
  40. package/data/cards/chase-united-gateway.yaml +5 -0
  41. package/data/cards/chase-united-quest.yaml +30 -2
  42. package/data/cards/citi-aadvantage-business.yaml +4 -0
  43. package/data/cards/citi-aadvantage-executive.yaml +4 -0
  44. package/data/cards/citi-aadvantage-globe.yaml +4 -0
  45. package/data/cards/citi-aadvantage-mileup.yaml +4 -0
  46. package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
  47. package/data/cards/citi-costco-anywhere-visa-business.yaml +4 -0
  48. package/data/cards/citi-costco-anywhere-visa.yaml +4 -0
  49. package/data/cards/citi-custom-cash.yaml +2 -0
  50. package/data/cards/citi-double-cash.yaml +4 -0
  51. package/data/cards/citi-my-best-buy-visa.yaml +4 -0
  52. package/data/cards/citi-strata-elite.yaml +4 -0
  53. package/data/cards/citi-strata-premier.yaml +4 -0
  54. package/data/cards/sofi-unlimited-2.yaml +4 -0
  55. package/data/cards/td-bank-nordstrom-credit-card.yaml +2 -0
  56. package/data/cards/td-bank-nordstrom-visa.yaml +2 -0
  57. package/data/cards/td-bank-target-circle-card.yaml +2 -0
  58. package/data/cards/usbank-cash-plus.yaml +5 -0
  59. package/data/cards/wells-fargo-active-cash.yaml +4 -0
  60. package/data/cards/wells-fargo-autograph.yaml +4 -0
  61. package/data/merchants/overrides.yaml +290 -0
  62. package/data/merchants/seed.yaml +1049 -2
  63. package/dist/cli/args.js +3 -6
  64. package/dist/cli/explore.js +10 -0
  65. package/dist/cli/import.js +18 -4
  66. package/dist/cli/run.js +3 -1
  67. package/dist/cli/wallet.js +5 -1
  68. package/dist/data/file-store.js +2 -2
  69. package/dist/data/integrity.js +17 -0
  70. package/dist/data/store.js +1 -1
  71. package/dist/data/wallet-edit.js +32 -18
  72. package/dist/engine/cheat-sheet.js +46 -2
  73. package/dist/engine/checklist.js +19 -8
  74. package/dist/engine/evaluate.js +2 -0
  75. package/dist/engine/explore.js +9 -5
  76. package/dist/engine/milestones.js +91 -0
  77. package/dist/engine/periods.js +29 -12
  78. package/dist/engine/recommend.js +68 -14
  79. package/dist/engine/record.js +39 -1
  80. package/dist/engine/rule-text.js +17 -5
  81. package/dist/engine/setup.js +13 -2
  82. package/dist/engine/simulate.js +25 -3
  83. package/dist/engine/text.js +11 -0
  84. package/dist/engine/wallet-change.js +67 -0
  85. package/dist/engine/wallet-summary.js +17 -5
  86. package/dist/format/cards.js +3 -1
  87. package/dist/format/recommendation.js +8 -2
  88. package/dist/format/wallet.js +22 -4
  89. package/dist/import/analyze.js +42 -2
  90. package/dist/mcp/inputs.js +10 -0
  91. package/dist/mcp/schemas.js +50 -2
  92. package/dist/mcp/server.js +17 -1
  93. package/dist/mcp/tools/account.js +1 -1
  94. package/dist/mcp/tools/answers.js +8 -4
  95. package/dist/mcp/tools/wallet.js +18 -10
  96. package/dist/schema/card.js +22 -1
  97. package/dist/schema/common.js +4 -0
  98. package/dist/schema/wallet.js +2 -0
  99. package/package.json +5 -3
@@ -1,4 +1,4 @@
1
- import { money, NETWORK_NAMES, shortDate } from "../engine/text.js";
1
+ import { CAP_PERIODS, money, NETWORK_NAMES, shortDate } from "../engine/text.js";
2
2
  // "To do now", above the card details; nothing when all's done.
3
3
  export function formatChecklist(items) {
4
4
  return items.length === 0
@@ -34,8 +34,11 @@ export function formatWalletSummary(summary) {
34
34
  if (c.rotationGap !== null)
35
35
  lines.push(` Rotating categories for ${c.rotationGap} aren't in earnrate yet.`);
36
36
  for (const cap of c.caps)
37
- if (cap.remaining !== null)
38
- lines.push(` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''} (cap key ${cap.key}).`);
37
+ lines.push(cap.remaining === null
38
+ ? ` Cap on ${cap.label}: ${money(cap.amount)} ${CAP_PERIODS[cap.period]}; give the card's anniversary to track it (cap key ${cap.key}).`
39
+ : ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used ${cap.period === 'cardmember_year' ? 'this cardmember year' : `for ${cap.currentPeriod ?? ''}`}${cap.until === null ? '' : `, through ${shortDate(cap.until)}`} (cap key ${cap.key}).`);
40
+ for (const m of c.milestones)
41
+ lines.push(` Spend bonus (${m.id}): ${m.text}`);
39
42
  if (c.anniversary !== null)
40
43
  lines.push(` Account anniversary: ${c.anniversary}.`);
41
44
  const b = c.bonus;
@@ -53,7 +56,7 @@ export function formatWalletSummary(summary) {
53
56
  : "Point values: earnrate's defaults.";
54
57
  return [...cards, memberships, values].join('\n');
55
58
  }
56
- export function formatBooking(amount, cardId, cap, bonus) {
59
+ export function formatBooking(amount, cardId, cap, bonus, milestones = []) {
57
60
  const messages = [];
58
61
  if (cap) {
59
62
  const left = Math.max(0, cap.capAmount - cap.after);
@@ -65,5 +68,20 @@ export function formatBooking(amount, cardId, cap, bonus) {
65
68
  ? `Welcome bonus spend met on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)}.`
66
69
  : `Welcome bonus on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
67
70
  }
71
+ // Every milestone counts the same purchases, so one line covers them.
72
+ const first = milestones[0];
73
+ if (first) {
74
+ const reached = milestones.filter((m) => m.before < m.spend && m.after >= m.spend);
75
+ const next = milestones.find((m) => m.after < m.spend);
76
+ messages.push([
77
+ `Spend on ${cardId} this year: ${money(first.after)}.`,
78
+ ...reached.map((m) => `That reaches its ${money(m.spend)} spend bonus.`),
79
+ ...(next
80
+ ? [
81
+ `${money(next.spend - next.after)} to go for the ${money(next.spend)} spend bonus.`,
82
+ ]
83
+ : []),
84
+ ].join(' '));
85
+ }
68
86
  return messages.join(' ');
69
87
  }
@@ -1,6 +1,7 @@
1
1
  import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
2
2
  import { HALF_CENT } from "../engine/compare.js";
3
3
  import { capByKey, categoryMatches } from "../engine/evaluate.js";
4
+ import { milestoneProgress } from "../engine/milestones.js";
4
5
  import { periodKey } from "../engine/periods.js";
5
6
  import { recommend } from "../engine/recommend.js";
6
7
  import { applyBooking, capBooking } from "../engine/record.js";
@@ -41,8 +42,14 @@ function capPeriodKey(catalog, wallet, booking, date) {
41
42
  // up partway through the year is accounted for on both sides.
42
43
  export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorded = () => false) {
43
44
  const sorted = [...entries].sort((a, b) => a.line.date.localeCompare(b.line.date));
44
- let actual = wallet;
45
- let simulated = wallet;
45
+ // The earned-versus-best replay leaves spend milestones out: they pay once
46
+ // on the card's total, whichever card is best for each purchase.
47
+ const replay = {
48
+ ...wallet,
49
+ cards: wallet.cards.map(({ milestone_usage: _usage, ...c }) => c),
50
+ };
51
+ let actual = replay;
52
+ let simulated = replay;
46
53
  let current = wallet;
47
54
  const byCard = new Map();
48
55
  const missed = new Map();
@@ -69,8 +76,40 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
69
76
  entries: [],
70
77
  });
71
78
  }
79
+ const milestones = [];
80
+ for (const held of wallet.cards) {
81
+ const card = catalog.cards.find((c) => c.id === held.id);
82
+ if (!card)
83
+ continue;
84
+ for (const p of milestoneProgress(card, held, today))
85
+ if (p.period !== null)
86
+ milestones.push({
87
+ cardId: held.id,
88
+ milestoneId: p.milestone.id,
89
+ period: p.period,
90
+ spend: p.milestone.spend,
91
+ before: p.spent,
92
+ after: p.spent,
93
+ entries: [],
94
+ });
95
+ }
72
96
  for (const entry of sorted) {
73
97
  const { cardId, line } = entry;
98
+ if (!alreadyRecorded(entry, 'milestone')) {
99
+ const held = wallet.cards.find((c) => c.id === cardId);
100
+ for (const m of milestones) {
101
+ if (m.cardId !== cardId)
102
+ continue;
103
+ const card = catalog.cards.find((c) => c.id === cardId);
104
+ const milestone = card?.milestones.find((x) => x.id === m.milestoneId);
105
+ if (!milestone ||
106
+ periodKey(milestone.period, line.date, held?.anniversary ?? null) !==
107
+ m.period)
108
+ continue;
109
+ m.after = roundCents(Math.max(0, m.after + line.amount));
110
+ m.entries.push(entry);
111
+ }
112
+ }
74
113
  const progress = bonuses.get(cardId);
75
114
  if (progress &&
76
115
  countsTowardBonus(progress, line.date) &&
@@ -202,5 +241,6 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
202
241
  outsideCurrentPeriod,
203
242
  alreadyImported,
204
243
  bonuses: [...bonuses.values()].filter((b) => b.entries.length > 0),
244
+ milestones: milestones.filter((m) => m.entries.length > 0),
205
245
  };
206
246
  }
@@ -3,6 +3,7 @@ import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
3
3
  import { profileText, VALUATION_PROFILES } from "../engine/profiles.js";
4
4
  import { knownMemberships } from "../engine/setup.js";
5
5
  import { IsoDate } from "../schema/common.js";
6
+ import { COUNTRY_CODE } from "../schema/common.js";
6
7
  export const Merchant = z
7
8
  .string()
8
9
  .min(1)
@@ -12,6 +13,11 @@ export const ChannelInput = z
12
13
  .optional()
13
14
  .describe('How the purchase is made. Omit if unknown; the answer says what it assumed.');
14
15
  export const DateInput = IsoDate.optional().describe('Purchase date, YYYY-MM-DD. Defaults to today.');
16
+ export const CountryInput = z
17
+ .string()
18
+ .regex(COUNTRY_CODE, 'Use a two-letter country code, like US or FR.')
19
+ .optional()
20
+ .describe('Where the purchase is made, as a two-letter country code (FR, GB, JP). Pass it when the user is paying abroad or in another currency: abroad, cards are ranked after their foreign transaction fees. Default US.');
15
21
  export const CardId = z
16
22
  .string()
17
23
  .min(1)
@@ -66,6 +72,10 @@ const cardFields = {
66
72
  .record(z.string(), z.number().nonnegative())
67
73
  .optional()
68
74
  .describe('Dollars already spent this period on a capped bonus, for a card the user starts tracking partway through: cap key (as list_wallet shows it) → dollars, e.g. {"five-percent-quarterly": 400}. Ask; recorded purchases add to it from then on.'),
75
+ milestoneSpent: z
76
+ .record(z.string(), z.number().nonnegative())
77
+ .optional()
78
+ .describe('Dollars already spent on the card this year, for its spend milestones (bonuses after a yearly spending total, as search_cards lists them): milestone id → dollars, e.g. {"spend-15k": 9000}. Every purchase on the card counts, so give each milestone the same total. Ask; recorded purchases add to it from then on.'),
69
79
  bonus: z
70
80
  .object({
71
81
  opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { CapPeriod } from "../schema/card.js";
2
+ import { CapPeriod, MilestoneSchema } from "../schema/card.js";
3
+ const MilestonePeriod = MilestoneSchema.shape.period;
3
4
  import { CardNetwork, Network } from "../schema/common.js";
4
5
  // Mirrors the engine's result types. The SDK validates every structured
5
6
  // result against these, so a drift between the two fails loudly in tests.
@@ -38,6 +39,10 @@ const RankedCardSchema = z.object({
38
39
  centsPerUnit: z.number(),
39
40
  effectiveReturnPct: z.number(),
40
41
  earned: z.number().nullable(),
42
+ foreignFee: z.number().nullable(),
43
+ milestoneBonus: z
44
+ .object({ points: z.number(), dollars: z.number() })
45
+ .nullable(),
41
46
  capped: z.boolean(),
42
47
  reasoning: z.array(z.string()),
43
48
  warnings: z.array(z.string()),
@@ -88,7 +93,14 @@ export const WalletSummarySchema = z.object({
88
93
  started: z.string().nullable(),
89
94
  checklist: z
90
95
  .array(z.object({
91
- kind: z.enum(['activate', 'pick', 'cap', 'bonus', 'rotation']),
96
+ kind: z.enum([
97
+ 'activate',
98
+ 'pick',
99
+ 'cap',
100
+ 'bonus',
101
+ 'milestone',
102
+ 'rotation',
103
+ ]),
92
104
  cardId: z.string(),
93
105
  text: z.string(),
94
106
  due: z.string().nullable(),
@@ -128,9 +140,22 @@ export const WalletSummarySchema = z.object({
128
140
  amount: z.number(),
129
141
  period: CapPeriod,
130
142
  currentPeriod: z.string().nullable(),
143
+ until: z.string().nullable(),
131
144
  spent: z.number(),
132
145
  remaining: z.number().nullable(),
133
146
  })),
147
+ milestones: z.array(z.object({
148
+ id: z.string(),
149
+ spend: z.number(),
150
+ period: MilestonePeriod,
151
+ currentPeriod: z.string().nullable(),
152
+ until: z.string().nullable(),
153
+ spent: z.number(),
154
+ tracked: z.boolean(),
155
+ points: z.number().nullable(),
156
+ rewards: z.array(z.string()),
157
+ text: z.string(),
158
+ })),
134
159
  chosen: z
135
160
  .object({
136
161
  period: z.string(),
@@ -168,6 +193,14 @@ export const RecordResultSchema = z.object({
168
193
  deadline: z.string().nullable(),
169
194
  })
170
195
  .nullable(),
196
+ milestones: z.array(z.object({
197
+ cardId: z.string(),
198
+ milestoneId: z.string(),
199
+ period: z.string(),
200
+ spend: z.number(),
201
+ before: z.number(),
202
+ after: z.number(),
203
+ })),
171
204
  message: z.string(),
172
205
  });
173
206
  export const OfferComparisonSchema = z.object({
@@ -207,6 +240,7 @@ export const CardSearchSchema = z.object({
207
240
  network: CardNetwork,
208
241
  currency: z.string(),
209
242
  annualFee: z.number().nullable(),
243
+ foreignTransactionFee: z.number().nullable(),
210
244
  business: z.boolean(),
211
245
  onlyAt: z.array(z.string()).nullable(),
212
246
  held: z.boolean(),
@@ -228,6 +262,14 @@ export const CardSearchSchema = z.object({
228
262
  .nullable(),
229
263
  activation: z.array(z.string()),
230
264
  needsAnniversary: z.boolean(),
265
+ milestones: z.array(z.object({
266
+ id: z.string(),
267
+ spend: z.number(),
268
+ period: MilestonePeriod,
269
+ points: z.number().nullable(),
270
+ rewards: z.array(z.string()),
271
+ text: z.string(),
272
+ })),
231
273
  })),
232
274
  });
233
275
  export const WalletChangeSchema = z.object({
@@ -309,6 +351,12 @@ const SimulationSchema = z.object({
309
351
  returnPct: z.number(),
310
352
  cards: z.array(z.string()),
311
353
  })),
354
+ milestones: z.array(z.object({
355
+ cardId: z.string(),
356
+ milestoneId: z.string(),
357
+ points: z.number().nullable(),
358
+ value: z.number(),
359
+ })),
312
360
  notes: z.array(z.string()),
313
361
  });
314
362
  export const SpendingSimulationSchema = z.object({
@@ -45,7 +45,23 @@ The wallet lists cards by product id, never card numbers; never ask for card num
45
45
 
46
46
  ${WHICH_WALLET[savedWallets]} The earnrate extension on the user's computer and the hosted earnrate connector keep separate wallets with the same tools. If both are connected, say which wallet an answer comes from, and ask the user which one to use before saving or recording anything.`;
47
47
  export function createEarnrateServer(deps) {
48
- const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: instructions(deps.savedWallets) });
48
+ const server = new McpServer({
49
+ name: MCP_SERVER_NAME,
50
+ version: deps.version,
51
+ websiteUrl: 'https://earnrate.dev',
52
+ icons: [
53
+ {
54
+ src: 'https://earnrate.dev/icon-192.png',
55
+ mimeType: 'image/png',
56
+ sizes: ['192x192'],
57
+ },
58
+ {
59
+ src: 'https://earnrate.dev/favicon.svg',
60
+ mimeType: 'image/svg+xml',
61
+ sizes: ['any'],
62
+ },
63
+ ],
64
+ }, { instructions: instructions(deps.savedWallets) });
49
65
  const ctx = makeContext(deps);
50
66
  const inputs = makeInputs(deps.catalog);
51
67
  registerAnswerTools(server, deps, ctx, inputs);
@@ -20,7 +20,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
20
20
  ];
21
21
  server.registerTool('export_wallet', {
22
22
  title: 'Export the wallet',
23
- description: 'The saved wallet as a wallet.yaml file the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, point values, cap usage and bonus progress. Never card numbers.',
23
+ description: 'The saved wallet as a wallet.yaml file the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, point values, cap usage, bonus progress and yearly spending toward spend bonuses. Never card numbers.',
24
24
  inputSchema: z.object({}),
25
25
  outputSchema: z.object({ yaml: z.string() }),
26
26
  annotations: {
@@ -15,7 +15,7 @@ import { formatOffers } from "../../format/offers.js";
15
15
  import { formatRecommendation } from "../../format/recommendation.js";
16
16
  import { resolvePlace, withCallerCategory } from "../../merchants/resolve.js";
17
17
  import { ALL_CARDS_NOTE, fail } from "../context.js";
18
- import { ChannelInput, DateInput, Merchant, settings, } from "../inputs.js";
18
+ import { ChannelInput, CountryInput, DateInput, Merchant, settings, } from "../inputs.js";
19
19
  import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, OfferComparisonSchema, PurchaseAnswerSchema, SpendingSimulationSchema, } from "../schemas.js";
20
20
  const SOURCES = {
21
21
  override: "from earnrate's merchant list",
@@ -34,7 +34,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
34
34
  const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, } = inputs;
35
35
  server.registerTool('best_card_for_purchase', {
36
36
  title: 'Best card for a purchase',
37
- description: "Which card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. For a business earnrate may not know, like a local restaurant, pass `category` when you can tell what kind of business it is. Never needs or returns card numbers.",
37
+ description: "Which credit card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. For a business earnrate may not know, like a local restaurant, pass `category` when you can tell what kind of business it is. When the user is paying outside the U.S. or in another currency, pass `country`: abroad, each card is ranked after its foreign transaction fee. Never needs or returns card numbers.",
38
38
  inputSchema: z.object({
39
39
  merchant: Merchant,
40
40
  amount: z
@@ -44,6 +44,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
44
44
  .describe('Purchase amount in US dollars. Improves cap math.'),
45
45
  channel: ChannelInput,
46
46
  date: DateInput,
47
+ country: CountryInput,
47
48
  category: CategoryInput,
48
49
  cards: NamedCards,
49
50
  memberships: NamedMemberships,
@@ -55,7 +56,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
55
56
  readOnlyHint: true,
56
57
  openWorldHint: deps.llm !== null,
57
58
  },
58
- }, async ({ merchant, amount, channel, date, category, cards, memberships, valuation, }) => {
59
+ }, async ({ merchant, amount, channel, date, country, category, cards, memberships, valuation, }) => {
59
60
  const held = await ctx.cardsFor(cards, memberships);
60
61
  if (typeof held === 'string')
61
62
  return fail(held);
@@ -67,7 +68,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
67
68
  amount: amount ?? null,
68
69
  channel: channel ?? null,
69
70
  date: date ?? deps.today(),
70
- country: 'US',
71
+ country: (country ?? 'US').toUpperCase(),
71
72
  };
72
73
  const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
73
74
  const retry = resolved.merchant.source === 'none'
@@ -80,6 +81,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
80
81
  amount: purchase.amount,
81
82
  channel: purchase.channel,
82
83
  date: purchase.date,
84
+ country: purchase.country,
83
85
  }, settings({ memberships, valuation }, await ctx.saved()), resolved.merchant);
84
86
  const allCards = { ...explored, ranked: explored.ranked.slice(0, TOP) };
85
87
  const empty = recommend(catalog, EMPTY_WALLET, purchase, resolved.merchant);
@@ -210,6 +212,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
210
212
  .optional()
211
213
  .describe('Purchase amount in US dollars, if known.'),
212
214
  channel: ChannelInput,
215
+ country: CountryInput,
213
216
  ...CatalogFilters,
214
217
  }),
215
218
  outputSchema: ExplorationSchema,
@@ -228,6 +231,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
228
231
  amount: args.amount ?? null,
229
232
  channel,
230
233
  date: deps.today(),
234
+ country: (args.country ?? 'US').toUpperCase(),
231
235
  }, settings(args, await ctx.saved()), args.merchant === undefined
232
236
  ? undefined
233
237
  : withCallerCategory(resolvePlace(catalog, args.merchant, channel), args.category ?? null));
@@ -4,6 +4,7 @@ import { choiceName, currencyLabel, listed, money, shortDate, words, } from "../
4
4
  import { membershipName } from "../../engine/memberships.js";
5
5
  import { profileText, VALUATION_PROFILES } from "../../engine/profiles.js";
6
6
  import { optionName } from "../../engine/rule-text.js";
7
+ import { currencyChanges } from "../../engine/wallet-change.js";
7
8
  import { walletSummary } from "../../engine/wallet-summary.js";
8
9
  import { checklist } from "../../engine/checklist.js";
9
10
  import { cardText } from "../../format/card-text.js";
@@ -44,6 +45,8 @@ function changes(catalog, args) {
44
45
  : `activated: ${args.activated.join(', ')}`);
45
46
  for (const [key, spent] of Object.entries(args.capSpent ?? {}))
46
47
  out.push(`${money(spent)} already spent on ${key} this period`);
48
+ for (const [id, spent] of Object.entries(args.milestoneSpent ?? {}))
49
+ out.push(`${money(spent)} spent toward ${id} this year`);
47
50
  if (args.bonus === null)
48
51
  out.push('welcome bonus no longer tracked');
49
52
  else if (args.bonus !== undefined)
@@ -55,7 +58,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
55
58
  const { catalog, store } = deps;
56
59
  server.registerTool('list_wallet', {
57
60
  title: 'List wallet',
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}`,
61
+ 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, yearly spend bonuses at least 75% of the way); 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, progress toward yearly spend bonuses, 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}`,
59
62
  outputSchema: WalletSummarySchema,
60
63
  annotations: {
61
64
  title: 'List wallet',
@@ -88,7 +91,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
88
91
  });
89
92
  server.registerTool('record_purchase', {
90
93
  title: 'Record a purchase',
91
- description: `Records a purchase the user has already made, so spending caps and welcome-bonus progress stay accurate for future recommendations. It updates the user's saved wallet. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap and no open welcome bonus are acknowledged and nothing is written. For a merchant earnrate doesn't recognize it asks for category first, so caps aren't missed. ${ctx.walletUse}`,
94
+ description: `Records a purchase the user has already made, so spending caps, welcome-bonus progress and yearly spend milestones stay accurate for future recommendations. It updates the user's saved wallet. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap, no open welcome bonus and no spend milestone are acknowledged and nothing is written. For a merchant earnrate doesn't recognize it asks for category first, so caps aren't missed. ${ctx.walletUse}`,
92
95
  inputSchema: z.object({
93
96
  cardId: z
94
97
  .string()
@@ -127,19 +130,21 @@ export function registerWalletTools(server, deps, ctx, inputs) {
127
130
  const result = await store.record(purchase, resolved.merchant);
128
131
  if (!result.ok)
129
132
  return fail(`Couldn't record it: ${result.error}`);
130
- const { cap: booking, bonus } = result;
131
- const output = booking || bonus
133
+ const { cap: booking, bonus, milestones } = result;
134
+ const output = booking || bonus || milestones.length > 0
132
135
  ? {
133
136
  recorded: true,
134
137
  booking,
135
138
  bonus,
136
- message: `${formatBooking(amount, cardId, booking, bonus)} Saved in ${ctx.walletName}.`,
139
+ milestones,
140
+ message: `${formatBooking(amount, cardId, booking, bonus, milestones)} Saved in ${ctx.walletName}.`,
137
141
  }
138
142
  : {
139
143
  recorded: false,
140
144
  booking: null,
141
145
  bonus: null,
142
- message: `Nothing to track: ${cardId} has no spending cap or open welcome bonus for this purchase, so the wallet wasn't changed.`,
146
+ milestones: [],
147
+ message: `Nothing to track: ${cardId} has no spending cap, open welcome bonus or spend milestone for this purchase, so the wallet wasn't changed.`,
143
148
  };
144
149
  return {
145
150
  content: [
@@ -173,7 +178,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
173
178
  });
174
179
  server.registerTool('update_card', {
175
180
  title: 'Update a card',
176
- description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, activated categories, cap spending so far (capSpent) or a welcome bonus to track. Picks replace the saved ones in each choice group you set (other groups are kept) for the current period. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+. ${ctx.walletUse}`,
181
+ description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, activated categories, cap spending so far (capSpent), spending this year toward its spend milestones (milestoneSpent) or a welcome bonus to track. Picks replace the saved ones in each choice group you set (other groups are kept) for the current period. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+. ${ctx.walletUse}`,
177
182
  inputSchema: CardChange,
178
183
  outputSchema: WalletChangeSchema,
179
184
  annotations: {
@@ -192,7 +197,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
192
197
  });
193
198
  server.registerTool('remove_card', {
194
199
  title: 'Remove a card',
195
- description: `Removes a card from the wallet, with its tracked cap usage and bonus progress. Memberships and point values stay, even when the last card goes. Only when the user says they no longer have it or don't want it considered. ${ctx.walletUse}`,
200
+ description: `Removes a card from the wallet, with its tracked cap usage, bonus progress and yearly spending. Memberships and point values stay, even when the last card goes. Only when the user says they no longer have it or don't want it considered. ${ctx.walletUse}`,
196
201
  inputSchema: z.object({ cardId: CardId }),
197
202
  outputSchema: WalletChangeSchema,
198
203
  annotations: {
@@ -226,7 +231,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
226
231
  });
227
232
  server.registerTool('set_point_values', {
228
233
  title: 'Set point values',
229
- description: `Saves what the user's points are worth to them, in cents per point, in their saved wallet; every answer then compares cards at those values unless a valuation profile is asked for. No saved cards are needed. Use it when the user says what their points are worth ("I value Chase points at 1.5¢") or that they transfer points to airlines and hotels (profile points-heavy). Values change only the currencies given; null goes back to earnrate's default. Currencies: ${catalog.currencies.map((c) => c.id).join(', ')}. ${ctx.walletUse}`,
234
+ description: `Saves what the user's points are worth to them, in cents per point, in their saved wallet; every answer then compares cards at those values unless a valuation profile is asked for. No saved cards are needed. Use it when the user says what their points are worth ("I value Chase points at 1.5¢") or that they transfer points to airlines and hotels (profile points-heavy). Values change only the currencies given; null goes back to earnrate's default. Currencies by id: ${catalog.currencies.map((c) => `${c.id} (${c.name})`).join(', ')}. ${ctx.walletUse}`,
230
235
  inputSchema: z.object({
231
236
  values: z
232
237
  .record(z.string(), z.number().nullable())
@@ -245,9 +250,12 @@ export function registerWalletTools(server, deps, ctx, inputs) {
245
250
  idempotentHint: true,
246
251
  },
247
252
  }, async ({ values, profile }) => {
253
+ const given = currencyChanges(catalog, values ?? {});
254
+ if (!given.ok)
255
+ return fail(given.error);
248
256
  const changes = {
249
257
  ...(profile ? VALUATION_PROFILES[profile] : {}),
250
- ...(values ?? {}),
258
+ ...given.changes,
251
259
  };
252
260
  if (Object.keys(changes).length === 0)
253
261
  return fail('Give values (cents per point by currency) or a profile.');
@@ -12,6 +12,20 @@ export const CapSchema = z.strictObject({
12
12
  period: CapPeriod,
13
13
  then_rate: z.number().nonnegative(),
14
14
  });
15
+ // A bonus paid once a period after a spending total, rather than per dollar
16
+ // ("10,000 bonus points after you spend $15,000 on purchases each calendar
17
+ // year"). Every purchase on the card counts. Only `points` is valued;
18
+ // `rewards` (status, night credits, free nights) is shown, never valued.
19
+ export const MilestoneSchema = z
20
+ .strictObject({
21
+ id: KebabId,
22
+ spend: z.number().positive(), // dollars of purchases in the period
23
+ period: z.enum(['calendar_year', 'cardmember_year']),
24
+ points: z.number().positive().optional(), // in the card's currency
25
+ rewards: z.array(z.string().min(1)).min(1).optional(),
26
+ note: z.string().optional(),
27
+ })
28
+ .refine((m) => m.points !== undefined || m.rewards !== undefined, 'a milestone needs points, rewards or both');
15
29
  export const SharedCapSchema = CapSchema.extend({
16
30
  id: KebabId,
17
31
  // Rotating categories (Freedom Flex): every rule on this cap covers one
@@ -47,7 +61,10 @@ export const RuleSchema = z
47
61
  exclude: ExcludeSchema.optional(),
48
62
  rate: z.number().nonnegative(), // currency units per dollar
49
63
  channel: z.enum(['any', 'online', 'in_store']).default('any'),
50
- geography: z.enum(['us_only', 'worldwide']).default('worldwide'),
64
+ // A purchase is foreign when its country isn't the U.S.
65
+ geography: z
66
+ .enum(['us_only', 'foreign_only', 'worldwide'])
67
+ .default('worldwide'),
51
68
  // Inline cap, or the id of a shared cap in the card's `caps`.
52
69
  cap: z.union([CapSchema, KebabId]).optional(),
53
70
  requires: z
@@ -148,6 +165,9 @@ export const CardSchema = z
148
165
  annual_fee: z.number().nonnegative().optional(), // dollars
149
166
  // When the first year differs, usually a waived fee.
150
167
  first_year_fee: z.number().nonnegative().optional(),
168
+ // Percent of each purchase outside the U.S.; 0 when there's none. null
169
+ // only when no issuer page states it, with a todo saying so.
170
+ foreign_transaction_fee: z.number().nonnegative().nullable(),
151
171
  welcome_offer: WelcomeOfferSchema.optional(),
152
172
  // A paid membership (data/memberships.yaml) the card can't be had
153
173
  // without, Costco's for Costco Anywhere. Its fee counts like an annual
@@ -162,6 +182,7 @@ export const CardSchema = z
162
182
  caps: z.array(SharedCapSchema).default([]),
163
183
  choices: z.array(ChoiceSchema).default([]),
164
184
  rules: z.array(RuleSchema).default([]),
185
+ milestones: z.array(MilestoneSchema).default([]),
165
186
  // A short quote from an issuer page for each rule (or `base_rate`). The
166
187
  // weekly source check fails a rule whose quote disappears from its page.
167
188
  evidence: z
@@ -23,3 +23,7 @@ export const PeriodKey = z.preprocess((v) => (typeof v === 'number' ? String(v)
23
23
  export const ValueRange = z
24
24
  .tuple([z.number().positive(), z.number().positive()])
25
25
  .refine(([low, high]) => low <= high, 'range must be [low, high]');
26
+ // A two-letter country code, like US or FR. Only the U.S. versus everywhere
27
+ // else matters to the engine.
28
+ export const COUNTRY_CODE = /^[A-Za-z]{2}$/;
29
+ export const countryProblem = (code) => `"${code}" isn't a country code. Use two letters, like US or FR.`;
@@ -37,6 +37,8 @@ export const WalletCardSchema = z.strictObject({
37
37
  })
38
38
  .optional(),
39
39
  activated: z.array(KebabId).default([]),
40
+ // Keyed by milestone id: every purchase on the card this period.
41
+ milestone_usage: z.record(KebabId, CapUsage).optional(),
40
42
  // A welcome bonus in progress. Terms default to the card's published
41
43
  // offer; set spend, by, points or dollars when yours was different.
42
44
  bonus: z
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "Which credit card earns the most at a merchant, from your cards or every card, and why",
5
5
  "keywords": [
6
6
  "credit cards",
@@ -46,13 +46,15 @@
46
46
  "remote:build": "node scripts/build-remote.ts",
47
47
  "remote:check": "yarn remote:build && WRANGLER_SEND_METRICS=false wrangler deploy --config remote/wrangler.jsonc --dry-run --outdir dist",
48
48
  "remote:deploy": "yarn remote:build && WRANGLER_SEND_METRICS=false wrangler deploy --config remote/wrangler.jsonc",
49
+ "monitor:check": "WRANGLER_SEND_METRICS=false wrangler deploy --config monitor/wrangler.jsonc --dry-run --outdir dist",
50
+ "monitor:deploy": "WRANGLER_SEND_METRICS=false wrangler deploy --config monitor/wrangler.jsonc",
49
51
  "site:build": "vite build site && node scripts/build-pages.ts",
50
52
  "site:dev": "vite site",
51
53
  "site:deploy": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy",
52
54
  "test": "vitest run",
53
55
  "test:watch": "vitest",
54
56
  "coverage": "vitest run --coverage --coverage.include=src/**",
55
- "typecheck": "tsc && tsc -p site && tsc -p remote",
57
+ "typecheck": "tsc && tsc -p site && tsc -p remote && tsc -p monitor",
56
58
  "lint": "eslint",
57
59
  "format": "prettier --write .",
58
60
  "format:check": "prettier --check .",
@@ -81,6 +83,6 @@
81
83
  "typescript-eslint": "^8.72.0",
82
84
  "vite": "^8.3.5",
83
85
  "vitest": "^5.0.5",
84
- "wrangler": "4.149.0"
86
+ "wrangler": "4.156.2"
85
87
  }
86
88
  }