earnrate 0.2.8 → 0.2.9

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 (97) hide show
  1. package/README.md +31 -5
  2. package/data/CHANGELOG.md +133 -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 +36 -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 +29 -1
  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 +284 -0
  62. package/data/merchants/seed.yaml +1055 -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 +1 -1
  76. package/dist/engine/milestones.js +83 -0
  77. package/dist/engine/periods.js +29 -12
  78. package/dist/engine/recommend.js +39 -9
  79. package/dist/engine/record.js +39 -1
  80. package/dist/engine/rule-text.js +5 -1
  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/wallet.js +18 -4
  88. package/dist/import/analyze.js +42 -2
  89. package/dist/mcp/inputs.js +10 -0
  90. package/dist/mcp/schemas.js +47 -2
  91. package/dist/mcp/tools/account.js +1 -1
  92. package/dist/mcp/tools/answers.js +8 -4
  93. package/dist/mcp/tools/wallet.js +18 -10
  94. package/dist/schema/card.js +22 -1
  95. package/dist/schema/common.js +4 -0
  96. package/dist/schema/wallet.js +2 -0
  97. package/package.json +5 -3
@@ -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,7 @@ 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(),
41
43
  capped: z.boolean(),
42
44
  reasoning: z.array(z.string()),
43
45
  warnings: z.array(z.string()),
@@ -88,7 +90,14 @@ export const WalletSummarySchema = z.object({
88
90
  started: z.string().nullable(),
89
91
  checklist: z
90
92
  .array(z.object({
91
- kind: z.enum(['activate', 'pick', 'cap', 'bonus', 'rotation']),
93
+ kind: z.enum([
94
+ 'activate',
95
+ 'pick',
96
+ 'cap',
97
+ 'bonus',
98
+ 'milestone',
99
+ 'rotation',
100
+ ]),
92
101
  cardId: z.string(),
93
102
  text: z.string(),
94
103
  due: z.string().nullable(),
@@ -128,9 +137,22 @@ export const WalletSummarySchema = z.object({
128
137
  amount: z.number(),
129
138
  period: CapPeriod,
130
139
  currentPeriod: z.string().nullable(),
140
+ until: z.string().nullable(),
131
141
  spent: z.number(),
132
142
  remaining: z.number().nullable(),
133
143
  })),
144
+ milestones: z.array(z.object({
145
+ id: z.string(),
146
+ spend: z.number(),
147
+ period: MilestonePeriod,
148
+ currentPeriod: z.string().nullable(),
149
+ until: z.string().nullable(),
150
+ spent: z.number(),
151
+ tracked: z.boolean(),
152
+ points: z.number().nullable(),
153
+ rewards: z.array(z.string()),
154
+ text: z.string(),
155
+ })),
134
156
  chosen: z
135
157
  .object({
136
158
  period: z.string(),
@@ -168,6 +190,14 @@ export const RecordResultSchema = z.object({
168
190
  deadline: z.string().nullable(),
169
191
  })
170
192
  .nullable(),
193
+ milestones: z.array(z.object({
194
+ cardId: z.string(),
195
+ milestoneId: z.string(),
196
+ period: z.string(),
197
+ spend: z.number(),
198
+ before: z.number(),
199
+ after: z.number(),
200
+ })),
171
201
  message: z.string(),
172
202
  });
173
203
  export const OfferComparisonSchema = z.object({
@@ -207,6 +237,7 @@ export const CardSearchSchema = z.object({
207
237
  network: CardNetwork,
208
238
  currency: z.string(),
209
239
  annualFee: z.number().nullable(),
240
+ foreignTransactionFee: z.number().nullable(),
210
241
  business: z.boolean(),
211
242
  onlyAt: z.array(z.string()).nullable(),
212
243
  held: z.boolean(),
@@ -228,6 +259,14 @@ export const CardSearchSchema = z.object({
228
259
  .nullable(),
229
260
  activation: z.array(z.string()),
230
261
  needsAnniversary: z.boolean(),
262
+ milestones: z.array(z.object({
263
+ id: z.string(),
264
+ spend: z.number(),
265
+ period: MilestonePeriod,
266
+ points: z.number().nullable(),
267
+ rewards: z.array(z.string()),
268
+ text: z.string(),
269
+ })),
231
270
  })),
232
271
  });
233
272
  export const WalletChangeSchema = z.object({
@@ -309,6 +348,12 @@ const SimulationSchema = z.object({
309
348
  returnPct: z.number(),
310
349
  cards: z.array(z.string()),
311
350
  })),
351
+ milestones: z.array(z.object({
352
+ cardId: z.string(),
353
+ milestoneId: z.string(),
354
+ points: z.number().nullable(),
355
+ value: z.number(),
356
+ })),
312
357
  notes: z.array(z.string()),
313
358
  });
314
359
  export const SpendingSimulationSchema = z.object({
@@ -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.9",
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
  }