earnrate 0.2.10 → 0.2.11

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 (41) hide show
  1. package/README.md +14 -6
  2. package/data/CHANGELOG.md +44 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-delta-skymiles-gold.yaml +120 -0
  5. package/data/cards/amex-delta-skymiles-platinum.yaml +118 -0
  6. package/data/cards/amex-delta-skymiles-reserve.yaml +80 -0
  7. package/data/cards/amex-hilton-honors-aspire.yaml +140 -0
  8. package/data/cards/amex-hilton-honors-surpass.yaml +151 -0
  9. package/data/cards/amex-hilton-honors.yaml +117 -0
  10. package/data/cards/amex-marriott-bonvoy-bevy.yaml +116 -0
  11. package/data/cards/amex-marriott-bonvoy-brilliant.yaml +91 -0
  12. package/data/cards/amex-platinum.yaml +14 -0
  13. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +13 -0
  14. package/data/cards/bank-of-america-atmos-rewards-business.yaml +13 -0
  15. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +16 -0
  16. package/data/cards/capital-one-quicksilver.yaml +80 -0
  17. package/data/cards/chase-aeroplan.yaml +28 -0
  18. package/data/cards/chase-marriott-bonvoy-bold.yaml +123 -0
  19. package/data/cards/chase-marriott-bonvoy-boundless.yaml +76 -0
  20. package/data/cards/chase-sapphire-reserve.yaml +29 -0
  21. package/data/cards/chase-united-business.yaml +10 -0
  22. package/data/cards/chase-united-club-business.yaml +19 -0
  23. package/data/cards/chase-united-club.yaml +31 -0
  24. package/data/cards/chase-united-explorer.yaml +19 -0
  25. package/data/cards/chase-united-gateway.yaml +18 -0
  26. package/data/cards/chase-united-quest.yaml +19 -0
  27. package/data/cards/citi-aadvantage-business.yaml +12 -1
  28. package/data/cards/citi-aadvantage-platinum-select.yaml +12 -1
  29. package/data/cards/discover-it-cash-back.yaml +169 -0
  30. package/data/currencies.yaml +26 -0
  31. package/data/issuers.yaml +3 -0
  32. package/data/merchants/overrides.yaml +16 -0
  33. package/data/merchants/seed.yaml +40 -0
  34. package/dist/engine/cheat-sheet.js +3 -0
  35. package/dist/engine/recommend.js +10 -8
  36. package/dist/mcp/prompts.js +90 -0
  37. package/dist/mcp/server.js +2 -0
  38. package/dist/mcp/tools/account.js +5 -4
  39. package/dist/mcp/tools/answers.js +7 -0
  40. package/dist/mcp/tools/wallet.js +1 -0
  41. package/package.json +1 -1
@@ -0,0 +1,169 @@
1
+ id: discover-it-cash-back
2
+ name: Discover it Cash Back Credit Card
3
+ # Discover merged into Capital One, N.A.; the application terms are headed
4
+ # "Discover by Capital One" and served from disclosures.capitalone.com.
5
+ issuer: discover
6
+ network: discover
7
+ currency: cash
8
+ last_verified: 2026-11-03
9
+ verified_by: claude
10
+ source_urls:
11
+ - https://www.discover.com/credit-cards/cash-back/it-card/
12
+ # The card page's "See rates, rewards, fees, and other cost information"
13
+ # (pricing and rewards FAQ); the number is the page's offer id and changes
14
+ # when Capital One changes it.
15
+ - https://disclosures.capitalone.com/disclosure.42300.en-US.html
16
+ - https://www.discover.com/credit-cards/cash-back/cashback-calendar.html
17
+ - https://www.discover.com/credit-cards/cash-back/cashback-bonus.html
18
+ # The calendar page's "See our FAQs".
19
+ - https://www.discover.com/credit-cards/card-smarts/using-cashback-bonus/
20
+ base_rate: 1
21
+ annual_fee: 0
22
+ # No page states it: the application terms' fee table lists no foreign
23
+ # transaction fee row, but nothing says "None".
24
+ foreign_transaction_fee: null
25
+ welcome_offer:
26
+ dollars: 200
27
+ spend: 500
28
+ months: 3
29
+ note: One-time $200 statement credit, available by applying through the card page. Existing or previous cardmembers may not be eligible if they received a new cardmember bonus for this product in the past 48 months. New cardmembers also get Cashback Match (all cash back earned in the first year matched), which isn't counted here.
30
+ caps:
31
+ # Discover's quarterly maximum on the rotating 5% categories.
32
+ - id: five-percent-quarterly
33
+ amount: 1500
34
+ period: quarter
35
+ then_rate: 1
36
+ rotating: true
37
+ rules:
38
+ - id: capital-one-travel
39
+ match:
40
+ {
41
+ merchants:
42
+ [
43
+ capital-one-travel-hotels,
44
+ capital-one-travel-vacation-rentals,
45
+ capital-one-travel-car-rentals,
46
+ capital-one-travel-activities,
47
+ ],
48
+ }
49
+ rate: 5
50
+ note: Hotels, vacation rentals, rental cars and activities booked through Capital One Travel only. Flights booked there, and hotel incidentals or upgrades paid outside Capital One Travel, earn the base 1%.
51
+ - id: capital-one-entertainment
52
+ match: { merchants: [capital-one-entertainment] }
53
+ rate: 5
54
+ note: Tickets bought on the Capital One Entertainment ticketing platform only. Cardholder-exclusive pre-sales, tickets bought directly from Capital One Hall or Capital One Arena, and Capital One Dining are excluded.
55
+ # Rotating 5% categories. Activation is per quarter and isn't retroactive:
56
+ # only purchases from the day you activate earn 5%.
57
+ - id: q4-2026-restaurants
58
+ match: { categories: [dining] }
59
+ rate: 5
60
+ geography: us_only
61
+ cap: five-percent-quarterly
62
+ requires: { activation: true }
63
+ valid_from: 2026-10-01
64
+ valid_to: 2026-12-31
65
+ activate_by: 2026-12-31
66
+ note: Full-service restaurants, cafes, cafeterias, fast-food locations and caterers. Restaurants inside hotels or retail stores, and bakeries, may not qualify. Earns 5% only from the day you activate (any time through December 31, 2026).
67
+ - id: q4-2026-entertainment
68
+ match: { categories: [entertainment, movie_theaters] }
69
+ rate: 5
70
+ geography: us_only
71
+ cap: five-percent-quarterly
72
+ requires: { activation: true }
73
+ valid_from: 2026-10-01
74
+ valid_to: 2026-12-31
75
+ activate_by: 2026-12-31
76
+ note: Tickets at movie theaters, professional and semi-professional sports promoters, theatrical and concert promoters, amusement parks, tourist attractions, aquariums, zoos, dance halls, record stores, video rental locations, pool halls and bowling alleys. Golf courses, country clubs, some collegiate sporting events, charitable events, cable and streaming subscriptions, amusement park tickets bought on third-party sites and packages coded as travel or hotel don't count. Earns 5% only from the day you activate.
77
+ - id: q4-2026-utilities
78
+ match: { categories: [utilities, tv_internet, phone] }
79
+ rate: 5
80
+ geography: us_only
81
+ cap: five-percent-quarterly
82
+ requires: { activation: true }
83
+ valid_from: 2026-10-01
84
+ valid_to: 2026-12-31
85
+ activate_by: 2026-12-31
86
+ note: Residential electricity, natural gas and water, plus internet, cable, satellite TV and mobile and landline phone service paid by phone or online. Paying for those at a physical store, through a landlord, property manager or HOA, home fuel delivery and standalone streaming services may not qualify. Earns 5% only from the day you activate.
87
+ todo:
88
+ - 'Discover has published only 2026 on its 5% calendar (Oct-Dec 2026 is the last quarter shown). Add 2027 quarters when Discover posts them.'
89
+ - Discover's 5% activation isn't retroactive (5% applies "from 10/1/26 (or the date on which you activate 5%, whichever is later)"), and you can activate until the quarter's last day. The schema's activate_by can't say that purchases before activation earn 1%; it's set to the quarter's last day and the rule notes say so.
90
+ - Cashback Match (Discover matches all cash back a new cardmember earns in the first year, no limit) can't be expressed as a fixed welcome offer; it roughly doubles first-year earnings on every rule. Not modeled.
91
+ - The card page's Cashback Match terms say "through your first 365 days"; the Cashback Bonus page says "your first 12 consecutive billing periods or 365 days, whichever is longer". Which one current applicants get is unclear; it doesn't change any rate here.
92
+ - Foreign transaction fee isn't stated on any Discover page or in the application terms (their fee table has no foreign transaction row). Left null; confirm with the Cardmember Agreement or Capital One.
93
+ - Discover says 5% purchases "must be made with merchants in the U.S." The application terms put that sentence in the general earning paragraph, so it may also apply to the 1% base and Capital One Travel; only the rotating rules are marked us_only.
94
+ - Amusement parks, tourist attractions, aquariums, zoos, record stores, video rental, pool halls and bowling alleys earn 5% as Q4 2026 entertainment but have no earnrate category. Golf courses, country clubs and collegiate sporting events are excluded but can't be told apart from the entertainment category.
95
+ - Third-party payment accounts and digital wallet transactions may not earn 5% if they don't carry a qualifying MCC. Not modeled.
96
+ # Issuer text backing each rule; `yarn check-sources` checks it weekly.
97
+ evidence:
98
+ - rule: base_rate
99
+ url: https://www.discover.com/credit-cards/cash-back/it-card/
100
+ quote: 'Plus, earn 1% cash back on all other purchases.'
101
+ - rule: base_rate
102
+ url: https://disclosures.capitalone.com/disclosure.42300.en-US.html
103
+ quote: 'There is no cap to the amount of 1% rewards you can earn on purchases.'
104
+ - rule: capital-one-travel
105
+ url: https://disclosures.capitalone.com/disclosure.42300.en-US.html
106
+ quote: 'earn 5% cash back on purchases (hotels, vacation rentals, rental cars and activities only) made on Capital One Travel'
107
+ - rule: capital-one-travel
108
+ url: https://disclosures.capitalone.com/disclosure.42300.en-US.html
109
+ quote: 'such as hotel incidentals, upgrades or other expenses, will not earn 5% cash back'
110
+ - rule: capital-one-entertainment
111
+ url: https://disclosures.capitalone.com/disclosure.42300.en-US.html
112
+ quote: 'You will earn 5% cash back on Capital One Entertainment purchases.'
113
+ - rule: capital-one-entertainment
114
+ url: https://disclosures.capitalone.com/disclosure.42300.en-US.html
115
+ quote: 'and dining reservations or purchases through Capital One Dining are excluded'
116
+ - rule: q4-2026-restaurants
117
+ url: https://www.discover.com/credit-cards/cash-back/cashback-calendar.html
118
+ quote: 'Earn 5% Cashback Bonus at Restaurants, Entertainment and Utilities, now to December 31, 2026'
119
+ - rule: q4-2026-restaurants
120
+ url: https://www.discover.com/credit-cards/cash-back/cashback-calendar.html
121
+ quote: 'from 10/1/26 (or the date on which you activate 5%, whichever is later) through 12/31/26'
122
+ - rule: q4-2026-restaurants
123
+ url: https://www.discover.com/credit-cards/cash-back/cashback-calendar.html
124
+ quote: 'Restaurant purchases include those made at merchants classified as full-service restaurants, cafes, cafeterias, fast-food locations, and caterers.'
125
+ - rule: q4-2026-restaurants
126
+ url: https://www.discover.com/credit-cards/cash-back/cashback-calendar.html
127
+ quote: 'Purchases must be made with merchants in the U.S.'
128
+ - rule: q4-2026-restaurants
129
+ url: https://disclosures.capitalone.com/disclosure.42300.en-US.html
130
+ quote: '5% category purchases over the quarterly maximum, and all other purchases, except as noted below, earn 1% cash back.'
131
+ - rule: q4-2026-restaurants
132
+ url: https://www.discover.com/credit-cards/cash-back/cashback-bonus.html
133
+ quote: 'NOW: Earn 5% Cashback Bonus at Restaurants, Entertainment and Utilities'
134
+ - rule: q4-2026-entertainment
135
+ url: https://www.discover.com/credit-cards/cash-back/cashback-calendar.html
136
+ quote: 'on up to $1,500 in purchases when you activate'
137
+ - rule: q4-2026-entertainment
138
+ url: https://www.discover.com/credit-cards/cash-back/cashback-calendar.html
139
+ quote: 'Entertainment purchases include ticket purchases at movie theaters, sports promoters (professional and semi-professional live events)'
140
+ - rule: q4-2026-entertainment
141
+ url: https://www.discover.com/credit-cards/cash-back/cashback-calendar.html
142
+ quote: 'Tickets for or fees at golf courses, country clubs, some collegiate sporting events, charitable events'
143
+ - rule: q4-2026-utilities
144
+ url: https://www.discover.com/credit-cards/cash-back/cashback-calendar.html
145
+ quote: 'Utilities purchases include purchases for residential electricity, natural gas, and water service.'
146
+ - rule: q4-2026-utilities
147
+ url: https://www.discover.com/credit-cards/cash-back/cashback-calendar.html
148
+ quote: 'purchases made over the phone or online for internet, cable, satellite TV, and mobile and landline phone service'
149
+ - rule: q4-2026-utilities
150
+ url: https://www.discover.com/credit-cards/cash-back/cashback-calendar.html
151
+ quote: 'Purchases at physical retail stores for internet, cable, satellite, and mobile and landline phone service do not qualify.'
152
+ - rule: q4-2026-utilities
153
+ url: https://www.discover.com/credit-cards/card-smarts/using-cashback-bonus/
154
+ quote: 'you need to activate to earn each quarter'
155
+ - rule: annual_fee
156
+ url: https://disclosures.capitalone.com/disclosure.42300.en-US.html
157
+ quote: 'Annual Fee None.'
158
+ - rule: annual_fee
159
+ url: https://www.discover.com/credit-cards/cash-back/it-card/
160
+ quote: 'No, there is no annual fee for the Discover it Card.'
161
+ - rule: welcome_offer
162
+ url: https://www.discover.com/credit-cards/cash-back/it-card/
163
+ quote: 'Earn a $200 bonus statement credit when you spend $500 in the first 3 months.'
164
+ - rule: welcome_offer
165
+ url: https://disclosures.capitalone.com/disclosure.42300.en-US.html
166
+ quote: 'You will receive a $200 statement credit if you spend at least $500 within 3 months'
167
+ - rule: welcome_offer
168
+ url: https://www.discover.com/credit-cards/cash-back/it-card/
169
+ quote: 'Get an unlimited dollar-for-dollar match of all the cash back you earn at the end of your first year'
@@ -131,6 +131,32 @@
131
131
  sources:
132
132
  - https://thepointsguy.com/loyalty-programs/monthly-valuations/
133
133
 
134
+ - id: delta-skymiles
135
+ name: Delta SkyMiles
136
+ value_cents: 1.0
137
+ range: [1.0, 1.2]
138
+ note: Delta's Pay with Miles lets Delta Amex cardholders take $50 off a Delta fare for every 5,000 miles (1.0¢ a mile). 1.2¢ is TPG's November 2026 valuation.
139
+ sources:
140
+ - https://www.delta.com/paywithmiles
141
+ - https://thepointsguy.com/loyalty-programs/monthly-valuations/
142
+
143
+ - id: hilton-honors
144
+ name: Hilton Honors points
145
+ value_cents: 0.4
146
+ note: Hilton publishes no general cash value for points; its terms say rewards can't be redeemed for cash, and the only fixed rate is for Event Credits on group bills (25,000 points for $50, 0.2¢). 0.4¢ is TPG's November 2026 valuation, below the 0.5¢ default used for IHG, so it's the default here.
147
+ sources:
148
+ - https://www.hilton.com/en/hilton-honors/terms/
149
+ - https://thepointsguy.com/loyalty-programs/monthly-valuations/
150
+
151
+ - id: marriott-bonvoy
152
+ name: Marriott Bonvoy points
153
+ value_cents: 0.5
154
+ range: [0.5, 0.75]
155
+ note: Marriott publishes no general cash value for points. The only fixed rate is Chase's Pay Yourself Back on the Bonvoy Bold card, $100 for every 12,500 points (0.8¢), limited to airline and Marriott purchases on that card. 0.5¢ is the conservative default IHG uses too; 0.75¢ is TPG's November 2026 valuation.
156
+ sources:
157
+ - https://creditcards.chase.com/travel-credit-cards/marriott-bonvoy/bold
158
+ - https://thepointsguy.com/loyalty-programs/monthly-valuations/
159
+
134
160
  - id: kohls-rewards
135
161
  name: Kohl's Rewards
136
162
  value_cents: 1.0
package/data/issuers.yaml CHANGED
@@ -19,6 +19,9 @@
19
19
  # Column N.A. issues the Bilt card; people know it as Bilt.
20
20
  name: Bilt
21
21
  search: [bilt]
22
+ - id: discover
23
+ name: Discover
24
+ search: [discover it]
22
25
  - id: sofi
23
26
  name: SoFi
24
27
  - id: td-bank
@@ -212,6 +212,11 @@
212
212
  aliases: [capital one travel car rental],
213
213
  category: travel,
214
214
  }
215
+ - id: capital-one-travel-activities
216
+ kind: booking
217
+ name: Activity through Capital One Travel
218
+ aliases: [capital one travel activity]
219
+ category: travel
215
220
  - {
216
221
  id: capital-one-travel-flights,
217
222
  kind: booking,
@@ -445,6 +450,17 @@
445
450
  domains: [postmates.com]
446
451
  category: dining
447
452
  note: A delivery app that codes as a restaurant for most issuers. Bonuses limited to buying directly from a restaurant don't count it.
453
+ - id: caviar
454
+ name: Caviar
455
+ domains: [trycaviar.com]
456
+ category: dining
457
+ note: A delivery app that codes as a restaurant for most issuers. Bonuses limited to buying directly from a restaurant don't count it.
458
+ - id: delivery-com
459
+ name: Delivery.com
460
+ aliases: [delivery com]
461
+ domains: [delivery.com]
462
+ category: dining
463
+ note: A delivery app that codes as a restaurant for most issuers. Bonuses limited to buying directly from a restaurant don't count it.
448
464
 
449
465
  # A dinner theater: food ordered at your seat may land on the same check.
450
466
  - id: alamo-drafthouse
@@ -1036,6 +1036,18 @@
1036
1036
  domains: [delta.com],
1037
1037
  category: travel.flights,
1038
1038
  }
1039
+ - {
1040
+ id: delta-vacations,
1041
+ name: Delta Vacations,
1042
+ domains: [deltavacations.com],
1043
+ category: travel,
1044
+ }
1045
+ - {
1046
+ id: delta-stays,
1047
+ name: Delta Stays,
1048
+ aliases: [delta stays, delta hotels],
1049
+ category: travel.hotels,
1050
+ }
1039
1051
  - {
1040
1052
  id: united,
1041
1053
  name: United Airlines,
@@ -1332,6 +1344,34 @@
1332
1344
  domains: [thrifty.com],
1333
1345
  category: travel.car_rental,
1334
1346
  }
1347
+ - {
1348
+ id: ez-rent-a-car,
1349
+ name: E-Z Rent-A-Car,
1350
+ aliases: [ez rent a car],
1351
+ domains: [e-zrentacar.com],
1352
+ category: travel.car_rental,
1353
+ }
1354
+ - {
1355
+ id: fox-rent-a-car,
1356
+ name: Fox Rent A Car,
1357
+ aliases: [fox car rental],
1358
+ domains: [foxrentacar.com],
1359
+ category: travel.car_rental,
1360
+ }
1361
+ - {
1362
+ id: payless-car-rental,
1363
+ name: Payless Car Rental,
1364
+ aliases: [payless car],
1365
+ domains: [paylesscar.com],
1366
+ category: travel.car_rental,
1367
+ }
1368
+ - {
1369
+ id: sixt,
1370
+ name: Sixt,
1371
+ aliases: [sixt rent a car],
1372
+ domains: [sixt.com],
1373
+ category: travel.car_rental,
1374
+ }
1335
1375
  - {
1336
1376
  id: dollar-car-rental,
1337
1377
  name: Dollar Rent A Car,
@@ -20,6 +20,9 @@ export const EVERYDAY_PURCHASES = [
20
20
  { label: 'Drugstores', category: 'drugstore' },
21
21
  { label: 'Everything else', category: 'other' },
22
22
  ];
23
+ // What a quick pick searches for: "Streaming (Netflix)" searches "Netflix",
24
+ // since streaming bonuses follow each issuer's list of services.
25
+ export const quickPickText = (item) => /\((.+)\)$/.exec(item.label)?.[1] ?? item.label;
23
26
  // The best card in the wallet for each everyday kind of purchase on `date`,
24
27
  // with caps, picks and activations as the wallet has them.
25
28
  export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
@@ -133,7 +133,7 @@ function warningsFor(catalog, entry, purchase) {
133
133
  const { best, nominal } = evaluation;
134
134
  const warnings = [];
135
135
  if (entry.fee > 0)
136
- warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.effectiveReturnPct)}.`);
136
+ warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
137
137
  if (isForeign(purchase) && card.foreign_transaction_fee === null)
138
138
  warnings.push("Its foreign transaction fee isn't on file, so none is subtracted. Check the issuer's terms before using it abroad.");
139
139
  const capLabel = (e) => e.cap
@@ -220,7 +220,7 @@ function tieNote(entries) {
220
220
  tied.some((e) => e.evaluation.best.cap !== null)
221
221
  ? 'because its rate has no cap'
222
222
  : 'by a fixed order; either works';
223
- return `${names} also ${verb} ${pct(winner.effectiveReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
223
+ return `${names} also ${verb} ${pct(winner.rateReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
224
224
  }
225
225
  // Finds the closest point inside a currency's value range where the winner
226
226
  // stops winning, varying one currency at a time. Returns are linear in the
@@ -338,9 +338,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
338
338
  const twin = alts.find((e) => e.card.id !== winner.card.id &&
339
339
  alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
340
340
  if (alt.card.id !== winner.card.id)
341
- warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.effectiveReturnPct)}).`);
341
+ warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.rateReturnPct)}).`);
342
342
  else if (twin)
343
- warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.effectiveReturnPct)}).`);
343
+ warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.rateReturnPct)}).`);
344
344
  }
345
345
  }
346
346
  if (!winner) {
@@ -359,7 +359,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
359
359
  ? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
360
360
  : undefined;
361
361
  if (alt)
362
- warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.effectiveReturnPct)}).`);
362
+ warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.rateReturnPct)}).`);
363
363
  }
364
364
  else if (merchant.accepts === null &&
365
365
  (winner.card.network === 'amex' || winner.card.network === 'discover')) {
@@ -384,7 +384,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
384
384
  const alt = rankEntries(catalog, wallet, purchase, via, via.category)
385
385
  .entries[0];
386
386
  if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
387
- notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.effectiveReturnPct)}.`);
387
+ notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.rateReturnPct)}.`);
388
388
  }
389
389
  }
390
390
  const open = bonusPick(catalog, wallet, entries, purchase.date);
@@ -400,7 +400,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
400
400
  const bonus = open && (open.behind || ties) ? open : null;
401
401
  const best = winner?.effectiveReturnPct ?? 0;
402
402
  if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
403
- notes.push(`${displayName(open.entry)} earns ${pct(open.entry.effectiveReturnPct)} here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${pct(best)}, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
403
+ notes.push(`${displayName(open.entry)} earns ${pct(open.entry.rateReturnPct)} here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${pct(best)}, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
404
404
  }
405
405
  const chosen = bonus?.entry ?? winner;
406
406
  // A tie among the top cards doesn't matter when a welcome bonus decides.
@@ -417,7 +417,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
417
417
  const daysLeft = status.daysLeft ?? 0;
418
418
  const costHere = ties || top.earned === null || first.earned === null
419
419
  ? null
420
- : roundCents(first.earned - top.earned);
420
+ : roundCents(first.earned +
421
+ (first.milestoneBonus?.dollars ?? 0) -
422
+ (top.earned + (top.milestoneBonus?.dollars ?? 0)));
421
423
  bonusResult = {
422
424
  cardId: top.cardId,
423
425
  checkoutLabel: top.checkoutLabel,
@@ -0,0 +1,90 @@
1
+ import { z } from 'zod';
2
+ // Ready-made questions a client can offer: the user's question, then what
3
+ // to call and how to answer. Rates and card facts come only from the tools.
4
+ export const PROMPTS = ['which-card', 'cheat-sheet', 'this-quarter', 'set-up'];
5
+ const FROM_SAVED_OR_ALL = "the user's saved cards if there are any, otherwise every card earnrate knows";
6
+ const ask = (question, guidance) => ({
7
+ messages: [
8
+ {
9
+ role: 'user',
10
+ content: {
11
+ type: 'text',
12
+ text: `${question}\n\n${guidance.join(' ')}`,
13
+ },
14
+ },
15
+ ],
16
+ });
17
+ const dollars = (amount) => (/^\d/.test(amount) ? `$${amount}` : amount);
18
+ export function registerPrompts(server, deps, ctx) {
19
+ server.registerPrompt('which-card', {
20
+ title: 'Which credit card should I use?',
21
+ description: 'The card that earns the most at a store or for a kind of purchase, with the reason. From your saved cards, or every card if you have none saved.',
22
+ argsSchema: z.object({
23
+ store: z
24
+ .string()
25
+ .trim()
26
+ .min(1)
27
+ .describe('The store, or the kind of purchase: Costco, groceries'),
28
+ amount: z
29
+ .string()
30
+ .optional()
31
+ .describe('How much you are spending, in US dollars'),
32
+ country: z
33
+ .string()
34
+ .optional()
35
+ .describe('Where you are paying, if not the U.S.: FR, Japan'),
36
+ }),
37
+ }, (args) => {
38
+ const amount = args.amount?.trim() || null;
39
+ const country = args.country?.trim() || null;
40
+ const call = [
41
+ `merchant ${JSON.stringify(args.store)}`,
42
+ ...(amount === null
43
+ ? []
44
+ : [`amount ${JSON.stringify(amount)} as a number of US dollars`]),
45
+ ...(country === null
46
+ ? []
47
+ : [`country as the two-letter code for ${JSON.stringify(country)}`]),
48
+ ];
49
+ return ask(`Which credit card should I use at ${args.store}${amount === null ? '' : ` for ${dollars(amount)}`}${country === null ? '' : ` in ${country}`}?`, [
50
+ `Call earnrate's best_card_for_purchase with ${call.join(', ')}.`,
51
+ `It answers from ${FROM_SAVED_OR_ALL}, so don't ask for their cards first.`,
52
+ 'Say which card to use and why, with the assumptions and warnings it gives.',
53
+ ...(country === null
54
+ ? [
55
+ 'If the user mentions paying abroad or in another currency, ask where, or pass `country` as its two-letter code.',
56
+ ]
57
+ : []),
58
+ ]);
59
+ });
60
+ server.registerPrompt('cheat-sheet', {
61
+ title: 'Which card for everyday spending?',
62
+ description: 'The card to use for each everyday kind of purchase right now, from your saved cards or every card.',
63
+ }, () => ask('Which card should I use for everyday spending?', [
64
+ "Call earnrate's cheat_sheet and show the card for each kind of purchase, with its notes.",
65
+ `It uses ${FROM_SAVED_OR_ALL}.`,
66
+ ]));
67
+ server.registerPrompt('this-quarter', {
68
+ title: 'What should I do with my cards this quarter?',
69
+ description: 'Bonuses to activate, categories to pick, caps running low and welcome bonuses to finish, from your saved cards.',
70
+ }, () => ask('What should I do with my cards this quarter?', [
71
+ "Call earnrate's list_wallet: it starts with a to-do list. Walk through it, soonest first.",
72
+ ctx.walletUse,
73
+ 'If no cards are saved, say in a sentence or two what saving them adds (reminders to activate bonuses, pick categories, and watch caps and welcome bonuses), without pressing the user to do it, and offer the cheat sheet from every card instead.',
74
+ ]));
75
+ server.registerPrompt('set-up', {
76
+ title: 'Set up my cards',
77
+ description: 'Save the cards you hold, by name, with their category picks, memberships and activations.',
78
+ }, () => ask('Help me set up my cards in earnrate.', [
79
+ 'Ask which cards the user has; their names are enough. Never ask for card numbers, expiry dates or security codes.',
80
+ ...(deps.savedWallets === 'account'
81
+ ? [
82
+ "Never ask for the recovery code either; it's typed only on earnrate's page.",
83
+ ]
84
+ : []),
85
+ 'Find each card with search_cards and check the match with the user, then save it with add_card.',
86
+ ctx.walletUse,
87
+ 'When a card needs category picks, a membership like Amazon Prime, activations or its anniversary, ask rather than guessing, and save the answers with update_card or set_memberships.',
88
+ 'When done, call list_wallet and show its to-do list.',
89
+ ]));
90
+ }
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { MCP_SERVER_NAME } from "../brand.js";
3
3
  import { makeContext } from "./context.js";
4
4
  import { makeInputs } from "./inputs.js";
5
+ import { registerPrompts } from "./prompts.js";
5
6
  import { registerAccountTools } from "./tools/account.js";
6
7
  import { registerAnswerTools } from "./tools/answers.js";
7
8
  import { registerWalletTools } from "./tools/wallet.js";
@@ -66,6 +67,7 @@ export function createEarnrateServer(deps) {
66
67
  const inputs = makeInputs(deps.catalog);
67
68
  registerAnswerTools(server, deps, ctx, inputs);
68
69
  registerWalletTools(server, deps, ctx, inputs);
70
+ registerPrompts(server, deps, ctx);
69
71
  if (deps.savedWallets === 'account')
70
72
  registerAccountTools(server, deps.catalog, deps.store, ctx);
71
73
  return server;
@@ -26,6 +26,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
26
26
  annotations: {
27
27
  title: 'Export the wallet',
28
28
  readOnlyHint: true,
29
+ destructiveHint: false,
29
30
  openWorldHint: false,
30
31
  },
31
32
  }, async () => {
@@ -82,7 +83,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
82
83
  });
83
84
  server.registerTool('delete_wallet', {
84
85
  title: 'Delete the wallet',
85
- description: "Deletes the saved wallet and its recovery code now and for good, and signs out every Claude app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: show it with list_wallet, offer export_wallet, and pass confirm, cards and saved only after the user confirms. It refuses if the wallet no longer holds what list_wallet showed, so a retried call can't delete a different wallet.",
86
+ description: "Deletes the saved wallet and its recovery code now and for good, and signs out every app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: show it with list_wallet, offer export_wallet, and pass confirm, cards and saved only after the user confirms. It refuses if the wallet no longer holds what list_wallet showed, so a retried call can't delete a different wallet.",
86
87
  inputSchema: z.object({
87
88
  confirm: z
88
89
  .literal(true)
@@ -121,7 +122,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
121
122
  content: [
122
123
  {
123
124
  type: 'text',
124
- text: "Deleted the saved wallet and its recovery code, and signed out every Claude app connected to it, this one included. To keep using earnrate here, the user connects again on earnrate's page and starts a new wallet.",
125
+ text: "Deleted the saved wallet and its recovery code, and signed out every app connected to it, this one included. To keep using earnrate here, the user connects again on earnrate's page and starts a new wallet.",
125
126
  },
126
127
  ],
127
128
  structuredContent: { deleted: true },
@@ -129,7 +130,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
129
130
  });
130
131
  server.registerTool('switch_wallet', {
131
132
  title: 'Switch wallets',
132
- description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. It also signs out anyone else connected to this wallet through the same app (another Claude account the recovery code was shared with): say so before calling it. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
133
+ description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. It also signs out anyone else connected to this wallet through the same app (another account the recovery code was shared with): say so before calling it. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
133
134
  inputSchema: z.object({}),
134
135
  outputSchema: z.object({ disconnected: z.boolean() }),
135
136
  annotations: {
@@ -145,7 +146,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
145
146
  content: [
146
147
  {
147
148
  type: 'text',
148
- text: "Disconnected from this saved wallet; nothing was deleted. earnrate's sign-in page opens on the next earnrate request (or reconnect earnrate in the app's connector settings). There the user chooses \"Already have a wallet?\" and types the other wallet's recovery code, or starts a new wallet. Other Claude accounts connected to this wallet through the same app are signed out too.",
149
+ text: "Disconnected from this saved wallet; nothing was deleted. earnrate's sign-in page opens on the next earnrate request (or reconnect earnrate in the app's connector settings). There the user chooses \"Already have a wallet?\" and types the other wallet's recovery code, or starts a new wallet. Other accounts connected to this wallet through the same app are signed out too.",
149
150
  },
150
151
  ],
151
152
  structuredContent: { disconnected: true },
@@ -54,6 +54,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
54
54
  annotations: {
55
55
  title: 'Best card for a purchase',
56
56
  readOnlyHint: true,
57
+ destructiveHint: false,
57
58
  openWorldHint: deps.llm !== null,
58
59
  },
59
60
  }, async ({ merchant, amount, channel, date, country, category, cards, memberships, valuation, }) => {
@@ -127,6 +128,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
127
128
  annotations: {
128
129
  title: 'Resolve a merchant',
129
130
  readOnlyHint: true,
131
+ destructiveHint: false,
130
132
  openWorldHint: deps.llm !== null,
131
133
  },
132
134
  }, async ({ merchant, channel, category }) => {
@@ -166,6 +168,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
166
168
  annotations: {
167
169
  title: 'Compare welcome offers',
168
170
  readOnlyHint: true,
171
+ destructiveHint: false,
169
172
  openWorldHint: false,
170
173
  },
171
174
  }, async (args) => {
@@ -219,6 +222,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
219
222
  annotations: {
220
223
  title: 'Best cards for a purchase',
221
224
  readOnlyHint: true,
225
+ destructiveHint: false,
222
226
  openWorldHint: false,
223
227
  },
224
228
  }, async (args) => {
@@ -266,6 +270,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
266
270
  annotations: {
267
271
  title: 'Simulate a year of spending',
268
272
  readOnlyHint: true,
273
+ destructiveHint: false,
269
274
  openWorldHint: false,
270
275
  },
271
276
  }, async (args) => {
@@ -349,6 +354,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
349
354
  annotations: {
350
355
  title: 'Cheat sheet',
351
356
  readOnlyHint: true,
357
+ destructiveHint: false,
352
358
  openWorldHint: false,
353
359
  },
354
360
  }, async (args) => {
@@ -391,6 +397,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
391
397
  annotations: {
392
398
  title: 'Search cards',
393
399
  readOnlyHint: true,
400
+ destructiveHint: false,
394
401
  openWorldHint: false,
395
402
  },
396
403
  }, async ({ query }) => {
@@ -63,6 +63,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
63
63
  annotations: {
64
64
  title: 'List wallet',
65
65
  readOnlyHint: true,
66
+ destructiveHint: false,
66
67
  openWorldHint: false,
67
68
  },
68
69
  }, async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
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",