earnrate 0.2.12 → 0.2.13

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 (95) hide show
  1. package/README.md +20 -7
  2. package/data/CHANGELOG.md +181 -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 +27 -1
  9. package/data/cards/amex-delta-skymiles-platinum.yaml +33 -1
  10. package/data/cards/amex-delta-skymiles-reserve.yaml +33 -1
  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-platinum.yaml +110 -0
  17. package/data/cards/apple-card.yaml +76 -0
  18. package/data/cards/bilt-obsidian.yaml +12 -2
  19. package/data/cards/bilt-palladium.yaml +12 -2
  20. package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
  21. package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -0
  22. package/data/cards/capital-one-venture-x-business.yaml +28 -1
  23. package/data/cards/capital-one-venture-x.yaml +30 -2
  24. package/data/cards/chase-aeroplan.yaml +14 -0
  25. package/data/cards/chase-ihg-one-rewards-business.yaml +27 -1
  26. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +16 -0
  27. package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -0
  28. package/data/cards/chase-ink-business-cash.yaml +13 -0
  29. package/data/cards/chase-ink-business-unlimited.yaml +13 -2
  30. package/data/cards/chase-sapphire-preferred.yaml +11 -0
  31. package/data/cards/chase-sapphire-reserve.yaml +44 -4
  32. package/data/cards/chase-united-business.yaml +32 -0
  33. package/data/cards/chase-united-club-business.yaml +40 -0
  34. package/data/cards/chase-united-club.yaml +40 -0
  35. package/data/cards/chase-united-explorer.yaml +32 -0
  36. package/data/cards/chase-united-quest.yaml +40 -0
  37. package/data/cards/citi-aadvantage-executive.yaml +26 -1
  38. package/data/cards/citi-strata-elite.yaml +20 -1
  39. package/data/cards/fidelity-rewards-visa-signature.yaml +60 -0
  40. package/data/cards/navy-federal-cash-rewards-plus.yaml +61 -0
  41. package/data/cards/navy-federal-cash-rewards.yaml +60 -0
  42. package/data/cards/navy-federal-more-rewards-amex.yaml +92 -0
  43. package/data/cards/paypal-cashback-mastercard.yaml +13 -2
  44. package/data/cards/penfed-power-cash-rewards.yaml +47 -0
  45. package/data/cards/usbank-altitude-connect.yaml +8 -2
  46. package/data/cards/usbank-altitude-go.yaml +8 -2
  47. package/data/cards/usbank-smartly.yaml +107 -0
  48. package/data/cards/venmo-credit-card.yaml +151 -0
  49. package/data/cards/verizon-visa-card.yaml +122 -0
  50. package/data/currencies.yaml +14 -0
  51. package/data/issuers.yaml +22 -0
  52. package/data/memberships.yaml +35 -0
  53. package/data/merchants/overrides.yaml +448 -0
  54. package/data/merchants/seed.yaml +1082 -65
  55. package/dist/cli/args.js +18 -0
  56. package/dist/cli/explore.js +10 -1
  57. package/dist/cli/run.js +4 -1
  58. package/dist/cli/wallet.js +30 -3
  59. package/dist/data/file-store.js +5 -2
  60. package/dist/data/integrity.js +22 -0
  61. package/dist/data/store.js +4 -1
  62. package/dist/data/wallet-edit.js +25 -6
  63. package/dist/engine/cheat-sheet.js +17 -5
  64. package/dist/engine/checklist.js +25 -4
  65. package/dist/engine/credits.js +65 -0
  66. package/dist/engine/evaluate.js +38 -11
  67. package/dist/engine/explore.js +70 -7
  68. package/dist/engine/offers.js +2 -1
  69. package/dist/engine/periods.js +10 -3
  70. package/dist/engine/recommend.js +72 -20
  71. package/dist/engine/record.js +41 -1
  72. package/dist/engine/rotation.js +3 -0
  73. package/dist/engine/rule-text.js +14 -3
  74. package/dist/engine/setup.js +15 -4
  75. package/dist/engine/simulate.js +10 -1
  76. package/dist/engine/text.js +84 -0
  77. package/dist/engine/wallet-change.js +37 -1
  78. package/dist/engine/wallet-summary.js +32 -4
  79. package/dist/format/card-text.js +4 -3
  80. package/dist/format/cheat-sheet.js +2 -0
  81. package/dist/format/explore.js +20 -4
  82. package/dist/format/recommendation.js +6 -2
  83. package/dist/format/wallet.js +6 -2
  84. package/dist/import/analyze.js +7 -2
  85. package/dist/mcp/inputs.js +10 -0
  86. package/dist/mcp/prompts.js +9 -1
  87. package/dist/mcp/schemas.js +70 -1
  88. package/dist/mcp/tools/answers.js +9 -4
  89. package/dist/mcp/tools/wallet.js +15 -7
  90. package/dist/merchants/resolve.js +8 -0
  91. package/dist/schema/card.js +37 -3
  92. package/dist/schema/common.js +1 -1
  93. package/dist/schema/merchant.js +3 -0
  94. package/dist/schema/wallet.js +8 -0
  95. package/package.json +1 -1
@@ -101,6 +101,32 @@ export const NETWORK_NAMES = {
101
101
  discover: 'Discover',
102
102
  store: 'store card',
103
103
  };
104
+ // How a purchase is paid, mid-sentence: "2% cash back with Apple Pay".
105
+ export const PAYMENT_PHRASES = {
106
+ card: 'with the card itself',
107
+ 'apple-pay': 'with Apple Pay',
108
+ 'google-pay': 'with Google Pay',
109
+ paypal: 'through PayPal checkout',
110
+ };
111
+ export const paidText = (payment) => `Counts this as paid ${PAYMENT_PHRASES[payment]}, as you said.`;
112
+ export const paymentUnknownText = (name, payment, earn, standard) => `${capitalize(PAYMENT_PHRASES[payment])}, ${name} would earn ${earn} here, instead of the ${standard} it's ranked at. Say how you're paying to count it.`;
113
+ // For answers that never ask how each purchase is paid (cheat sheets and
114
+ // simulations): which cards' rates that need a way of paying are left out.
115
+ export function paymentNote(cards) {
116
+ const payments = new Set();
117
+ const names = [];
118
+ for (const { name, card } of cards) {
119
+ const needs = card.rules.flatMap((r) => r.requires?.payment === undefined ? [] : [r.requires.payment]);
120
+ if (needs.length === 0)
121
+ continue;
122
+ names.push(name);
123
+ for (const p of needs)
124
+ payments.add(p);
125
+ }
126
+ if (names.length === 0)
127
+ return null;
128
+ return `Rates ${listed([...payments].map((p) => PAYMENT_PHRASES[p]), 'or')} (${listed(names)}) aren't counted: this ranks each card by what it earns however you pay.`;
129
+ }
104
130
  // A name with its own "and" ("drugstores and pharmacies") would run into the
105
131
  // list's: those lists use commas only, with those names last.
106
132
  export const listed = (parts, joiner = 'and') => {
@@ -162,3 +188,61 @@ export const introRateText = (earn, standard, days, until) => `${earn} in your $
162
188
  // For a card whose opening date isn't known: it's never assumed to be new.
163
189
  export const introUnknownText = (name, earn, standard, days) => `A ${introAdjective(days)} rate may apply on ${name}: ${earn} here in an account's ${introPeriod(days)}, instead of the ${standard} it's ranked at. Give the card's opening date if it opened ${introSince(days)}.`;
164
190
  export const introEndsText = (name, days, until) => `${name}'s ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
191
+ // Merchant statement credits. Always dollars, never a percentage.
192
+ export const CREDIT_PERIODS = {
193
+ month: 'each month',
194
+ quarter: 'each quarter',
195
+ half_year: 'each half year',
196
+ calendar_year: 'each calendar year',
197
+ cardmember_year: 'each cardmember year',
198
+ };
199
+ export const CREDIT_THIS_PERIOD = {
200
+ month: 'this month',
201
+ quarter: 'this quarter',
202
+ half_year: 'this half year',
203
+ calendar_year: 'this year',
204
+ cardmember_year: 'this cardmember year',
205
+ };
206
+ export const ENROLL_FIRST = 'enroll first';
207
+ // The group of cards with a credit at the store, shown before the ranking.
208
+ export const CREDIT_GROUP = 'With a store credit';
209
+ // "$75 back each quarter"
210
+ export const creditBack = (c) => `${money(c.amount)} back ${CREDIT_PERIODS[c.period]}`;
211
+ // "$75 back each quarter, then 1x Amex Membership Rewards"
212
+ export const creditThen = (c, earn) => `${creditBack(c)}, then ${earn}`;
213
+ // "$35 left this quarter"
214
+ export const creditLeft = (left, period) => `${money(left)} left ${CREDIT_THIS_PERIOD[period]}`;
215
+ // A line for an answer that uses the card's credit, or says why it doesn't.
216
+ export function creditAnswerText(c, dollars) {
217
+ const credit = `${c.name} credit (${creditBack(c)})`;
218
+ if (!c.enrolled)
219
+ return `Has a ${credit} here; ${ENROLL_FIRST} with the issuer, then mark it enrolled in your wallet. It isn't counted until then.`;
220
+ if (c.left === null)
221
+ return `Has a ${credit} here; give the card's anniversary to track it.`;
222
+ if (c.left <= 0)
223
+ return `Its ${c.name} credit is used up ${CREDIT_THIS_PERIOD[c.period]}${c.until === null ? '' : `; it renews after ${shortDate(c.until)}`}.`;
224
+ if (dollars === null)
225
+ return `Its ${credit} has ${creditLeft(c.left, c.period)}; give the amount to count it.`;
226
+ return dollars < c.left
227
+ ? `${money(dollars)} back from its ${credit}, on top of what it earns; ${creditLeft(roundCents(c.left - dollars), c.period)} after this.`
228
+ : `${money(dollars)} back from its ${credit}: all that's left ${CREDIT_THIS_PERIOD[c.period]}, on top of what it earns.`;
229
+ }
230
+ // One short line beside a ranked card: "+ $35 back from its Lululemon
231
+ // credit", "Lululemon credit: $35 left this quarter".
232
+ export function creditShort(c, dollars) {
233
+ if (!c.enrolled)
234
+ return `${c.name} credit: ${creditBack(c)}, ${ENROLL_FIRST}`;
235
+ if (c.left === null)
236
+ return `${c.name} credit: ${creditBack(c)}`;
237
+ if (c.left <= 0)
238
+ return `${c.name} credit used up ${CREDIT_THIS_PERIOD[c.period]}`;
239
+ if (dollars === null || dollars <= 0)
240
+ return `${c.name} credit: ${creditLeft(c.left, c.period)}`;
241
+ return `+ ${money(dollars)} back from its ${c.name} credit`;
242
+ }
243
+ // For the to-do list: "Use your $75 lululemon credit on Amex Platinum by Dec
244
+ // 31, 2026: $35 left."
245
+ export const useCreditText = (card, c, left) => `Use your ${money(c.amount)} ${c.name} credit on ${card}${c.until === null ? '' : ` by ${shortDate(c.until)}`}: ${money(left)} left.`;
246
+ export const enrollCreditText = (card, name) => `Enroll in ${card}'s ${name} credit with the issuer, then mark it enrolled.`;
247
+ // Simulations, the cheat sheet and welcome-offer comparisons.
248
+ export const CREDITS_LEFT_OUT = 'Merchant statement credits (dollars back at a named store) are left out.';
@@ -4,7 +4,7 @@ import { addMonths } from "./bonus.js";
4
4
  import { capByKey } from "./evaluate.js";
5
5
  import { choicePeriod, periodKey } from "./periods.js";
6
6
  import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
7
- import { idList } from "./text.js";
7
+ import { CREDIT_PERIODS, idList, money } from "./text.js";
8
8
  const failed = (error) => ({ ok: false, error });
9
9
  function findCatalogCard(catalog, id, today) {
10
10
  const card = catalog.cards.find((c) => c.id === id);
@@ -149,6 +149,40 @@ export function cardFields(card, held, input, today) {
149
149
  }
150
150
  fields.milestone_usage = usage;
151
151
  }
152
+ if (input.enrolled !== undefined) {
153
+ const needing = card.credits
154
+ .filter((c) => c.enroll === true)
155
+ .map((c) => c.id);
156
+ for (const id of input.enrolled) {
157
+ if (!needing.includes(id))
158
+ problems.push(needing.length > 0
159
+ ? `'${id}' isn't a ${card.id} credit that needs enrolling. Those are: ${idList(needing)}.`
160
+ : `${card.id} has no credits that need enrolling.`);
161
+ }
162
+ fields.enrolled = [...new Set(input.enrolled)];
163
+ }
164
+ if (input.creditUsed !== undefined) {
165
+ const usage = { ...held?.credit_usage };
166
+ const anniversary = fields.anniversary ?? held?.anniversary ?? null;
167
+ for (const [id, used] of Object.entries(input.creditUsed)) {
168
+ const credit = card.credits.find((c) => c.id === id);
169
+ if (!credit) {
170
+ problems.push(card.credits.length > 0
171
+ ? `${card.id} has no credit '${id}'. Its credits are: ${idList(card.credits.map((c) => c.id))}.`
172
+ : `${card.id} has no merchant credits.`);
173
+ continue;
174
+ }
175
+ if (!(used >= 0 && used <= credit.amount))
176
+ problems.push(`The '${id}' credit is ${money(credit.amount)} ${CREDIT_PERIODS[credit.period]}, so what's used must be $0 to ${money(credit.amount)}.`);
177
+ const period = periodKey(credit.period, today, anniversary);
178
+ if (period === null) {
179
+ problems.push(`The '${id}' credit resets on the card's anniversary; give the anniversary (MM-DD) too.`);
180
+ continue;
181
+ }
182
+ usage[id] = { period, used };
183
+ }
184
+ fields.credit_usage = usage;
185
+ }
152
186
  if (input.bonus === null)
153
187
  fields.bonus = null;
154
188
  else if (input.bonus !== undefined) {
@@ -215,6 +249,8 @@ const withFields = ({ bonus, checkout_label, opened, ...held }, f) => ({
215
249
  ...(f.milestone_usage === undefined
216
250
  ? {}
217
251
  : { milestone_usage: f.milestone_usage }),
252
+ ...(f.enrolled === undefined ? {} : { enrolled: f.enrolled }),
253
+ ...(f.credit_usage === undefined ? {} : { credit_usage: f.credit_usage }),
218
254
  ...(f.bonus ? { bonus: f.bonus } : {}),
219
255
  });
220
256
  // `wallet` is null when there isn't one yet; adding a card starts it.
@@ -3,12 +3,13 @@ import { activationGroup, capUsed, isActivated, pickedCategories, } from "./eval
3
3
  import { introUntil, introWindows, openedOn } from "./intro.js";
4
4
  import { membershipName } from "./memberships.js";
5
5
  import { milestoneSummaries } from "./milestones.js";
6
- import { optionName, ruleParts } from "./rule-text.js";
6
+ import { creditStates } from "./credits.js";
7
+ import { creditWhere, optionName, ruleParts } from "./rule-text.js";
7
8
  import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
8
9
  import { STALE_AFTER_DAYS } from "./recommend.js";
9
10
  import { rotationGap } from "./rotation.js";
10
11
  import { activationRules } from "./setup.js";
11
- import { CAP_PERIODS, categoryReadsOn, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
12
+ import { CAP_PERIODS, categoryReadsOn, creditBack, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, earnText, listed, PAYMENT_PHRASES, shortDate, wholeDollars, words, } from "./text.js";
12
13
  import { cardValuation, currencyValuation } from "./valuation.js";
13
14
  const matchKeys = (rule) => rule.match.choice !== undefined
14
15
  ? []
@@ -62,10 +63,12 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
62
63
  if (replaced)
63
64
  continue;
64
65
  const pending = r.requires?.activation && !isActivated(card, held, r.id);
66
+ const payment = r.requires?.payment;
65
67
  const key = [
66
68
  `${earnText(catalog, r.rate, currency)} on`,
67
69
  capLimit(card, shown, r),
68
70
  pending ? ', once activated' : '',
71
+ payment === undefined ? '' : ` ${PAYMENT_PHRASES[payment]}`,
69
72
  ].join('|');
70
73
  const whats = lines.get(key) ?? [];
71
74
  for (const part of ruleParts(catalog, r))
@@ -75,12 +78,13 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
75
78
  }
76
79
  return [
77
80
  ...[...lines].map(([key, whats]) => {
78
- const [head = '', limit = '', activate = ''] = key.split('|');
81
+ const [head = '', limit = '', activate = '', paid = ''] = key.split('|');
79
82
  const after = `${limit}${activate}`;
80
- return `${head} ${listed(whats)}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
83
+ return `${head} ${listed(whats)}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
81
84
  }),
82
85
  ...(shown.some((r) => r.match.any &&
83
86
  r.geography !== 'foreign_only' &&
87
+ r.requires?.payment === undefined &&
84
88
  r.rate > card.base_rate)
85
89
  ? []
86
90
  : [`${earnText(catalog, card.base_rate, currency)} on everything else`]),
@@ -200,6 +204,7 @@ export function walletSummary(catalog, wallet, today) {
200
204
  rotationGap: rotationGap(card, today),
201
205
  caps: capSummaries(catalog, card, held, today),
202
206
  milestones: milestoneSummaries(card, held, today),
207
+ credits: creditSummaries(catalog, card, held, today),
203
208
  chosen: held.chosen
204
209
  ? {
205
210
  period: held.chosen.period,
@@ -229,6 +234,29 @@ export function walletSummary(catalog, wallet, today) {
229
234
  cards,
230
235
  };
231
236
  }
237
+ function creditSummaries(catalog, card, held, today) {
238
+ return creditStates(catalog, card, held, today).map((s) => {
239
+ const status = !s.enrolled
240
+ ? `${ENROLL_FIRST}, then mark it enrolled`
241
+ : s.left === null
242
+ ? "give the card's anniversary to track it"
243
+ : `${creditLeft(s.left, s.period)}${s.until === null ? '' : `, through ${shortDate(s.until)}`}`;
244
+ return {
245
+ id: s.credit.id,
246
+ name: s.name,
247
+ amount: s.amount,
248
+ period: s.period,
249
+ enroll: s.credit.enroll === true,
250
+ enrolled: s.enrolled,
251
+ currentPeriod: s.key,
252
+ until: s.until,
253
+ used: s.used,
254
+ left: s.left,
255
+ tracked: held.credit_usage?.[s.credit.id] !== undefined,
256
+ text: `${creditBack(s)} ${creditWhere(catalog, s.credit)}: ${status}.`,
257
+ };
258
+ });
259
+ }
232
260
  function introSummary(card, held, today) {
233
261
  const [first] = card.intro_rates;
234
262
  if (!first)
@@ -9,11 +9,12 @@ export function cardText(catalog) {
9
9
  const cards = new Map(catalog.cards.map((c) => [c.id, c]));
10
10
  function shortName(id) {
11
11
  const name = cards.get(id)?.name ?? id;
12
- return name
12
+ return (name
13
13
  .replace(/^American Express /, 'Amex ')
14
14
  .replace(/^The Platinum Card from American Express$/, 'Amex Platinum')
15
- .replace(/ (Visa Signature |Visa Infinite )?(Credit )?Card$/, '')
16
- .replace(/ Card by /, ' by ');
15
+ // "Card" is part of Apple Card's name, not a suffix.
16
+ .replace(/ (Visa Signature |Visa Infinite )?(Credit )?(?<!^Apple )Card$/, '')
17
+ .replace(/ Card by /, ' by '));
17
18
  }
18
19
  function feeLabel(id) {
19
20
  const card = cards.get(id);
@@ -15,5 +15,7 @@ export function formatCheatSheet(sheet) {
15
15
  }
16
16
  if (sheet.notes.length > 0)
17
17
  lines.push('', ...sheet.notes);
18
+ if (sheet.assumptions.length > 0)
19
+ lines.push('', ...sheet.assumptions);
18
20
  return `${lines.join('\n')}\n`;
19
21
  }
@@ -1,5 +1,5 @@
1
1
  import { membershipName, membershipNote, } from "../engine/memberships.js";
2
- import { choiceName, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
2
+ import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
3
3
  const membershipFees = (memberships) => memberships
4
4
  .map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
5
5
  .join('');
@@ -15,7 +15,23 @@ export function formatExploration(r, catalog, all) {
15
15
  return `No cards match at ${title}.\n`;
16
16
  const shown = all ? r.ranked : r.ranked.slice(0, TOP_BEST);
17
17
  const width = Math.max(...shown.map((e) => e.cardName.length));
18
- const lines = [`Best cards for ${title}:`, ''];
18
+ const lines = [
19
+ ...(r.credits.length === 0
20
+ ? []
21
+ : [
22
+ `${CREDIT_GROUP} at ${title}:`,
23
+ '',
24
+ ...r.credits.flatMap((c) => [
25
+ `- ${c.cardName}: ${c.text}${c.enroll ? ` (${ENROLL_FIRST})` : ''}. ${capitalize(feeText(c))}.`,
26
+ ...(c.evidence
27
+ ? [` "${c.evidence.quote}" (${hostname(c.evidence.url)})`]
28
+ : []),
29
+ ]),
30
+ '',
31
+ ]),
32
+ `Best cards for ${title}${r.credits.length === 0 ? '' : ' by rate'}:`,
33
+ '',
34
+ ];
19
35
  shown.forEach((e, i) => {
20
36
  const earned = e.earned === null ? '' : ` ${dollarsAndCents(e.earned)}`;
21
37
  lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
@@ -89,7 +105,7 @@ export function formatCatalogSimulation(r, catalog, all) {
89
105
  ]);
90
106
  if (notes.size > 0)
91
107
  lines.push('', ...notes);
92
- lines.push('', "Assumes each card's best picks, activations done each quarter, and no welcome bonuses.");
108
+ lines.push('', `Assumes each card's best picks, activations done each quarter, and no welcome bonuses. ${CREDITS_LEFT_OUT}`);
93
109
  return `${lines.join('\n')}\n`;
94
110
  }
95
111
  export function formatAdditions(r, catalog, all) {
@@ -117,6 +133,6 @@ export function formatAdditions(r, catalog, all) {
117
133
  ]);
118
134
  if (notes.size > 0)
119
135
  lines.push('', ...notes);
120
- lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh. Annual fees are counted in full: statement credits and perks that offset them are left out. Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.');
136
+ lines.push('', `Assumes your picks carry over, rotating categories get activated, and caps start fresh. Annual fees are counted in full: statement credits and perks that offset them are left out. ${CREDITS_LEFT_OUT} Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.`);
121
137
  return `${lines.join('\n')}\n`;
122
138
  }
@@ -33,7 +33,8 @@ function cardLines(cards, style, bonusPick) {
33
33
  const earned = (c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`) +
34
34
  (c.milestoneBonus === null
35
35
  ? ''
36
- : ` + ${money(c.milestoneBonus.dollars)} spend bonus`);
36
+ : ` + ${money(c.milestoneBonus.dollars)} spend bonus`) +
37
+ (c.credit?.dollars ? ` + ${money(c.credit.dollars)} credit` : '');
37
38
  const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
38
39
  lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
39
40
  for (const r of c.reasoning)
@@ -59,9 +60,12 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
59
60
  const milestone = top.milestoneBonus === null
60
61
  ? ''
61
62
  : `, plus a spend bonus worth about ${money(top.milestoneBonus.dollars)}`;
63
+ const credit = top.credit?.dollars
64
+ ? `, plus ${money(top.credit.dollars)} back from its ${top.credit.name} credit`
65
+ : '';
62
66
  const confidence = rec.decision.confidence;
63
67
  const why = rec.bonus ? ' for its welcome bonus' : '';
64
- lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}${milestone}.`)} ${capitalize(confidence)} confidence.`);
68
+ lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}${milestone}${credit}.`)} ${capitalize(confidence)} confidence.`);
65
69
  if (rec.bonus && rec.notes[0])
66
70
  lines.push(rec.notes[0]);
67
71
  }
@@ -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, } 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,8 +64,10 @@ 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, cardId, cap, bonus, milestones = [], credit = null) {
66
68
  const messages = [];
69
+ if (credit)
70
+ messages.push(`Used ${money(roundCents(credit.after - credit.before))} of ${cardId}'s ${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
71
  if (cap) {
68
72
  const left = Math.max(0, cap.capAmount - cap.after);
69
73
  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.`);
@@ -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(),
@@ -217,6 +246,18 @@ export const RecordResultSchema = z.object({
217
246
  before: z.number(),
218
247
  after: z.number(),
219
248
  })),
249
+ credit: z
250
+ .object({
251
+ cardId: z.string(),
252
+ creditId: z.string(),
253
+ name: z.string(),
254
+ period: z.string(),
255
+ periodKind: CreditPeriod,
256
+ amount: z.number(),
257
+ before: z.number(),
258
+ after: z.number(),
259
+ })
260
+ .nullable(),
220
261
  message: z.string(),
221
262
  });
222
263
  export const OfferComparisonSchema = z.object({
@@ -287,6 +328,14 @@ export const CardSearchSchema = z.object({
287
328
  text: z.string(),
288
329
  })),
289
330
  introRates: z.array(z.object({ id: z.string(), days: z.number(), text: z.string() })),
331
+ credits: z.array(z.object({
332
+ id: z.string(),
333
+ name: z.string(),
334
+ amount: z.number(),
335
+ period: CreditPeriod,
336
+ enroll: z.boolean(),
337
+ text: z.string(),
338
+ })),
290
339
  })),
291
340
  });
292
341
  export const WalletChangeSchema = z.object({
@@ -315,6 +364,25 @@ const ExcludedCardSchema = z.object({
315
364
  const Picks = z.record(z.string(), z.array(z.string()));
316
365
  export const ExplorationSchema = z.object({
317
366
  merchant: MerchantResolutionSchema,
367
+ // Cards with a statement credit at this store, shown before the ranking.
368
+ credits: z.array(z.object({
369
+ cardId: z.string(),
370
+ cardName: z.string(),
371
+ issuer: z.string(),
372
+ network: CardNetwork,
373
+ annualFee: z.number().nullable(),
374
+ firstYearFee: z.number().nullable(),
375
+ creditId: z.string(),
376
+ name: z.string(),
377
+ amount: z.number(),
378
+ period: CreditPeriod,
379
+ enroll: z.boolean(),
380
+ note: z.string().nullable(),
381
+ back: z.string(),
382
+ earn: z.string(),
383
+ text: z.string(),
384
+ evidence: z.object({ quote: z.string(), url: z.string() }).nullable(),
385
+ })),
318
386
  ranked: z.array(RankedCardSchema.extend({
319
387
  issuer: z.string(),
320
388
  network: CardNetwork,
@@ -403,6 +471,7 @@ export const CheatSheetSchema = z.object({
403
471
  note: z.string().nullable(),
404
472
  })),
405
473
  notes: z.array(z.string()),
474
+ assumptions: z.array(z.string()),
406
475
  });
407
476
  export const PurchaseAnswerSchema = RecommendationSchema.extend({
408
477
  // 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));