earnrate 0.2.16 → 0.2.18
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 +22 -10
- package/data/CHANGELOG.md +33 -1
- package/data/VERSION +1 -1
- package/data/cards/amex-business-gold.yaml +1 -0
- package/data/cards/amex-business-platinum.yaml +15 -1
- package/data/cards/amex-platinum.yaml +1 -1
- package/data/cards/barclays-jetblue-plus.yaml +19 -1
- package/data/cards/chase-freedom-flex.yaml +9 -12
- package/data/cards/chase-ihg-one-rewards-business.yaml +19 -3
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +5 -1
- package/data/cards/chase-ihg-one-rewards-premier.yaml +7 -1
- package/data/cards/chase-ihg-one-rewards.yaml +13 -2
- package/data/cards/chase-ink-business-cash.yaml +5 -1
- package/data/cards/chase-ink-business-unlimited.yaml +5 -1
- package/data/cards/chase-prime-visa.yaml +3 -3
- package/data/cards/chase-sapphire-reserve.yaml +12 -3
- package/data/cards/chase-united-business.yaml +8 -1
- package/data/cards/chase-united-club-business.yaml +10 -1
- package/data/cards/chase-united-club.yaml +5 -1
- package/data/cards/chase-united-explorer.yaml +5 -1
- package/data/cards/chase-united-quest.yaml +5 -1
- package/data/cards/citi-aadvantage-business.yaml +5 -0
- package/data/cards/citi-aadvantage-executive.yaml +2 -0
- package/data/cards/citi-aadvantage-globe.yaml +2 -0
- package/data/cards/citi-aadvantage-mileup.yaml +4 -0
- package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
- package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
- package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
- package/data/cards/citi-custom-cash.yaml +8 -10
- package/data/cards/citi-double-cash.yaml +1 -0
- package/data/cards/citi-my-best-buy-visa.yaml +1 -0
- package/data/cards/citi-strata-elite.yaml +2 -0
- package/data/cards/citi-strata-premier.yaml +16 -1
- package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
- package/data/cards/wells-fargo-autograph-journey.yaml +16 -1
- package/data/changes.yaml +2840 -0
- package/data/merchants/overrides.yaml +3 -3
- package/dist/cli/args.js +13 -7
- package/dist/cli/offers.js +21 -5
- package/dist/cli/run.js +18 -3
- package/dist/cli/wallet.js +46 -11
- package/dist/data/change-integrity.js +105 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +20 -0
- package/dist/data/load.js +5 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +21 -5
- package/dist/engine/changes.js +124 -0
- package/dist/engine/checklist.js +42 -7
- package/dist/engine/credits.js +56 -7
- package/dist/engine/evaluate.js +21 -1
- package/dist/engine/explore.js +52 -15
- package/dist/engine/memberships.js +21 -9
- package/dist/engine/milestones.js +11 -3
- package/dist/engine/offers.js +60 -6
- package/dist/engine/periods.js +7 -0
- package/dist/engine/recommend.js +21 -2
- package/dist/engine/renewal.js +101 -0
- package/dist/engine/rule-text.js +21 -13
- package/dist/engine/setup.js +12 -8
- package/dist/engine/simulate.js +57 -17
- package/dist/engine/text.js +110 -5
- package/dist/engine/wallet-change.js +34 -15
- package/dist/engine/wallet-summary.js +29 -10
- package/dist/format/card-text.js +10 -11
- package/dist/format/explore.js +12 -8
- package/dist/format/offers.js +5 -3
- package/dist/format/wallet-yaml.js +3 -0
- package/dist/format/wallet.js +8 -1
- package/dist/mcp/context.js +11 -3
- package/dist/mcp/inputs.js +12 -2
- package/dist/mcp/prompts.js +3 -3
- package/dist/mcp/schemas.js +47 -0
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +42 -24
- package/dist/mcp/tools/wallet.js +33 -9
- package/dist/schema/card.js +18 -0
- package/dist/schema/changes.js +73 -0
- package/dist/schema/wallet.js +11 -3
- package/package.json +1 -1
package/dist/engine/text.js
CHANGED
|
@@ -25,6 +25,8 @@ const dateWords = (date, options) => new Date(`${date}T12:00:00Z`).toLocaleDateS
|
|
|
25
25
|
export const shortDate = (date) => dateWords(date, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
26
26
|
export const longDate = (date) => dateWords(date, { month: 'long', day: 'numeric', year: 'numeric' });
|
|
27
27
|
export const monthYear = (date) => dateWords(date, { month: 'long', year: 'numeric' });
|
|
28
|
+
// "Q4 2026", from an ISO date.
|
|
29
|
+
export const quarterYear = (date) => `Q${String(Math.floor((Number(date.slice(5, 7)) - 1) / 3) + 1)} ${date.slice(0, 4)}`;
|
|
28
30
|
// Always cents ($4.00), for what one purchase earns. A loss reads −$4.00.
|
|
29
31
|
export const dollarsAndCents = (n) => `${n < 0 ? '−' : ''}$${Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
30
32
|
// Rounded to whole dollars ($2,800), for fees, caps and yearly totals.
|
|
@@ -87,7 +89,7 @@ export function codesAs(catalog, merchant) {
|
|
|
87
89
|
const code = mcc === null ? '' : ` (MCC ${String(mcc)})`;
|
|
88
90
|
return `Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`;
|
|
89
91
|
}
|
|
90
|
-
// "
|
|
92
|
+
// "Point values: Membership Rewards (MR) at 1¢." for the currencies an
|
|
91
93
|
// answer ranks, or null when every card shown earns cash.
|
|
92
94
|
export function pointValuesText(catalog, shown) {
|
|
93
95
|
const values = new Map();
|
|
@@ -97,7 +99,7 @@ export function pointValuesText(catalog, shown) {
|
|
|
97
99
|
if (values.size === 0)
|
|
98
100
|
return null;
|
|
99
101
|
const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
|
|
100
|
-
return `
|
|
102
|
+
return `Point values: ${listed(list)}.`;
|
|
101
103
|
}
|
|
102
104
|
export const NETWORK_NAMES = {
|
|
103
105
|
visa: 'Visa',
|
|
@@ -115,6 +117,7 @@ export const PAYMENT_PHRASES = {
|
|
|
115
117
|
};
|
|
116
118
|
export const paidText = (payment) => `Counts this as paid ${PAYMENT_PHRASES[payment]}, as you said.`;
|
|
117
119
|
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.`;
|
|
120
|
+
export const amountUnknownText = (name, amount, earn, standard) => `On a purchase of ${wholeDollars(amount)} or more, ${name} would earn ${earn} here, instead of the ${standard} it's ranked at. Give the amount to count it.`;
|
|
118
121
|
// For answers that never ask how each purchase is paid (cheat sheets and
|
|
119
122
|
// simulations): which cards' rates that need a way of paying are left out.
|
|
120
123
|
export function paymentNote(cards) {
|
|
@@ -146,6 +149,14 @@ export function withArticle(name) {
|
|
|
146
149
|
: /^[aeiou]/i.test(name) && !/^(u[bnst]|u\.s\.|eu|one\b|once\b)/i.test(name);
|
|
147
150
|
return `${an ? 'an' : 'a'} ${name}`;
|
|
148
151
|
}
|
|
152
|
+
// A card's name as people say it: "Amex Blue Cash Preferred", not "American
|
|
153
|
+
// Express Blue Cash Preferred Card".
|
|
154
|
+
export const shortCardName = (name) => name
|
|
155
|
+
.replace(/^American Express /, 'Amex ')
|
|
156
|
+
.replace(/^The (.+) Card from American Express$/, 'Amex $1')
|
|
157
|
+
// "Card" is part of Apple Card's name, not a suffix.
|
|
158
|
+
.replace(/ (Visa Signature |Visa Infinite )?(Credit )?(?<!^Apple )Card$/, '')
|
|
159
|
+
.replace(/ Card by /, ' by ');
|
|
149
160
|
// "Amex Platinum's", "Capital One Savor Rewards'".
|
|
150
161
|
export const possessive = (name) => name.endsWith('s') ? `${name}'` : `${name}'s`;
|
|
151
162
|
export const listed = (parts, joiner = 'and') => {
|
|
@@ -196,6 +207,36 @@ export const PICK_PERIODS = {
|
|
|
196
207
|
calendar_year: 'each year',
|
|
197
208
|
until_changed: 'until you change it',
|
|
198
209
|
};
|
|
210
|
+
// "the 2 categories you pick each quarter", or for a card whose issuer
|
|
211
|
+
// applies the bonus itself, "the category you spend the most in each month".
|
|
212
|
+
// `oneOf` reads as one option of several: "one of the 2 categories …".
|
|
213
|
+
export function choicePhrase(c, oneOf = false) {
|
|
214
|
+
const which = c.pick === 1
|
|
215
|
+
? 'the category'
|
|
216
|
+
: `${oneOf ? 'one of ' : ''}the ${String(c.pick)} categories`;
|
|
217
|
+
const when = c.period === 'until_changed' ? '' : ` ${PICK_PERIODS[c.period]}`;
|
|
218
|
+
return c.automatic
|
|
219
|
+
? `${which} you spend the most in${when}`
|
|
220
|
+
: `${which} you pick${when}`;
|
|
221
|
+
}
|
|
222
|
+
// What earning a picked rate takes, for an answer's conditions: "Needs
|
|
223
|
+
// dining as its top-category pick (picked each month)", or for an automatic
|
|
224
|
+
// choice, "Counts when dining is the category you spend the most in that
|
|
225
|
+
// month".
|
|
226
|
+
export function pickCondition(c, options, name) {
|
|
227
|
+
if (c.automatic) {
|
|
228
|
+
const period = {
|
|
229
|
+
month: ' that month',
|
|
230
|
+
quarter: ' that quarter',
|
|
231
|
+
calendar_year: ' that year',
|
|
232
|
+
until_changed: '',
|
|
233
|
+
}[c.period];
|
|
234
|
+
return c.pick === 1
|
|
235
|
+
? `Counts when ${options} is the category you spend the most in${period}`
|
|
236
|
+
: `Counts when ${options} is one of the ${String(c.pick)} categories you spend the most in${period}`;
|
|
237
|
+
}
|
|
238
|
+
return `Needs ${options} as its ${name} pick (${c.period === 'until_changed' ? '' : 'picked '}${PICK_PERIODS[c.period]})`;
|
|
239
|
+
}
|
|
199
240
|
// Intro rates (a card's first 365 days): "first year", "first 90 days".
|
|
200
241
|
export const introPeriod = (days) => days === 365 ? 'first year' : `first ${String(days)} days`;
|
|
201
242
|
export const introAdjective = (days) => days === 365 ? 'first-year' : `${String(days)}-day`;
|
|
@@ -225,14 +266,21 @@ export const CREDIT_THIS_PERIOD = {
|
|
|
225
266
|
export const ENROLL_FIRST = 'enroll first';
|
|
226
267
|
// The group of cards with a credit at the store, shown before the ranking.
|
|
227
268
|
export const CREDIT_GROUP = 'With a statement credit';
|
|
269
|
+
// A credit the cardholder said they don't use, in list_wallet.
|
|
270
|
+
export const CREDIT_SKIPPED = "marked as one you don't use, so it isn't counted";
|
|
228
271
|
// "$75 back each quarter"
|
|
229
272
|
export const creditBack = (c) => `${money(c.amount)} back ${CREDIT_PERIODS[c.period]}`;
|
|
273
|
+
const minPurchase = (amount) => `a purchase of ${wholeDollars(amount)} or more`;
|
|
274
|
+
// " on a purchase of $500 or more", " through Sep 30, 2027": the limits a
|
|
275
|
+
// credit's issuer sets beyond its amount and period.
|
|
276
|
+
export const creditTerms = (c) => `${c.min_amount === undefined ? '' : ` with ${minPurchase(c.min_amount)}`}${c.valid_to === undefined ? '' : ` through ${shortDate(c.valid_to)}`}`;
|
|
230
277
|
// "$75 back each quarter, then 1x Amex Membership Rewards"
|
|
231
|
-
export const creditThen = (c, earn) => `${creditBack(c)}, then ${earn}`;
|
|
278
|
+
export const creditThen = (c, earn) => `${creditBack(c)}${creditTerms(c)}, then ${earn}`;
|
|
232
279
|
// "$35 left this quarter"
|
|
233
280
|
export const creditLeft = (left, period) => `${money(left)} left ${CREDIT_THIS_PERIOD[period]}`;
|
|
234
281
|
// A line for an answer that uses the card's credit, or says why it doesn't.
|
|
235
282
|
export function creditAnswerText(c, dollars) {
|
|
283
|
+
const min = c.minAmount ?? null;
|
|
236
284
|
const credit = `${c.name} credit (${creditBack(c)})`;
|
|
237
285
|
if (!c.enrolled)
|
|
238
286
|
return `Has ${withArticle(credit)} here; ${ENROLL_FIRST} with the issuer, then mark it enrolled in your wallet. It isn't counted until then.`;
|
|
@@ -241,7 +289,9 @@ export function creditAnswerText(c, dollars) {
|
|
|
241
289
|
if (c.left <= 0)
|
|
242
290
|
return `Its ${c.name} credit is used up ${CREDIT_THIS_PERIOD[c.period]}${c.until === null ? '' : `; it renews after ${shortDate(c.until)}`}.`;
|
|
243
291
|
if (dollars === null)
|
|
244
|
-
return `Its ${credit} has ${creditLeft(c.left, c.period)}; give the amount to count it.`;
|
|
292
|
+
return `Its ${credit} has ${creditLeft(c.left, c.period)}; give the amount to count it${min === null ? '' : ` (it needs ${minPurchase(min)})`}.`;
|
|
293
|
+
if (dollars <= 0 && min !== null)
|
|
294
|
+
return `Its ${credit} needs ${minPurchase(min)}, so it doesn't count here.`;
|
|
245
295
|
return dollars < c.left
|
|
246
296
|
? `${money(dollars)} back from its ${credit}, on top of what it earns; ${creditLeft(roundCents(c.left - dollars), c.period)} after this.`
|
|
247
297
|
: `${money(dollars)} back from its ${credit}: all that's left ${CREDIT_THIS_PERIOD[c.period]}, on top of what it earns.`;
|
|
@@ -265,5 +315,60 @@ export const useCreditText = (card, c, left) => `Use your ${money(c.amount)} ${c
|
|
|
265
315
|
export const enrollCreditText = (card, names) => names.length === 1
|
|
266
316
|
? `Enroll in ${possessive(card)} ${names[0] ?? ''} credit with the issuer, then mark it enrolled.`
|
|
267
317
|
: `Enroll in ${possessive(card)} ${listed(names)} credits with the issuer, then mark them enrolled.`;
|
|
268
|
-
//
|
|
318
|
+
// Credits that need no enrolling, before the cardholder says whether they
|
|
319
|
+
// use them: they don't offset the annual fee until then.
|
|
320
|
+
export const askCreditText = (card, names) => names.length === 1
|
|
321
|
+
? `Do you use ${possessive(card)} ${names[0] ?? ''} credit? Mark it used and it counts against the annual fee.`
|
|
322
|
+
: `Do you use ${possessive(card)} ${listed(names)} credits? Mark them used and they count against the annual fee.`;
|
|
323
|
+
// The cheat sheet: its picks are by rate alone.
|
|
269
324
|
export const CREDITS_LEFT_OUT = 'Statement credits (dollars back at a named store) are left out.';
|
|
325
|
+
// "$424 a year: Dunkin', Uber, Grubhub and Resy"
|
|
326
|
+
export const creditsYearText = (c) => `${wholeDollars(c.yearly)} a year: ${listed(c.names)}`;
|
|
327
|
+
// A held card's credits the cardholder uses, counted against its fee:
|
|
328
|
+
// "Amex Gold's credits you use (Dunkin' and Uber) count against its fee: $204 a year."
|
|
329
|
+
export const creditsCountedText = (card, fee, c) => fee > 0
|
|
330
|
+
? `${possessive(card)} credits you use (${listed(c.names)}) count against its fee: ${wholeDollars(c.yearly)} a year.`
|
|
331
|
+
: `${possessive(card)} credits you use (${listed(c.names)}) add ${wholeDollars(c.yearly)} a year.`;
|
|
332
|
+
// Rankings of cards the person doesn't hold: the honest figure leaves
|
|
333
|
+
// credits out, and the one with them is shown beside it.
|
|
334
|
+
export const CREDITS_APART = "Statement credits (dollars back at named stores) aren't in the ranking: they're worth it only if you'd spend at those places anyway. Where a card has them, what it nets if you'd use them all is shown apart.";
|
|
335
|
+
export const HELD_CREDITS_COUNTED = "Statement credits on your own cards count against their fees once you've said you use them: marked enrolled, or marked used (or already used) for ones that need no enrolling.";
|
|
336
|
+
export const CREDITS_ONLY_IF = "They're worth that only if you'd spend at those places anyway.";
|
|
337
|
+
// "If you'd use all the statement credits on Amex Gold and Sapphire Reserve
|
|
338
|
+
// ($724 a year), the pair nets $X a year."
|
|
339
|
+
export const pairCreditsUsedText = (cards, yearly, dollars) => `If you'd use all the statement credits on ${listed(cards)} (${wholeDollars(yearly)} a year), the pair nets ${wholeDollars(dollars)} a year.`;
|
|
340
|
+
// "If you'd use all its statement credits ($424 a year: …), it nets $X a
|
|
341
|
+
// year." `adds` for a card beside the ones already held.
|
|
342
|
+
export const ifCreditsUsedText = (c, dollars, verb = 'nets', owner = 'its') => `If you'd use all ${owner} statement credits (${creditsYearText(c)}), it ${verb} ${wholeDollars(dollars)} a year.`;
|
|
343
|
+
// The renewal check on the to-do list. A card well ahead gets one short
|
|
344
|
+
// line; one that's close or behind gets the parts of the math.
|
|
345
|
+
export function renewalText(name, issuer, r) {
|
|
346
|
+
const fee = r.firstYearFee === null
|
|
347
|
+
? `${money(r.fee)} fee`
|
|
348
|
+
: `${money(r.fee)} fee, up from ${money(r.firstYearFee)} the first year`;
|
|
349
|
+
const when = `${name} renews around ${shortDate(r.due)} (${fee})`;
|
|
350
|
+
if (r.net >= r.fee)
|
|
351
|
+
return `${when}: it's paying for itself, about ${wholeDollars(r.net)} a year ahead after the fee.`;
|
|
352
|
+
const basis = r.typical ? 'At typical spending' : 'On your spending';
|
|
353
|
+
const over = r.against === null ? 'your other cards would' : `${r.against} would`;
|
|
354
|
+
const earns = Math.round(r.rewardsAdded) > 0
|
|
355
|
+
? `it earns about ${wholeDollars(r.rewardsAdded)} a year more than ${over}`
|
|
356
|
+
: Math.round(r.rewardsAdded) < 0
|
|
357
|
+
? `it earns about ${wholeDollars(-r.rewardsAdded)} a year less than ${over}`
|
|
358
|
+
: `it earns about the same as ${over}`;
|
|
359
|
+
const credits = r.credits > 0 ? `, plus ${wholeDollars(r.credits)} in credits you use` : '';
|
|
360
|
+
const result = r.net >= 0
|
|
361
|
+
? `about ${wholeDollars(r.net)} a year ahead after the fee.`
|
|
362
|
+
: `about ${wholeDollars(-r.net)} a year short of the fee. Check with ${issuer} before the fee posts.`;
|
|
363
|
+
return `${when}. ${basis}, ${earns}${credits}: ${result}`;
|
|
364
|
+
}
|
|
365
|
+
// The next-card planner's figures: "about $1,690 over the first two years
|
|
366
|
+
// ($1,290 the first year, then $394 a year)".
|
|
367
|
+
export const twoYearText = (p) => `about ${wholeDollars(p.twoYears)} over the first two years (${wholeDollars(p.yearOne)} the first year, then ${wholeDollars(p.yearTwo)} a year${p.yearTwo < 0 ? ' after its fee' : ''})`;
|
|
368
|
+
// Whether a welcome offer's minimum spend fits, at the person's spending.
|
|
369
|
+
export function minimumSpendText(spend, months, m, typical) {
|
|
370
|
+
const whose = typical ? 'typical spending' : 'your spending';
|
|
371
|
+
return m.reachable
|
|
372
|
+
? `The ${money(spend)} minimum takes about ${String(m.weeks)} weeks of all ${whose}.`
|
|
373
|
+
: `The ${money(spend)} minimum in ${String(months)} months is more than ${whose}, so the bonus isn't counted.`;
|
|
374
|
+
}
|
|
@@ -2,7 +2,7 @@ import { CARD_NUMBER_LABEL, labelHasCardNumber } from "../schema/card-number.js"
|
|
|
2
2
|
import { WALLET_VERSION, } from "../schema/wallet.js";
|
|
3
3
|
import { addMonths } from "./bonus.js";
|
|
4
4
|
import { capByKey } from "./evaluate.js";
|
|
5
|
-
import { choicePeriod, periodKey } from "./periods.js";
|
|
5
|
+
import { choicePeriod, isAnniversary, periodKey } from "./periods.js";
|
|
6
6
|
import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
|
|
7
7
|
import { CREDIT_PERIODS, idList, money } from "./text.js";
|
|
8
8
|
const failed = (error) => ({ ok: false, error });
|
|
@@ -15,13 +15,6 @@ function findCatalogCard(catalog, id, today) {
|
|
|
15
15
|
? `No card '${id}' in earnrate. Closest: ${idList(near)}.`
|
|
16
16
|
: `No card '${id}' in earnrate. Search the catalog to find its id.`;
|
|
17
17
|
}
|
|
18
|
-
function isAnniversary(text) {
|
|
19
|
-
const m = /^(\d{2})-(\d{2})$/.exec(text);
|
|
20
|
-
if (!m)
|
|
21
|
-
return false;
|
|
22
|
-
const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
|
|
23
|
-
return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
|
|
24
|
-
}
|
|
25
18
|
const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d) &&
|
|
26
19
|
new Date(`${d}T00:00:00Z`).toISOString().startsWith(d);
|
|
27
20
|
function pickKey(card, today) {
|
|
@@ -150,17 +143,25 @@ export function cardFields(card, held, input, today) {
|
|
|
150
143
|
fields.milestone_usage = usage;
|
|
151
144
|
}
|
|
152
145
|
if (input.enrolled !== undefined) {
|
|
153
|
-
const
|
|
154
|
-
.filter((c) => c.enroll === true)
|
|
155
|
-
.map((c) => c.id);
|
|
146
|
+
const ids = card.credits.map((c) => c.id);
|
|
156
147
|
for (const id of input.enrolled) {
|
|
157
|
-
if (!
|
|
158
|
-
problems.push(
|
|
159
|
-
?
|
|
160
|
-
: `${card.id} has no credits
|
|
148
|
+
if (!ids.includes(id))
|
|
149
|
+
problems.push(ids.length > 0
|
|
150
|
+
? `${card.id} has no credit '${id}'. Its credits are: ${idList(ids)}.`
|
|
151
|
+
: `${card.id} has no statement credits.`);
|
|
161
152
|
}
|
|
162
153
|
fields.enrolled = [...new Set(input.enrolled)];
|
|
163
154
|
}
|
|
155
|
+
if (input.skippedCredits !== undefined) {
|
|
156
|
+
const ids = card.credits.map((c) => c.id);
|
|
157
|
+
for (const id of input.skippedCredits) {
|
|
158
|
+
if (!ids.includes(id))
|
|
159
|
+
problems.push(ids.length > 0
|
|
160
|
+
? `${card.id} has no credit '${id}'. Its credits are: ${idList(ids)}.`
|
|
161
|
+
: `${card.id} has no statement credits.`);
|
|
162
|
+
}
|
|
163
|
+
fields.skipped_credits = [...new Set(input.skippedCredits)];
|
|
164
|
+
}
|
|
164
165
|
if (input.creditUsed !== undefined) {
|
|
165
166
|
const usage = { ...held?.credit_usage };
|
|
166
167
|
const anniversary = fields.anniversary ?? held?.anniversary ?? null;
|
|
@@ -251,6 +252,9 @@ const withFields = ({ bonus, checkout_label, opened, ...held }, f) => ({
|
|
|
251
252
|
: { milestone_usage: f.milestone_usage }),
|
|
252
253
|
...(f.enrolled === undefined ? {} : { enrolled: f.enrolled }),
|
|
253
254
|
...(f.credit_usage === undefined ? {} : { credit_usage: f.credit_usage }),
|
|
255
|
+
...(f.skipped_credits === undefined
|
|
256
|
+
? {}
|
|
257
|
+
: { skipped_credits: f.skipped_credits }),
|
|
254
258
|
...(f.bonus ? { bonus: f.bonus } : {}),
|
|
255
259
|
});
|
|
256
260
|
// `wallet` is null when there isn't one yet; adding a card starts it.
|
|
@@ -327,6 +331,21 @@ export function withMemberships(catalog, wallet, memberships) {
|
|
|
327
331
|
notes: [],
|
|
328
332
|
};
|
|
329
333
|
}
|
|
334
|
+
// Issuers to leave out of next-card answers. [] clears the list.
|
|
335
|
+
export function withSkippedIssuers(catalog, wallet, issuers) {
|
|
336
|
+
const known = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
|
|
337
|
+
const unknown = issuers.filter((i) => !known.includes(i));
|
|
338
|
+
if (unknown.length > 0)
|
|
339
|
+
return failed(`No card earnrate knows is from ${unknown.map((i) => `'${i}'`).join(', ')}. Issuers: ${idList(known)}.`);
|
|
340
|
+
const { skipped_issuers: _, ...rest } = wallet;
|
|
341
|
+
const list = [...new Set(issuers)];
|
|
342
|
+
return {
|
|
343
|
+
ok: true,
|
|
344
|
+
wallet: list.length > 0 ? { ...rest, skipped_issuers: list } : rest,
|
|
345
|
+
fields: {},
|
|
346
|
+
notes: [],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
330
349
|
// Point values in cents per point, by currency id; null goes back to
|
|
331
350
|
// earnrate's default for that currency.
|
|
332
351
|
export function withValuations(catalog, wallet, changes) {
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { bonusStatus } from "./bonus.js";
|
|
2
2
|
import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
|
|
3
3
|
import { introUntil, introWindows, openedOn } from "./intro.js";
|
|
4
|
-
import { membershipName } from "./memberships.js";
|
|
4
|
+
import { effectiveMemberships, membershipName } from "./memberships.js";
|
|
5
5
|
import { milestoneSummaries } from "./milestones.js";
|
|
6
|
-
import { creditStates } from "./credits.js";
|
|
6
|
+
import { creditStates, usedCreditsYear } from "./credits.js";
|
|
7
7
|
import { creditWhere, optionName, ruleParts, rulePreposition, } from "./rule-text.js";
|
|
8
8
|
import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
|
|
9
9
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
10
10
|
import { rotationGap } from "./rotation.js";
|
|
11
11
|
import { activationRules } from "./setup.js";
|
|
12
|
-
import { CAP_PERIODS, creditBack, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, earnText, listed, PAYMENT_PHRASES, shortDate, wholeDollars, words, } from "./text.js";
|
|
12
|
+
import { CAP_PERIODS, creditBack, creditTerms, creditsCountedText, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, CREDIT_SKIPPED, earnText, listed, PAYMENT_PHRASES, shortCardName, shortDate, wholeDollars, words, } from "./text.js";
|
|
13
13
|
import { cardValuation, currencyValuation } from "./valuation.js";
|
|
14
14
|
const matchKeys = (rule) => rule.match.choice !== undefined
|
|
15
15
|
? []
|
|
@@ -34,11 +34,12 @@ function capLimit(card, shown, rule) {
|
|
|
34
34
|
// lower tier an uncapped, always-on higher rate fully covers (Prime Visa's 3%
|
|
35
35
|
// without Prime, once Prime is held). Rules at the same rate share a line.
|
|
36
36
|
function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
37
|
+
const has = effectiveMemberships(catalog, wallet.memberships, wallet.cards.map((c) => c.id));
|
|
37
38
|
const shown = card.rules
|
|
38
39
|
.filter((r) => inEffect(r, today) &&
|
|
39
40
|
r.rate > card.base_rate &&
|
|
40
41
|
(r.requires?.membership === undefined ||
|
|
41
|
-
|
|
42
|
+
has.includes(r.requires.membership)))
|
|
42
43
|
.sort((a, b) => b.rate - a.rate);
|
|
43
44
|
const lines = new Map();
|
|
44
45
|
for (const r of shown) {
|
|
@@ -214,6 +215,7 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
214
215
|
caps: capSummaries(catalog, card, held, today),
|
|
215
216
|
milestones: milestoneSummaries(card, held, today),
|
|
216
217
|
credits: creditSummaries(catalog, card, held, today),
|
|
218
|
+
...usedCredits(catalog, card, held, today),
|
|
217
219
|
chosen: held.chosen
|
|
218
220
|
? {
|
|
219
221
|
period: held.chosen.period,
|
|
@@ -238,18 +240,33 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
238
240
|
return {
|
|
239
241
|
memberships: wallet.memberships,
|
|
240
242
|
membershipNames: wallet.memberships.map((m) => membershipName(catalog, m)),
|
|
243
|
+
skippedIssuers: (wallet.skipped_issuers ?? []).map((id) => ({
|
|
244
|
+
id,
|
|
245
|
+
name: catalog.issuers.find((i) => i.id === id)?.name ?? id,
|
|
246
|
+
})),
|
|
241
247
|
pointValues: pointValues(catalog, wallet),
|
|
242
248
|
started: null,
|
|
243
249
|
cards,
|
|
244
250
|
};
|
|
245
251
|
}
|
|
252
|
+
function usedCredits(catalog, card, held, today) {
|
|
253
|
+
const used = usedCreditsYear(catalog, card, held, today);
|
|
254
|
+
return {
|
|
255
|
+
usedCredits: used.yearly,
|
|
256
|
+
usedCreditsText: used.creditIds.length === 0
|
|
257
|
+
? null
|
|
258
|
+
: creditsCountedText(shortCardName(card.name), card.annual_fee ?? 0, used),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
246
261
|
function creditSummaries(catalog, card, held, today) {
|
|
247
262
|
return creditStates(catalog, card, held, today).map((s) => {
|
|
248
|
-
const status =
|
|
249
|
-
?
|
|
250
|
-
: s.
|
|
251
|
-
?
|
|
252
|
-
:
|
|
263
|
+
const status = s.skipped
|
|
264
|
+
? CREDIT_SKIPPED
|
|
265
|
+
: !s.enrolled
|
|
266
|
+
? `${ENROLL_FIRST}, then mark it enrolled`
|
|
267
|
+
: s.left === null
|
|
268
|
+
? "give the card's anniversary to track it"
|
|
269
|
+
: `${creditLeft(s.left, s.period)}${s.until === null ? '' : `, through ${shortDate(s.until)}`}`;
|
|
253
270
|
return {
|
|
254
271
|
id: s.credit.id,
|
|
255
272
|
name: s.name,
|
|
@@ -257,12 +274,14 @@ function creditSummaries(catalog, card, held, today) {
|
|
|
257
274
|
period: s.period,
|
|
258
275
|
enroll: s.credit.enroll === true,
|
|
259
276
|
enrolled: s.enrolled,
|
|
277
|
+
skipped: s.skipped,
|
|
278
|
+
confirmed: s.confirmed,
|
|
260
279
|
currentPeriod: s.key,
|
|
261
280
|
until: s.until,
|
|
262
281
|
used: s.used,
|
|
263
282
|
left: s.left,
|
|
264
283
|
tracked: held.credit_usage?.[s.credit.id] !== undefined,
|
|
265
|
-
text: `${creditBack(s)} ${creditWhere(catalog, s.credit)}: ${status}.`,
|
|
284
|
+
text: `${creditBack(s)} ${creditWhere(catalog, s.credit)}${creditTerms(s.credit)}: ${status}.`,
|
|
266
285
|
};
|
|
267
286
|
});
|
|
268
287
|
}
|
package/dist/format/card-text.js
CHANGED
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
// Card names, fees and requirements as earnrate.dev and its static search
|
|
2
2
|
// pages show them. Node-free: the site bundles it.
|
|
3
3
|
import { membershipName, membershipText } from "../engine/memberships.js";
|
|
4
|
-
import { capSentence, earnText, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
|
|
4
|
+
import { capSentence, earnText, PICK_PERIODS, pickCondition, shortCardName, wholeDollars, words, } from "../engine/text.js";
|
|
5
5
|
// An issuer's name as people know it, from data/issuers.yaml.
|
|
6
6
|
export const issuerName = (catalog, id) => catalog.issuers.find((i) => i.id === id)?.name ?? id;
|
|
7
7
|
export function cardText(catalog) {
|
|
8
8
|
const cards = new Map(catalog.cards.map((c) => [c.id, c]));
|
|
9
|
-
|
|
10
|
-
const name = cards.get(id)?.name ?? id;
|
|
11
|
-
return (name
|
|
12
|
-
.replace(/^American Express /, 'Amex ')
|
|
13
|
-
.replace(/^The Platinum Card from American Express$/, 'Amex Platinum')
|
|
14
|
-
// "Card" is part of Apple Card's name, not a suffix.
|
|
15
|
-
.replace(/ (Visa Signature |Visa Infinite )?(Credit )?(?<!^Apple )Card$/, '')
|
|
16
|
-
.replace(/ Card by /, ' by '));
|
|
17
|
-
}
|
|
9
|
+
const shortName = (id) => shortCardName(cards.get(id)?.name ?? id);
|
|
18
10
|
// `membership: false` leaves out a membership the card needs, for lists
|
|
19
11
|
// whose own conditions say it only when it applies.
|
|
20
12
|
function feeLabel(id, { membership = true } = {}) {
|
|
@@ -35,7 +27,14 @@ export function cardText(catalog) {
|
|
|
35
27
|
function requirementLines(cardId, r, until = null) {
|
|
36
28
|
const lines = [];
|
|
37
29
|
if (r.pick)
|
|
38
|
-
lines.push(
|
|
30
|
+
lines.push(r.pick.automatic
|
|
31
|
+
? pickCondition({
|
|
32
|
+
pick: cards
|
|
33
|
+
.get(cardId)
|
|
34
|
+
?.choices.find((c) => c.id === r.pick?.choice)?.pick ?? 1,
|
|
35
|
+
...r.pick,
|
|
36
|
+
}, r.pick.options.map(words).join(' or '), '')
|
|
37
|
+
: `Pick ${r.pick.options.map(words).join(' or ')} as a bonus category (${PICK_PERIODS[r.pick.period]})`);
|
|
39
38
|
if (r.activation !== null)
|
|
40
39
|
lines.push(`Activate the bonus with ${issuerName(catalog, cards.get(cardId)?.issuer ?? '')} (free)`);
|
|
41
40
|
if (r.membership !== null)
|
package/dist/format/explore.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { membershipName, membershipNote, } from "../engine/memberships.js";
|
|
2
|
-
import { capitalize, choiceName, CREDIT_GROUP,
|
|
2
|
+
import { capitalize, choiceName, CREDIT_GROUP, CREDITS_APART, ENROLL_FIRST, HELD_CREDITS_COUNTED, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, listed, } from "../engine/text.js";
|
|
3
3
|
const membershipFees = (memberships) => memberships
|
|
4
4
|
.map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
|
|
5
5
|
.join('');
|
|
6
6
|
const TOP_BEST = 10;
|
|
7
|
+
// " · $X if you'd use all its credits ($424 a year)"
|
|
8
|
+
const ifCredits = (credits, net, sign = '', its = 'its') => credits > 0
|
|
9
|
+
? ` · ${sign}${wholeDollars(net)} if you'd use all ${its} credits (${wholeDollars(credits)} a year)`
|
|
10
|
+
: '';
|
|
7
11
|
export function formatExploration(r, catalog, all) {
|
|
8
12
|
const m = r.merchant;
|
|
9
13
|
const title = m.source === 'category'
|
|
@@ -83,15 +87,15 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
83
87
|
const card = s.cards[0];
|
|
84
88
|
if (!card)
|
|
85
89
|
return;
|
|
86
|
-
lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeDollars(s.net)} (${wholeDollars(s.rewards)} rewards, ${wholeDollars(s.annualFees)} fee${membershipFees(s.memberships)})${picksText(card.picks)}`);
|
|
90
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeDollars(s.net)} (${wholeDollars(s.rewards)} rewards, ${wholeDollars(s.annualFees)} fee${membershipFees(s.memberships)})${ifCredits(s.otherCredits, s.netIfCreditsUsed)}${picksText(card.picks)}`);
|
|
87
91
|
});
|
|
88
92
|
const pairs = r.pairs.slice(0, top);
|
|
89
93
|
if (pairs.length > 0) {
|
|
90
94
|
lines.push('', 'Best two-card pairs:');
|
|
91
95
|
pairs.forEach((s, i) => {
|
|
92
|
-
lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeDollars(s.net)}`);
|
|
96
|
+
lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeDollars(s.net)}${ifCredits(s.otherCredits, s.netIfCreditsUsed, '', 'their')}`);
|
|
93
97
|
if (s.alternatives.length > 0)
|
|
94
|
-
lines.push(` Same result with ${s.alternatives.map(name)
|
|
98
|
+
lines.push(` Same result with ${listed(s.alternatives.map(name), 'or')} as the second card.`);
|
|
95
99
|
for (const split of splitText(s, name))
|
|
96
100
|
lines.push(` ${split}`);
|
|
97
101
|
});
|
|
@@ -105,13 +109,13 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
105
109
|
]);
|
|
106
110
|
if (notes.size > 0)
|
|
107
111
|
lines.push('', ...notes);
|
|
108
|
-
lines.push('', `Assumes each card's best picks, activations done each quarter, and no welcome bonuses. ${
|
|
112
|
+
lines.push('', `Assumes each card's best picks, activations done each quarter, and no welcome bonuses. ${CREDITS_APART}`);
|
|
109
113
|
return `${lines.join('\n')}\n`;
|
|
110
114
|
}
|
|
111
115
|
export function formatAdditions(r, catalog, all) {
|
|
112
116
|
const name = cardNamer(catalog);
|
|
113
117
|
const lines = [
|
|
114
|
-
`Your wallet earns about ${wholeDollars(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeDollars(r.current.annualFees)} in annual fees: ${wholeDollars(r.current.net)} after fees` : ''}.`,
|
|
118
|
+
`Your wallet earns about ${wholeDollars(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeDollars(r.current.annualFees)} in annual fees${r.current.heldCredits > 0 ? ` and ${wholeDollars(r.current.heldCredits)} of credits you use` : ''}: ${wholeDollars(r.current.net)} after fees` : ''}.`,
|
|
115
119
|
];
|
|
116
120
|
for (const split of splitText(r.current, name))
|
|
117
121
|
lines.push(` ${split}`);
|
|
@@ -122,7 +126,7 @@ export function formatAdditions(r, catalog, all) {
|
|
|
122
126
|
else {
|
|
123
127
|
lines.push('', 'Cards that would add the most (after their annual fee and any membership they need):');
|
|
124
128
|
gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
|
|
125
|
-
lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeDollars(a.gain)} a year (+${wholeDollars(a.extraRewards)} rewards, ${wholeDollars(a.annualFee)} fee${membershipFees(a.simulation.memberships.filter((m) => m.cardIds.includes(a.cardId)))})${picksText(a.picks)}`);
|
|
129
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeDollars(a.gain)} a year (+${wholeDollars(a.extraRewards)} rewards, ${wholeDollars(a.annualFee)} fee${membershipFees(a.simulation.memberships.filter((m) => m.cardIds.includes(a.cardId)))})${ifCredits(a.credits, a.gainIfCreditsUsed, '+')}${picksText(a.picks)}`);
|
|
126
130
|
});
|
|
127
131
|
}
|
|
128
132
|
const notes = new Set([
|
|
@@ -133,6 +137,6 @@ export function formatAdditions(r, catalog, all) {
|
|
|
133
137
|
]);
|
|
134
138
|
if (notes.size > 0)
|
|
135
139
|
lines.push('', ...notes);
|
|
136
|
-
lines.push('', `Assumes your picks carry over, rotating categories get activated, and caps start fresh.
|
|
140
|
+
lines.push('', `Assumes your picks carry over, rotating categories get activated, and caps start fresh. ${r.current.heldCredits > 0 ? `${HELD_CREDITS_COUNTED} ` : ''}${CREDITS_APART} Perks like lounge access aren't counted. 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.`);
|
|
137
141
|
return `${lines.join('\n')}\n`;
|
|
138
142
|
}
|
package/dist/format/offers.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { offerTerms } from "../engine/offers.js";
|
|
2
|
-
import { capitalize, feeText, money, shortDate, wholeDollars, } from "../engine/text.js";
|
|
2
|
+
import { capitalize, feeText, minimumSpendText, money, shortDate, twoYearText, wholeDollars, } from "../engine/text.js";
|
|
3
3
|
const TOP = 10;
|
|
4
4
|
export function formatOffers(c, currencyName, all) {
|
|
5
5
|
if (c.offers.length === 0) {
|
|
@@ -11,8 +11,10 @@ export function formatOffers(c, currencyName, all) {
|
|
|
11
11
|
shown.forEach((o, i) => {
|
|
12
12
|
const weeks = (o.months * 52) / 12;
|
|
13
13
|
const pace = o.spend > 0 ? ` (about ${wholeDollars(o.spend / weeks)} a week)` : '';
|
|
14
|
-
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${wholeDollars(o.firstYearValue)} the first year`, ` ${offerTerms(o, currencyName(o.currency))}${pace}. ${capitalize(feeText(o))}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
|
|
15
|
-
lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
|
|
14
|
+
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} ${o.plan ? twoYearText(o.plan) : `about ${wholeDollars(o.firstYearValue)} the first year`}${o.credits > 0 ? `; its credits are ${wholeDollars(o.credits)} a year more if you'd use them all` : ''}`, ` ${offerTerms(o, currencyName(o.currency))}${pace}. ${capitalize(feeText(o))}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
|
|
15
|
+
lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ. The bonus alone is worth about ${wholeDollars(o.firstYearValue)} after the first year's fee.`);
|
|
16
|
+
if (o.plan?.minimumSpend)
|
|
17
|
+
lines.push(` ${minimumSpendText(o.spend, o.months, o.plan.minimumSpend, c.typical)}`);
|
|
16
18
|
if (o.ineligible)
|
|
17
19
|
lines.push(` ${o.ineligible}`);
|
|
18
20
|
if (o.note)
|
|
@@ -22,6 +22,9 @@ function compact(wallet) {
|
|
|
22
22
|
...(wallet.memberships.length > 0
|
|
23
23
|
? { memberships: wallet.memberships }
|
|
24
24
|
: {}),
|
|
25
|
+
...(wallet.skipped_issuers && wallet.skipped_issuers.length > 0
|
|
26
|
+
? { skipped_issuers: wallet.skipped_issuers }
|
|
27
|
+
: {}),
|
|
25
28
|
cards: wallet.cards.map((c) => Object.fromEntries(CARD_FIELDS.flatMap((k) => (isEmpty(c[k]) ? [] : [[k, c[k]]])))),
|
|
26
29
|
};
|
|
27
30
|
}
|
package/dist/format/wallet.js
CHANGED
|
@@ -41,6 +41,8 @@ export function formatWalletSummary(summary) {
|
|
|
41
41
|
lines.push(` Spend bonus (${m.id}): ${m.text}`);
|
|
42
42
|
for (const credit of c.credits)
|
|
43
43
|
lines.push(` Credit (${credit.id}): ${credit.text}`);
|
|
44
|
+
if (c.usedCreditsText !== null)
|
|
45
|
+
lines.push(` ${c.usedCreditsText}`);
|
|
44
46
|
if (c.anniversary !== null)
|
|
45
47
|
lines.push(` Account anniversary: ${c.anniversary}.`);
|
|
46
48
|
if (c.intro?.state === 'active' && c.intro.until !== null)
|
|
@@ -62,7 +64,12 @@ export function formatWalletSummary(summary) {
|
|
|
62
64
|
const values = summary.pointValues.length > 0
|
|
63
65
|
? `Point values in the wallet: ${summary.pointValues.map((v) => `${v.name} at ${String(v.centsPerUnit)}¢${v.redemption === null ? '' : ` (redeemed ${v.redemption})`}`).join(', ')}. Others use earnrate's defaults.`
|
|
64
66
|
: "Point values: earnrate's defaults.";
|
|
65
|
-
|
|
67
|
+
const skipped = summary.skippedIssuers.length > 0
|
|
68
|
+
? [
|
|
69
|
+
`Next-card answers leave out: ${summary.skippedIssuers.map((i) => `${i.name} (${i.id})`).join(', ')}.`,
|
|
70
|
+
]
|
|
71
|
+
: [];
|
|
72
|
+
return [...cards, memberships, ...skipped, values].join('\n');
|
|
66
73
|
}
|
|
67
74
|
export function formatBooking(amount, name, // the card's short name
|
|
68
75
|
cap, bonus, milestones = [], credit = null, capLabel = null) {
|
package/dist/mcp/context.js
CHANGED
|
@@ -80,6 +80,9 @@ export function makeContext(deps) {
|
|
|
80
80
|
return {
|
|
81
81
|
valuations: wallet?.valuations ?? {},
|
|
82
82
|
memberships: wallet?.memberships ?? [],
|
|
83
|
+
...(wallet?.skipped_issuers
|
|
84
|
+
? { skipped_issuers: wallet.skipped_issuers }
|
|
85
|
+
: {}),
|
|
83
86
|
};
|
|
84
87
|
}
|
|
85
88
|
catch {
|
|
@@ -95,9 +98,14 @@ export function makeContext(deps) {
|
|
|
95
98
|
})),
|
|
96
99
|
memberships: memberships ?? (await saved()).memberships,
|
|
97
100
|
}, deps.today());
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
if (typeof named === 'string')
|
|
102
|
+
return named;
|
|
103
|
+
const { valuations, skipped_issuers } = await saved();
|
|
104
|
+
return {
|
|
105
|
+
...named,
|
|
106
|
+
valuations,
|
|
107
|
+
...(skipped_issuers ? { skipped_issuers } : {}),
|
|
108
|
+
};
|
|
101
109
|
}
|
|
102
110
|
const read = await readableWallet();
|
|
103
111
|
if (typeof read === 'string')
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -53,6 +53,10 @@ const NamedCard = z.object({
|
|
|
53
53
|
.array(z.string())
|
|
54
54
|
.optional()
|
|
55
55
|
.describe("Statement credits the user has enrolled in with the issuer, as credit ids from search_cards. A credit that needs enrolling counts only when it's here. Ask; never assume."),
|
|
56
|
+
skippedCredits: z
|
|
57
|
+
.array(z.string())
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("Statement credit ids the user says they don't use. They don't count toward a pick or against the card's annual fee."),
|
|
56
60
|
opened: z
|
|
57
61
|
.string()
|
|
58
62
|
.optional()
|
|
@@ -73,7 +77,7 @@ const cardFields = {
|
|
|
73
77
|
.string()
|
|
74
78
|
.regex(/^\d{2}-\d{2}$/)
|
|
75
79
|
.optional()
|
|
76
|
-
.describe("The account's anniversary as MM-DD
|
|
80
|
+
.describe("The account's anniversary as MM-DD: for cards whose cap resets on it, and for a fee card's renewal check (whether it still pays for itself before the fee comes due). Optional; the opening date stands in. Ask; don't guess."),
|
|
77
81
|
opened: IsoDate.nullable()
|
|
78
82
|
.optional()
|
|
79
83
|
.describe('Optional: the day the account opened, YYYY-MM-DD. Only for cards with a first-year rate (search_cards lists it), which applies only while a purchase falls in the window; without a date the card is ranked at its standard rates. Ask the user; never guess or assume a card is new. null removes it.'),
|
|
@@ -92,11 +96,15 @@ const cardFields = {
|
|
|
92
96
|
enrolled: z
|
|
93
97
|
.array(z.string())
|
|
94
98
|
.optional()
|
|
95
|
-
.describe(
|
|
99
|
+
.describe("Statement credit ids the user has enrolled in with the issuer, or, for a credit that needs no enrolling, says they use (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, and any credit counts against the annual fee only once it's here (or has been used). Replaces the current list. Ask; never assume."),
|
|
96
100
|
creditUsed: z
|
|
97
101
|
.record(z.string(), z.number().nonnegative())
|
|
98
102
|
.optional()
|
|
99
103
|
.describe('Dollars of a statement credit already received this period: credit id → dollars, e.g. {"lululemon": 40}. Ask; recorded purchases add to it from then on.'),
|
|
104
|
+
skippedCredits: z
|
|
105
|
+
.array(z.string())
|
|
106
|
+
.optional()
|
|
107
|
+
.describe("Statement credit ids the user doesn't use, e.g. [\"dunkin\"] on Amex Gold. They're left out of picks, the to-do list and the annual-fee math; every other credit on the card counts against its fee once it's in enrolled (or, for one that needs no enrolling, has been used). Replaces the current list; [] counts them all again."),
|
|
100
108
|
bonus: z
|
|
101
109
|
.object({
|
|
102
110
|
opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
|
|
@@ -163,6 +171,7 @@ export function makeInputs(catalog) {
|
|
|
163
171
|
.optional()
|
|
164
172
|
.describe(`Instead of the user's saved point values (or earnrate's defaults): flat, points at their cash-out value, ${profileText('flat')}; or points-heavy, ${profileText('points-heavy')}, for people who transfer points to airlines and hotels.`),
|
|
165
173
|
};
|
|
174
|
+
const SkippedIssuers = z.array(z.enum(issuers));
|
|
166
175
|
const AllMemberships = Memberships.describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`);
|
|
167
176
|
return {
|
|
168
177
|
CategoryInput,
|
|
@@ -173,6 +182,7 @@ export function makeInputs(catalog) {
|
|
|
173
182
|
OfferFilters,
|
|
174
183
|
CatalogFilters,
|
|
175
184
|
AllMemberships,
|
|
185
|
+
SkippedIssuers,
|
|
176
186
|
};
|
|
177
187
|
}
|
|
178
188
|
// The catalog filters as exploreCards and simulate take them. Memberships or
|
package/dist/mcp/prompts.js
CHANGED
|
@@ -74,9 +74,9 @@ export function registerPrompts(server, deps, ctx) {
|
|
|
74
74
|
]));
|
|
75
75
|
server.registerPrompt('this-quarter', {
|
|
76
76
|
title: 'What should I do with my cards this quarter?',
|
|
77
|
-
description: 'Bonuses to activate, categories to pick, caps running low
|
|
77
|
+
description: 'Bonuses to activate, categories to pick, caps running low, welcome bonuses to finish and fee cards coming up for renewal, from your saved cards.',
|
|
78
78
|
}, () => ask('What should I do with my cards this quarter?', [
|
|
79
|
-
"Call earnrate's list_wallet: it starts with a to-do list. Walk through it, soonest first.",
|
|
79
|
+
"Call earnrate's list_wallet: it starts with a to-do list. Walk through it, soonest first. A renewal check says whether a fee card still pays for itself; give its math as stated, and don't recommend canceling or downgrading on your own.",
|
|
80
80
|
ctx.walletUse,
|
|
81
81
|
'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.',
|
|
82
82
|
]));
|
|
@@ -92,7 +92,7 @@ export function registerPrompts(server, deps, ctx) {
|
|
|
92
92
|
: []),
|
|
93
93
|
'Find each card with search_cards and check the match with the user, then save it with add_card.',
|
|
94
94
|
ctx.walletUse,
|
|
95
|
-
|
|
95
|
+
"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. For a card with an annual fee, offer to save its anniversary (or opening date) so earnrate can check before each renewal whether it still pays for itself; it's optional.",
|
|
96
96
|
'When done, call list_wallet and show its to-do list.',
|
|
97
97
|
]));
|
|
98
98
|
}
|