earnrate 0.2.12 → 0.2.14

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 +20 -7
  2. package/data/CHANGELOG.md +197 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-cash-everyday.yaml +28 -2
  5. package/data/cards/amex-blue-cash-preferred.yaml +19 -0
  6. package/data/cards/amex-business-gold.yaml +38 -3
  7. package/data/cards/amex-business-platinum.yaml +72 -3
  8. package/data/cards/amex-delta-skymiles-gold.yaml +34 -9
  9. package/data/cards/amex-delta-skymiles-platinum.yaml +40 -9
  10. package/data/cards/amex-delta-skymiles-reserve.yaml +42 -10
  11. package/data/cards/amex-gold.yaml +52 -0
  12. package/data/cards/amex-hilton-honors-aspire.yaml +23 -1
  13. package/data/cards/amex-hilton-honors-business.yaml +14 -1
  14. package/data/cards/amex-hilton-honors-surpass.yaml +14 -1
  15. package/data/cards/amex-marriott-bonvoy-brilliant.yaml +15 -1
  16. package/data/cards/amex-marriott-bonvoy-business.yaml +7 -8
  17. package/data/cards/amex-platinum.yaml +110 -0
  18. package/data/cards/apple-card.yaml +76 -0
  19. package/data/cards/bilt-obsidian.yaml +12 -2
  20. package/data/cards/bilt-palladium.yaml +12 -2
  21. package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
  22. package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -0
  23. package/data/cards/capital-one-venture-x-business.yaml +28 -1
  24. package/data/cards/capital-one-venture-x.yaml +30 -2
  25. package/data/cards/chase-aeroplan.yaml +14 -0
  26. package/data/cards/chase-ihg-one-rewards-business.yaml +27 -1
  27. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +16 -0
  28. package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -0
  29. package/data/cards/chase-ink-business-cash.yaml +13 -0
  30. package/data/cards/chase-ink-business-unlimited.yaml +13 -2
  31. package/data/cards/chase-sapphire-preferred.yaml +11 -0
  32. package/data/cards/chase-sapphire-reserve.yaml +44 -4
  33. package/data/cards/chase-united-business.yaml +32 -0
  34. package/data/cards/chase-united-club-business.yaml +40 -0
  35. package/data/cards/chase-united-club.yaml +40 -0
  36. package/data/cards/chase-united-explorer.yaml +32 -0
  37. package/data/cards/chase-united-quest.yaml +40 -0
  38. package/data/cards/citi-aadvantage-executive.yaml +26 -1
  39. package/data/cards/citi-strata-elite.yaml +20 -1
  40. package/data/cards/discover-it-cash-back.yaml +8 -5
  41. package/data/cards/fidelity-rewards-visa-signature.yaml +60 -0
  42. package/data/cards/navy-federal-cash-rewards-plus.yaml +61 -0
  43. package/data/cards/navy-federal-cash-rewards.yaml +60 -0
  44. package/data/cards/navy-federal-more-rewards-amex.yaml +92 -0
  45. package/data/cards/paypal-cashback-mastercard.yaml +13 -2
  46. package/data/cards/penfed-power-cash-rewards.yaml +47 -0
  47. package/data/cards/usbank-altitude-connect.yaml +8 -2
  48. package/data/cards/usbank-altitude-go.yaml +8 -2
  49. package/data/cards/usbank-smartly.yaml +107 -0
  50. package/data/cards/venmo-credit-card.yaml +151 -0
  51. package/data/cards/verizon-visa-card.yaml +122 -0
  52. package/data/currencies.yaml +14 -0
  53. package/data/issuers.yaml +22 -0
  54. package/data/memberships.yaml +35 -0
  55. package/data/merchants/overrides.yaml +448 -0
  56. package/data/merchants/seed.yaml +1085 -66
  57. package/dist/cli/args.js +18 -0
  58. package/dist/cli/explore.js +10 -1
  59. package/dist/cli/run.js +4 -1
  60. package/dist/cli/wallet.js +31 -4
  61. package/dist/data/file-store.js +5 -2
  62. package/dist/data/integrity.js +22 -0
  63. package/dist/data/store.js +4 -1
  64. package/dist/data/wallet-edit.js +25 -6
  65. package/dist/engine/cheat-sheet.js +20 -7
  66. package/dist/engine/checklist.js +35 -11
  67. package/dist/engine/credits.js +65 -0
  68. package/dist/engine/evaluate.js +38 -11
  69. package/dist/engine/explore.js +72 -9
  70. package/dist/engine/offers.js +2 -1
  71. package/dist/engine/periods.js +10 -3
  72. package/dist/engine/recommend.js +101 -38
  73. package/dist/engine/record.js +42 -1
  74. package/dist/engine/rotation.js +4 -0
  75. package/dist/engine/rule-text.js +35 -7
  76. package/dist/engine/setup.js +17 -6
  77. package/dist/engine/simulate.js +13 -4
  78. package/dist/engine/text.js +89 -1
  79. package/dist/engine/wallet-change.js +37 -1
  80. package/dist/engine/wallet-summary.js +32 -4
  81. package/dist/format/card-text.js +4 -3
  82. package/dist/format/cards.js +6 -2
  83. package/dist/format/cheat-sheet.js +2 -0
  84. package/dist/format/explore.js +20 -4
  85. package/dist/format/recommendation.js +6 -2
  86. package/dist/format/wallet-yaml.js +11 -15
  87. package/dist/format/wallet.js +15 -7
  88. package/dist/import/analyze.js +7 -2
  89. package/dist/mcp/inputs.js +10 -0
  90. package/dist/mcp/prompts.js +9 -1
  91. package/dist/mcp/schemas.js +71 -1
  92. package/dist/mcp/tools/answers.js +11 -5
  93. package/dist/mcp/tools/wallet.js +15 -7
  94. package/dist/merchants/resolve.js +8 -0
  95. package/dist/schema/card.js +37 -3
  96. package/dist/schema/common.js +1 -1
  97. package/dist/schema/merchant.js +6 -0
  98. package/dist/schema/wallet.js +8 -0
  99. package/package.json +1 -1
@@ -1,9 +1,18 @@
1
1
  import { Document, parse, visit } from 'yaml';
2
2
  import { walletProblems } from "../data/integrity.js";
3
3
  import { CARD_NUMBER_LABEL, CARD_NUMBER_REDEMPTION, } from "../schema/card-number.js";
4
- import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
4
+ import { WALLET_VERSION, WalletCardSchema, WalletSchema, } from "../schema/wallet.js";
5
5
  // A wallet as the YAML file the CLI reads, for moving it between the
6
6
  // website, the remote server and the command line. Node-free.
7
+ // Every field the schema defines, in its order, so a new wallet field is
8
+ // exported without anyone remembering to add it here.
9
+ const CARD_FIELDS = Object.keys(WalletCardSchema.shape);
10
+ const isEmpty = (value) => value === undefined ||
11
+ (Array.isArray(value)
12
+ ? value.length === 0
13
+ : typeof value === 'object' &&
14
+ value !== null &&
15
+ Object.keys(value).length === 0);
7
16
  function compact(wallet) {
8
17
  return {
9
18
  version: wallet.version ?? WALLET_VERSION,
@@ -13,20 +22,7 @@ function compact(wallet) {
13
22
  ...(wallet.memberships.length > 0
14
23
  ? { memberships: wallet.memberships }
15
24
  : {}),
16
- cards: wallet.cards.map((c) => ({
17
- id: c.id,
18
- ...(c.checkout_label === undefined
19
- ? {}
20
- : { checkout_label: c.checkout_label }),
21
- ...(c.anniversary === undefined ? {} : { anniversary: c.anniversary }),
22
- ...(c.opened === undefined ? {} : { opened: c.opened }),
23
- ...(Object.keys(c.cap_usage).length > 0
24
- ? { cap_usage: c.cap_usage }
25
- : {}),
26
- ...(c.chosen === undefined ? {} : { chosen: c.chosen }),
27
- ...(c.activated.length > 0 ? { activated: c.activated } : {}),
28
- ...(c.bonus === undefined ? {} : { bonus: c.bonus }),
29
- })),
25
+ cards: wallet.cards.map((c) => Object.fromEntries(CARD_FIELDS.flatMap((k) => (isEmpty(c[k]) ? [] : [[k, c[k]]])))),
30
26
  };
31
27
  }
32
28
  // Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare 2026-10-15 as a
@@ -1,4 +1,4 @@
1
- import { CAP_PERIODS, introAdjective, introSince, money, NETWORK_NAMES, periodPhrase, shortDate, } from "../engine/text.js";
1
+ import { CAP_PERIODS, introAdjective, introSince, money, NETWORK_NAMES, periodPhrase, roundCents, shortDate, possessive, } 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
@@ -39,6 +39,8 @@ export function formatWalletSummary(summary) {
39
39
  : ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used ${periodPhrase(cap.period, cap.currentPeriod ?? '')}${cap.until === null ? '' : `, through ${shortDate(cap.until)}`} (cap key ${cap.key}).`);
40
40
  for (const m of c.milestones)
41
41
  lines.push(` Spend bonus (${m.id}): ${m.text}`);
42
+ for (const credit of c.credits)
43
+ lines.push(` Credit (${credit.id}): ${credit.text}`);
42
44
  if (c.anniversary !== null)
43
45
  lines.push(` Account anniversary: ${c.anniversary}.`);
44
46
  if (c.intro?.state === 'active' && c.intro.until !== null)
@@ -62,27 +64,33 @@ export function formatWalletSummary(summary) {
62
64
  : "Point values: earnrate's defaults.";
63
65
  return [...cards, memberships, values].join('\n');
64
66
  }
65
- export function formatBooking(amount, cardId, cap, bonus, milestones = []) {
67
+ export function formatBooking(amount, name, // the card's short name
68
+ cap, bonus, milestones = [], credit = null) {
66
69
  const messages = [];
70
+ if (credit)
71
+ messages.push(`Used ${money(roundCents(credit.after - credit.before))} of ${possessive(name)} ${credit.name} credit: ${money(credit.after)} of ${money(credit.amount)} ${periodPhrase(credit.periodKind, credit.period)}, ${money(roundCents(Math.max(0, credit.amount - credit.after)))} left.`);
67
72
  if (cap) {
68
73
  const left = Math.max(0, cap.capAmount - cap.after);
69
- messages.push(`Recorded ${money(amount)} on ${cardId} against "${cap.capKey}": ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} ${periodPhrase(cap.periodKind, cap.period)}, ${money(left)} left.`);
74
+ messages.push(`Recorded ${money(amount)} on ${name} against "${cap.capKey}": ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} ${periodPhrase(cap.periodKind, cap.period)}, ${money(left)} left.`);
70
75
  }
71
76
  if (bonus) {
72
77
  const left = Math.max(0, bonus.spend - bonus.after);
73
78
  messages.push(left === 0
74
- ? `Welcome bonus spend met on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)}.`
75
- : `Welcome bonus on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
79
+ ? `Welcome bonus spend met on ${name}: ${money(bonus.after)} of ${money(bonus.spend)}.`
80
+ : `Welcome bonus on ${name}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
76
81
  }
77
82
  // Every milestone counts the same purchases, so one line covers them.
78
83
  const first = milestones[0];
79
84
  if (first) {
80
85
  const reached = milestones.filter((m) => m.before < m.spend && m.after >= m.spend);
81
86
  const next = milestones.find((m) => m.after < m.spend);
87
+ const period = first.periodKind === 'cardmember_year' ? 'cardmember year' : 'year';
82
88
  messages.push([
83
- `Spend on ${cardId} this ${first.periodKind === 'cardmember_year' ? 'cardmember year' : 'year'}: ${money(first.after)}.`,
89
+ first.started
90
+ ? `Started tracking spend on ${name} this ${period} with this purchase: ${money(first.after)}. Spending on it earlier this ${period} isn't counted; give the total so far (milestoneSpent) to include it.`
91
+ : `Spend on ${name} this ${period}: ${money(first.after)}.`,
84
92
  ...reached.map((m) => `That reaches its ${money(m.spend)} spend bonus.`),
85
- ...(next
93
+ ...(next && !first.started
86
94
  ? [
87
95
  `${money(next.spend - next.after)} to go for the ${money(next.spend)} spend bonus.`,
88
96
  ]
@@ -182,8 +182,13 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
182
182
  date: line.date,
183
183
  country: 'US',
184
184
  };
185
- const used = recommend(catalog, actual, purchase, merchant).ranked.find((r) => r.cardId === cardId);
186
- const top = recommend(catalog, simulated, purchase, merchant).ranked[0];
185
+ // Past statements don't say which credits were used, so they're left out.
186
+ const used = recommend(catalog, actual, purchase, merchant, {
187
+ credits: false,
188
+ }).ranked.find((r) => r.cardId === cardId);
189
+ const top = recommend(catalog, simulated, purchase, merchant, {
190
+ credits: false,
191
+ }).ranked[0];
187
192
  if (!used || !top)
188
193
  continue;
189
194
  earned += used.earned ?? 0;
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  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
+ import { PaymentMethod } from "../schema/card.js";
5
6
  import { IsoDate } from "../schema/common.js";
6
7
  import { COUNTRY_CODE } from "../schema/common.js";
7
8
  export const Merchant = z
@@ -18,6 +19,7 @@ export const CountryInput = z
18
19
  .regex(COUNTRY_CODE, 'Use a two-letter country code, like US or FR.')
19
20
  .optional()
20
21
  .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.');
22
+ export const PaymentInput = PaymentMethod.optional().describe("How the purchase is paid, only when the user says: card (the card itself or its number), apple-pay, google-pay or paypal (PayPal checkout). Optional; omit it when unknown, and never assume Apple Pay. Some rates need one (Apple Card's 2% with Apple Pay, PayPal Cashback's 3% through PayPal checkout), and the answer's assumptions say when paying another way would change the pick.");
21
23
  export const CardId = z
22
24
  .string()
23
25
  .min(1)
@@ -79,6 +81,14 @@ const cardFields = {
79
81
  .record(z.string(), z.number().nonnegative())
80
82
  .optional()
81
83
  .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.'),
84
+ enrolled: z
85
+ .array(z.string())
86
+ .optional()
87
+ .describe('Merchant credit ids the user has enrolled in with the issuer (search_cards and list_wallet list each card\'s credits, e.g. "lululemon" on Amex Platinum). A credit that needs enrolling counts in answers only once it\'s here. Replaces the current list. Ask; never assume.'),
88
+ creditUsed: z
89
+ .record(z.string(), z.number().nonnegative())
90
+ .optional()
91
+ .describe('Dollars of a merchant credit already received this period: credit id → dollars, e.g. {"lululemon": 40}. Ask; recorded purchases add to it from then on.'),
82
92
  bonus: z
83
93
  .object({
84
94
  opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
@@ -33,10 +33,15 @@ export function registerPrompts(server, deps, ctx) {
33
33
  .string()
34
34
  .optional()
35
35
  .describe('Where you are paying, if not the U.S.: FR, Japan'),
36
+ paying: z
37
+ .string()
38
+ .optional()
39
+ .describe('How you are paying, if you know: Apple Pay, PayPal'),
36
40
  }),
37
41
  }, (args) => {
38
42
  const amount = args.amount?.trim() || null;
39
43
  const country = args.country?.trim() || null;
44
+ const paying = args.paying?.trim() || null;
40
45
  const call = [
41
46
  `merchant ${JSON.stringify(args.store)}`,
42
47
  ...(amount === null
@@ -45,8 +50,11 @@ export function registerPrompts(server, deps, ctx) {
45
50
  ...(country === null
46
51
  ? []
47
52
  : [`country as the two-letter code for ${JSON.stringify(country)}`]),
53
+ ...(paying === null
54
+ ? []
55
+ : [`payment for paying with ${JSON.stringify(paying)}`]),
48
56
  ];
49
- return ask(`Which credit card should I use at ${args.store}${amount === null ? '' : ` for ${dollars(amount)}`}${country === null ? '' : ` in ${country}`}?`, [
57
+ return ask(`Which credit card should I use at ${args.store}${amount === null ? '' : ` for ${dollars(amount)}`}${country === null ? '' : ` in ${country}`}${paying === null ? '' : ` with ${paying}`}?`, [
50
58
  `Call earnrate's best_card_for_purchase with ${call.join(', ')}.`,
51
59
  `It answers from ${FROM_SAVED_OR_ALL}, so don't ask for their cards first.`,
52
60
  'Say which card to use and why, with the assumptions and warnings it gives.',
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { CapPeriod, MilestoneSchema } from "../schema/card.js";
2
+ import { CapPeriod, CreditPeriod, MilestoneSchema } from "../schema/card.js";
3
3
  const MilestonePeriod = MilestoneSchema.shape.period;
4
4
  import { CardNetwork, Network } from "../schema/common.js";
5
5
  // Mirrors the engine's result types. The SDK validates every structured
@@ -43,6 +43,18 @@ const RankedCardSchema = z.object({
43
43
  milestoneBonus: z
44
44
  .object({ points: z.number(), dollars: z.number() })
45
45
  .nullable(),
46
+ credit: z
47
+ .object({
48
+ id: z.string(),
49
+ name: z.string(),
50
+ amount: z.number(),
51
+ period: CreditPeriod,
52
+ enrolled: z.boolean(),
53
+ left: z.number().nullable(),
54
+ until: z.string().nullable(),
55
+ dollars: z.number().nullable(),
56
+ })
57
+ .nullable(),
46
58
  capped: z.boolean(),
47
59
  reasoning: z.array(z.string()),
48
60
  warnings: z.array(z.string()),
@@ -101,8 +113,11 @@ export const WalletSummarySchema = z.object({
101
113
  'milestone',
102
114
  'rotation',
103
115
  'intro',
116
+ 'credit',
117
+ 'enroll',
104
118
  ]),
105
119
  cardId: z.string(),
120
+ ref: z.string().optional(),
106
121
  text: z.string(),
107
122
  due: z.string().nullable(),
108
123
  }))
@@ -157,6 +172,20 @@ export const WalletSummarySchema = z.object({
157
172
  rewards: z.array(z.string()),
158
173
  text: z.string(),
159
174
  })),
175
+ credits: z.array(z.object({
176
+ id: z.string(),
177
+ name: z.string(),
178
+ amount: z.number(),
179
+ period: CreditPeriod,
180
+ enroll: z.boolean(),
181
+ enrolled: z.boolean(),
182
+ currentPeriod: z.string().nullable(),
183
+ until: z.string().nullable(),
184
+ used: z.number(),
185
+ left: z.number().nullable(),
186
+ tracked: z.boolean(),
187
+ text: z.string(),
188
+ })),
160
189
  chosen: z
161
190
  .object({
162
191
  period: z.string(),
@@ -216,7 +245,20 @@ export const RecordResultSchema = z.object({
216
245
  spend: z.number(),
217
246
  before: z.number(),
218
247
  after: z.number(),
248
+ started: z.boolean(),
219
249
  })),
250
+ credit: z
251
+ .object({
252
+ cardId: z.string(),
253
+ creditId: z.string(),
254
+ name: z.string(),
255
+ period: z.string(),
256
+ periodKind: CreditPeriod,
257
+ amount: z.number(),
258
+ before: z.number(),
259
+ after: z.number(),
260
+ })
261
+ .nullable(),
220
262
  message: z.string(),
221
263
  });
222
264
  export const OfferComparisonSchema = z.object({
@@ -287,6 +329,14 @@ export const CardSearchSchema = z.object({
287
329
  text: z.string(),
288
330
  })),
289
331
  introRates: z.array(z.object({ id: z.string(), days: z.number(), text: z.string() })),
332
+ credits: z.array(z.object({
333
+ id: z.string(),
334
+ name: z.string(),
335
+ amount: z.number(),
336
+ period: CreditPeriod,
337
+ enroll: z.boolean(),
338
+ text: z.string(),
339
+ })),
290
340
  })),
291
341
  });
292
342
  export const WalletChangeSchema = z.object({
@@ -315,6 +365,25 @@ const ExcludedCardSchema = z.object({
315
365
  const Picks = z.record(z.string(), z.array(z.string()));
316
366
  export const ExplorationSchema = z.object({
317
367
  merchant: MerchantResolutionSchema,
368
+ // Cards with a statement credit at this store, shown before the ranking.
369
+ credits: z.array(z.object({
370
+ cardId: z.string(),
371
+ cardName: z.string(),
372
+ issuer: z.string(),
373
+ network: CardNetwork,
374
+ annualFee: z.number().nullable(),
375
+ firstYearFee: z.number().nullable(),
376
+ creditId: z.string(),
377
+ name: z.string(),
378
+ amount: z.number(),
379
+ period: CreditPeriod,
380
+ enroll: z.boolean(),
381
+ note: z.string().nullable(),
382
+ back: z.string(),
383
+ earn: z.string(),
384
+ text: z.string(),
385
+ evidence: z.object({ quote: z.string(), url: z.string() }).nullable(),
386
+ })),
318
387
  ranked: z.array(RankedCardSchema.extend({
319
388
  issuer: z.string(),
320
389
  network: CardNetwork,
@@ -403,6 +472,7 @@ export const CheatSheetSchema = z.object({
403
472
  note: z.string().nullable(),
404
473
  })),
405
474
  notes: z.array(z.string()),
475
+ assumptions: z.array(z.string()),
406
476
  });
407
477
  export const PurchaseAnswerSchema = RecommendationSchema.extend({
408
478
  // Every card earnrate knows, ranked: set when no cards were given.
@@ -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, CountryInput, DateInput, Merchant, settings, } from "../inputs.js";
18
+ import { ChannelInput, CountryInput, PaymentInput, 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 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.",
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. Pass `payment` only when the user says how they're paying, like Apple Pay or PayPal checkout. A merchant statement credit (a card's `credit`, like Amex Platinum's lululemon credit) counts toward the pick only once it's enrolled in the wallet and has dollars left this period, and only when the amount is given; say its dollars apart from the % back. With no cards, `allCards.credits` lists cards with a credit at the store; present those first. Never needs or returns card numbers.",
38
38
  inputSchema: z.object({
39
39
  merchant: Merchant,
40
40
  amount: z
@@ -45,6 +45,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
45
45
  channel: ChannelInput,
46
46
  date: DateInput,
47
47
  country: CountryInput,
48
+ payment: PaymentInput,
48
49
  category: CategoryInput,
49
50
  cards: NamedCards,
50
51
  memberships: NamedMemberships,
@@ -57,7 +58,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
57
58
  destructiveHint: false,
58
59
  openWorldHint: deps.llm !== null,
59
60
  },
60
- }, async ({ merchant, amount, channel, date, country, category, cards, memberships, valuation, }) => {
61
+ }, async ({ merchant, amount, channel, date, country, payment, category, cards, memberships, valuation, }) => {
61
62
  const held = await ctx.cardsFor(cards, memberships);
62
63
  if (typeof held === 'string')
63
64
  return fail(held);
@@ -70,6 +71,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
70
71
  channel: channel ?? null,
71
72
  date: date ?? deps.today(),
72
73
  country: (country ?? 'US').toUpperCase(),
74
+ payment: payment ?? null,
73
75
  };
74
76
  const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
75
77
  const retry = resolved.merchant.source === 'none'
@@ -83,6 +85,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
83
85
  channel: purchase.channel,
84
86
  date: purchase.date,
85
87
  country: purchase.country,
88
+ payment: purchase.payment,
86
89
  }, settings({ memberships, valuation }, await ctx.saved()), resolved.merchant);
87
90
  const allCards = { ...explored, ranked: explored.ranked.slice(0, TOP) };
88
91
  const empty = recommend(catalog, EMPTY_WALLET, purchase, resolved.merchant);
@@ -205,7 +208,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
205
208
  });
206
209
  server.registerTool('best_cards_for', {
207
210
  title: 'Best cards for a purchase',
208
- description: 'Ranks every card earnrate knows that takes new applicants, not only the user\'s, for one purchase or category, each set up at its best (the right category pick, activated, cap unused), with its annual fee and what earning the rate takes. Use it for questions like "what\'s the best card for groceries?" or "which card should I get for Amazon?". It needs no wallet. For which of the user\'s own cards to use, call best_card_for_purchase instead. Mention the annual fee and any conditions alongside each card.',
211
+ description: 'Ranks every card earnrate knows that takes new applicants, not only the user\'s, for one purchase or category, each set up at its best (the right category pick, activated, cap unused), with its annual fee and what earning the rate takes. Use it for questions like "what\'s the best card for groceries?" or "which card should I get for Amazon?". It needs no wallet. For which of the user\'s own cards to use, call best_card_for_purchase instead. Mention the annual fee and any conditions alongside each card. For a store, cards with a statement credit there come first in `credits` ("$75 back each quarter, then 1x Amex Membership Rewards"): present them first, in dollars and never as a percentage, saying when the cardholder must enroll, then the ranking by rate.',
209
212
  inputSchema: z.object({
210
213
  merchant: Merchant.optional(),
211
214
  category: CatalogCategory.optional(),
@@ -216,6 +219,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
216
219
  .describe('Purchase amount in US dollars, if known.'),
217
220
  channel: ChannelInput,
218
221
  country: CountryInput,
222
+ payment: PaymentInput,
219
223
  ...CatalogFilters,
220
224
  }),
221
225
  outputSchema: ExplorationSchema,
@@ -236,6 +240,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
236
240
  channel,
237
241
  date: deps.today(),
238
242
  country: (args.country ?? 'US').toUpperCase(),
243
+ payment: args.payment ?? null,
239
244
  }, settings(args, await ctx.saved()), args.merchant === undefined
240
245
  ? undefined
241
246
  : withCallerCategory(resolvePlace(catalog, args.merchant, channel), args.category ?? null));
@@ -412,7 +417,8 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
412
417
  content: [
413
418
  {
414
419
  type: 'text',
415
- text: header + result.cards.map(formatCardInfo).join('\n'),
420
+ text: header +
421
+ result.cards.map((c) => formatCardInfo(catalog, c)).join('\n'),
416
422
  },
417
423
  ],
418
424
  structuredContent: result,
@@ -51,6 +51,12 @@ function changes(catalog, args) {
51
51
  out.push(`${money(spent)} already spent on ${key} this period`);
52
52
  for (const [id, spent] of Object.entries(args.milestoneSpent ?? {}))
53
53
  out.push(`${money(spent)} spent toward ${id} this ${card?.milestones.find((m) => m.id === id)?.period === 'cardmember_year' ? 'cardmember year' : 'year'}`);
54
+ if (args.enrolled !== undefined)
55
+ out.push(args.enrolled.length === 0
56
+ ? 'no credits enrolled'
57
+ : `enrolled in: ${args.enrolled.join(', ')}`);
58
+ for (const [id, used] of Object.entries(args.creditUsed ?? {}))
59
+ out.push(`${money(used)} of the ${id} credit used this period`);
54
60
  if (args.bonus === null)
55
61
  out.push('welcome bonus no longer tracked');
56
62
  else if (args.bonus !== undefined)
@@ -62,7 +68,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
62
68
  const { catalog, store } = deps;
63
69
  server.registerTool('list_wallet', {
64
70
  title: 'List wallet',
65
- 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}`,
71
+ 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, merchant credits to enroll in or use before they reset); 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, merchant statement credits (enrolled or not, and what's left this period), 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}`,
66
72
  outputSchema: WalletSummarySchema,
67
73
  annotations: {
68
74
  title: 'List wallet',
@@ -96,7 +102,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
96
102
  });
97
103
  server.registerTool('record_purchase', {
98
104
  title: 'Record a purchase',
99
- 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}`,
105
+ description: `Records a purchase the user has already made, so spending caps, welcome-bonus progress, yearly spend milestones and merchant credits used 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, no spend milestone and no enrolled merchant credit 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}`,
100
106
  inputSchema: z.object({
101
107
  cardId: z
102
108
  .string()
@@ -135,21 +141,23 @@ export function registerWalletTools(server, deps, ctx, inputs) {
135
141
  const result = await store.record(purchase, resolved.merchant);
136
142
  if (!result.ok)
137
143
  return fail(`Couldn't record it: ${result.error}`);
138
- const { cap: booking, bonus, milestones } = result;
139
- const output = booking || bonus || milestones.length > 0
144
+ const { cap: booking, bonus, milestones, credit } = result;
145
+ const output = booking || bonus || milestones.length > 0 || credit
140
146
  ? {
141
147
  recorded: true,
142
148
  booking,
143
149
  bonus,
144
150
  milestones,
145
- message: `${formatBooking(amount, cardId, booking, bonus, milestones)} Saved in ${ctx.walletName}.`,
151
+ credit,
152
+ message: `${formatBooking(amount, cardText(catalog).shortName(cardId), booking, bonus, milestones, credit)} Saved in ${ctx.walletName}.`,
146
153
  }
147
154
  : {
148
155
  recorded: false,
149
156
  booking: null,
150
157
  bonus: null,
151
158
  milestones: [],
152
- message: `Nothing to track: ${cardId} has no spending cap, open welcome bonus or spend milestone for this purchase, so the wallet wasn't changed.`,
159
+ credit: null,
160
+ message: `Nothing to track: ${cardText(catalog).shortName(cardId)} has no spending cap, open welcome bonus, spend milestone or enrolled merchant credit for this purchase, so the wallet wasn't changed.`,
153
161
  };
154
162
  return {
155
163
  content: [
@@ -183,7 +191,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
183
191
  });
184
192
  server.registerTool('update_card', {
185
193
  title: 'Update a card',
186
- description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, opening date (optional; for first-year rates), 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}`,
194
+ description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, opening date (optional; for first-year rates), activated categories, cap spending so far (capSpent), spending this year toward its spend milestones (milestoneSpent), merchant credits enrolled in (enrolled) and used this period (creditUsed), 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}`,
187
195
  inputSchema: CardChange,
188
196
  outputSchema: WalletChangeSchema,
189
197
  annotations: {
@@ -121,6 +121,14 @@ export function findMerchant(catalog, id) {
121
121
  return (catalog.merchants.overrides.find((m) => m.id === id) ??
122
122
  catalog.merchants.seed.find((m) => m.id === id));
123
123
  }
124
+ // The ids a card rule can name to match this merchant: its own and, for a
125
+ // brand, its parent's.
126
+ export function merchantFamily(catalog, id) {
127
+ if (id === null)
128
+ return [];
129
+ const parent = findMerchant(catalog, id)?.brand_of;
130
+ return parent === undefined ? [id] : [id, parent];
131
+ }
124
132
  export function resolveMerchantById(catalog, id, channel) {
125
133
  const override = catalog.merchants.overrides.find((m) => m.id === id);
126
134
  const found = findMerchant(catalog, id);
@@ -33,6 +33,16 @@ export const SharedCapSchema = CapSchema.extend({
33
33
  // aren't in the data yet, not that there's no bonus.
34
34
  rotating: z.literal(true).optional(),
35
35
  });
36
+ // How a purchase is paid, for rates that depend on it: Apple Card's 2% with
37
+ // Apple Pay, PayPal Cashback's 3% through PayPal checkout. `card` is the card
38
+ // itself or its number, not a phone wallet or PayPal. Unknown unless the
39
+ // person says, so a rule that requires one never applies by assumption.
40
+ export const PaymentMethod = z.enum([
41
+ 'card',
42
+ 'apple-pay',
43
+ 'google-pay',
44
+ 'paypal',
45
+ ]);
36
46
  const hasAny = (m) => Object.values(m).some((v) => v === true || (v !== undefined && v.length > 0));
37
47
  // Kinds combine with AND, values within a kind with OR: `{ merchants:
38
48
  // [lyft], categories: [transit] }` is a Lyft purchase that codes as transit.
@@ -52,8 +62,9 @@ export const ExcludeSchema = z
52
62
  categories: z.array(CategoryId).optional(),
53
63
  merchants: z.array(KebabId).optional(),
54
64
  mccs: z.array(Mcc).optional(),
65
+ payment: z.array(PaymentMethod).optional(),
55
66
  })
56
- .refine(hasAny, 'exclude needs categories, merchants or mccs');
67
+ .refine(hasAny, 'exclude needs categories, merchants, mccs or payment');
57
68
  export const RuleSchema = z
58
69
  .strictObject({
59
70
  id: KebabId,
@@ -71,6 +82,7 @@ export const RuleSchema = z
71
82
  .strictObject({
72
83
  membership: KebabId.optional(),
73
84
  activation: z.boolean().optional(),
85
+ payment: PaymentMethod.optional(),
74
86
  })
75
87
  .optional(),
76
88
  valid_from: IsoDate.optional(),
@@ -160,6 +172,26 @@ export const IntroRateSchema = z
160
172
  note: z.string().optional(),
161
173
  })
162
174
  .refine((i) => (i.multiply === undefined) !== (i.add === undefined), 'an intro rate needs multiply or add, not both');
175
+ // Merchant statement credits: dollars back for spending at named merchants
176
+ // (or a category) up to `amount` each period, like Amex Platinum's $75 a
177
+ // quarter at lululemon. They're separate from earn rates: the purchase still
178
+ // earns the card's rate on its whole amount, and the credit comes on top.
179
+ // `half_year` runs January to June and July to December.
180
+ export const CreditPeriod = z.enum([...CapPeriod.options, 'half_year']);
181
+ export const CreditSchema = z.strictObject({
182
+ id: KebabId,
183
+ match: z
184
+ .strictObject({
185
+ merchants: z.array(KebabId).min(1).optional(),
186
+ categories: z.array(CategoryId).min(1).optional(),
187
+ })
188
+ .refine((m) => (m.merchants === undefined) !== (m.categories === undefined), 'a credit matches merchants or categories, not both'),
189
+ amount: z.number().positive(), // dollars of credit per period
190
+ period: CreditPeriod,
191
+ // The cardholder must enroll with the issuer before it pays.
192
+ enroll: z.literal(true).optional(),
193
+ note: z.string().optional(),
194
+ });
163
195
  const HexColor = z.string().regex(/^#[0-9a-f]{6}$/);
164
196
  export const CardSchema = z
165
197
  .strictObject({
@@ -206,8 +238,10 @@ export const CardSchema = z
206
238
  rules: z.array(RuleSchema).default([]),
207
239
  milestones: z.array(MilestoneSchema).default([]),
208
240
  intro_rates: z.array(IntroRateSchema).default([]),
209
- // A short quote from an issuer page for each rule (or `base_rate`). The
210
- // weekly source check fails a rule whose quote disappears from its page.
241
+ credits: z.array(CreditSchema).default([]),
242
+ // A short quote from an issuer page for each rule (or `base_rate`, or a
243
+ // credit's id). The weekly source check fails a rule whose quote
244
+ // disappears from its page.
211
245
  evidence: z
212
246
  .array(z.strictObject({
213
247
  rule: z.string().min(1),
@@ -19,7 +19,7 @@ export const IsoDate = z.iso.date();
19
19
  // YAML reads `period: 2026` as a number, so accept both and normalize.
20
20
  export const PeriodKey = z.preprocess((v) => (typeof v === 'number' ? String(v) : v), z
21
21
  .string()
22
- .regex(/^\d{4}(-Q[1-4]|-(0[1-9]|1[0-2]))?$/, 'expected a period like 2026, 2026-Q4 or 2026-10'));
22
+ .regex(/^\d{4}(-Q[1-4]|-H[12]|-(0[1-9]|1[0-2]))?$/, 'expected a period like 2026, 2026-Q4, 2026-H2 or 2026-10'));
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]');
@@ -13,6 +13,9 @@ export const MerchantSchema = z.strictObject({
13
13
  aliases: z.array(z.string().min(1)).default([]),
14
14
  domains: z.array(z.string().min(1)).default([]),
15
15
  category: CategoryId,
16
+ // A brand of another merchant (Hampton Inn of Hilton): rules that name the
17
+ // parent's id also match the brand. One level only.
18
+ brand_of: KebabId.optional(),
16
19
  likely_mcc: Mcc.optional(),
17
20
  accepts: Accepts.optional(),
18
21
  in_store: ChannelOverride.optional(),
@@ -31,6 +34,9 @@ export const MerchantSchema = z.strictObject({
31
34
  // Costco Travel), or a booking made through one (booking: "Hotel through
32
35
  // Amex Travel").
33
36
  kind: z.enum(['portal', 'booking']).optional(),
37
+ // "on Walmart+ membership" rather than "at": a membership or subscription,
38
+ // not a store.
39
+ preposition: z.literal('on').optional(),
34
40
  note: z.string().optional(),
35
41
  });
36
42
  export const MerchantsFileSchema = z.array(MerchantSchema);
@@ -16,6 +16,10 @@ const CapUsage = z.strictObject({
16
16
  period: PeriodKey,
17
17
  spent: z.number().nonnegative(), // dollars
18
18
  });
19
+ const CreditUsage = z.strictObject({
20
+ period: PeriodKey,
21
+ used: z.number().nonnegative(), // dollars of credit
22
+ });
19
23
  export const WalletCardSchema = z.strictObject({
20
24
  id: KebabId,
21
25
  checkout_label: z
@@ -42,6 +46,10 @@ export const WalletCardSchema = z.strictObject({
42
46
  activated: z.array(KebabId).default([]),
43
47
  // Keyed by milestone id: every purchase on the card this period.
44
48
  milestone_usage: z.record(KebabId, CapUsage).optional(),
49
+ // Merchant credits (the card's `credits`) the cardholder has enrolled in,
50
+ // by credit id, and the dollars of each credit already received this period.
51
+ enrolled: z.array(KebabId).optional(),
52
+ credit_usage: z.record(KebabId, CreditUsage).optional(),
45
53
  // A welcome bonus in progress. Terms default to the card's published
46
54
  // offer; set spend, by, points or dollars when yours was different.
47
55
  bonus: z
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
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",