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.
- package/README.md +20 -7
- package/data/CHANGELOG.md +181 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-cash-everyday.yaml +28 -2
- package/data/cards/amex-blue-cash-preferred.yaml +19 -0
- package/data/cards/amex-business-gold.yaml +38 -3
- package/data/cards/amex-business-platinum.yaml +72 -3
- package/data/cards/amex-delta-skymiles-gold.yaml +27 -1
- package/data/cards/amex-delta-skymiles-platinum.yaml +33 -1
- package/data/cards/amex-delta-skymiles-reserve.yaml +33 -1
- package/data/cards/amex-gold.yaml +52 -0
- package/data/cards/amex-hilton-honors-aspire.yaml +23 -1
- package/data/cards/amex-hilton-honors-business.yaml +14 -1
- package/data/cards/amex-hilton-honors-surpass.yaml +14 -1
- package/data/cards/amex-marriott-bonvoy-brilliant.yaml +15 -1
- package/data/cards/amex-platinum.yaml +110 -0
- package/data/cards/apple-card.yaml +76 -0
- package/data/cards/bilt-obsidian.yaml +12 -2
- package/data/cards/bilt-palladium.yaml +12 -2
- package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
- package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -0
- package/data/cards/capital-one-venture-x-business.yaml +28 -1
- package/data/cards/capital-one-venture-x.yaml +30 -2
- package/data/cards/chase-aeroplan.yaml +14 -0
- package/data/cards/chase-ihg-one-rewards-business.yaml +27 -1
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +16 -0
- package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -0
- package/data/cards/chase-ink-business-cash.yaml +13 -0
- package/data/cards/chase-ink-business-unlimited.yaml +13 -2
- package/data/cards/chase-sapphire-preferred.yaml +11 -0
- package/data/cards/chase-sapphire-reserve.yaml +44 -4
- package/data/cards/chase-united-business.yaml +32 -0
- package/data/cards/chase-united-club-business.yaml +40 -0
- package/data/cards/chase-united-club.yaml +40 -0
- package/data/cards/chase-united-explorer.yaml +32 -0
- package/data/cards/chase-united-quest.yaml +40 -0
- package/data/cards/citi-aadvantage-executive.yaml +26 -1
- package/data/cards/citi-strata-elite.yaml +20 -1
- package/data/cards/fidelity-rewards-visa-signature.yaml +60 -0
- package/data/cards/navy-federal-cash-rewards-plus.yaml +61 -0
- package/data/cards/navy-federal-cash-rewards.yaml +60 -0
- package/data/cards/navy-federal-more-rewards-amex.yaml +92 -0
- package/data/cards/paypal-cashback-mastercard.yaml +13 -2
- package/data/cards/penfed-power-cash-rewards.yaml +47 -0
- package/data/cards/usbank-altitude-connect.yaml +8 -2
- package/data/cards/usbank-altitude-go.yaml +8 -2
- package/data/cards/usbank-smartly.yaml +107 -0
- package/data/cards/venmo-credit-card.yaml +151 -0
- package/data/cards/verizon-visa-card.yaml +122 -0
- package/data/currencies.yaml +14 -0
- package/data/issuers.yaml +22 -0
- package/data/memberships.yaml +35 -0
- package/data/merchants/overrides.yaml +448 -0
- package/data/merchants/seed.yaml +1082 -65
- package/dist/cli/args.js +18 -0
- package/dist/cli/explore.js +10 -1
- package/dist/cli/run.js +4 -1
- package/dist/cli/wallet.js +30 -3
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +22 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +25 -6
- package/dist/engine/cheat-sheet.js +17 -5
- package/dist/engine/checklist.js +25 -4
- package/dist/engine/credits.js +65 -0
- package/dist/engine/evaluate.js +38 -11
- package/dist/engine/explore.js +70 -7
- package/dist/engine/offers.js +2 -1
- package/dist/engine/periods.js +10 -3
- package/dist/engine/recommend.js +72 -20
- package/dist/engine/record.js +41 -1
- package/dist/engine/rotation.js +3 -0
- package/dist/engine/rule-text.js +14 -3
- package/dist/engine/setup.js +15 -4
- package/dist/engine/simulate.js +10 -1
- package/dist/engine/text.js +84 -0
- package/dist/engine/wallet-change.js +37 -1
- package/dist/engine/wallet-summary.js +32 -4
- package/dist/format/card-text.js +4 -3
- package/dist/format/cheat-sheet.js +2 -0
- package/dist/format/explore.js +20 -4
- package/dist/format/recommendation.js +6 -2
- package/dist/format/wallet.js +6 -2
- package/dist/import/analyze.js +7 -2
- package/dist/mcp/inputs.js +10 -0
- package/dist/mcp/prompts.js +9 -1
- package/dist/mcp/schemas.js +70 -1
- package/dist/mcp/tools/answers.js +9 -4
- package/dist/mcp/tools/wallet.js +15 -7
- package/dist/merchants/resolve.js +8 -0
- package/dist/schema/card.js +37 -3
- package/dist/schema/common.js +1 -1
- package/dist/schema/merchant.js +3 -0
- package/dist/schema/wallet.js +8 -0
- package/package.json +1 -1
package/dist/engine/text.js
CHANGED
|
@@ -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 {
|
|
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)
|
package/dist/format/card-text.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
.replace(/
|
|
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);
|
package/dist/format/explore.js
CHANGED
|
@@ -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 = [
|
|
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('',
|
|
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('',
|
|
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
|
}
|
package/dist/format/wallet.js
CHANGED
|
@@ -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.`);
|
package/dist/import/analyze.js
CHANGED
|
@@ -182,8 +182,13 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
182
182
|
date: line.date,
|
|
183
183
|
country: 'US',
|
|
184
184
|
};
|
|
185
|
-
|
|
186
|
-
const
|
|
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;
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -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.'),
|
package/dist/mcp/prompts.js
CHANGED
|
@@ -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.',
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -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));
|