earnrate 0.2.16 → 0.2.17
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 +14 -8
- package/data/CHANGELOG.md +14 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-business-platinum.yaml +15 -1
- package/data/cards/barclays-jetblue-plus.yaml +19 -1
- package/data/cards/chase-freedom-flex.yaml +7 -7
- 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 +5 -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 +5 -1
- package/data/cards/chase-united-club-business.yaml +5 -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-strata-premier.yaml +14 -1
- package/data/cards/wells-fargo-autograph-journey.yaml +16 -1
- package/data/merchants/overrides.yaml +3 -3
- package/dist/cli/args.js +13 -7
- package/dist/cli/run.js +4 -0
- package/dist/cli/wallet.js +18 -8
- package/dist/data/integrity.js +13 -0
- package/dist/data/wallet-edit.js +5 -0
- package/dist/engine/checklist.js +25 -5
- package/dist/engine/credits.js +56 -7
- package/dist/engine/evaluate.js +21 -1
- package/dist/engine/explore.js +27 -9
- package/dist/engine/memberships.js +21 -9
- package/dist/engine/milestones.js +11 -3
- package/dist/engine/offers.js +9 -4
- package/dist/engine/recommend.js +21 -2
- package/dist/engine/rule-text.js +20 -7
- package/dist/engine/setup.js +7 -6
- package/dist/engine/simulate.js +43 -10
- package/dist/engine/text.js +46 -5
- package/dist/engine/wallet-change.js +18 -7
- package/dist/engine/wallet-summary.js +25 -10
- package/dist/format/card-text.js +2 -10
- package/dist/format/explore.js +12 -8
- package/dist/format/offers.js +1 -1
- package/dist/format/wallet.js +2 -0
- package/dist/mcp/inputs.js +9 -1
- package/dist/mcp/schemas.js +23 -0
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/wallet.js +5 -1
- package/dist/schema/card.js +11 -0
- package/dist/schema/wallet.js +6 -2
- package/package.json +1 -1
package/dist/engine/rule-text.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { findMerchant } from "../merchants/resolve.js";
|
|
2
|
-
import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, PICK_PERIODS, words, } from "./text.js";
|
|
2
|
+
import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, PICK_PERIODS, wholeDollars, words, } from "./text.js";
|
|
3
3
|
// What a rule or a pick covers, in words, for answers and wallet lists.
|
|
4
4
|
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
5
5
|
// lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
|
|
@@ -31,11 +31,13 @@ export function ruleParts(catalog, rule, card, { everyStore = false } = {}) {
|
|
|
31
31
|
return [
|
|
32
32
|
rule.geography === 'foreign_only'
|
|
33
33
|
? 'purchases outside the U.S.'
|
|
34
|
-
: rule.
|
|
35
|
-
?
|
|
36
|
-
:
|
|
37
|
-
? '
|
|
38
|
-
:
|
|
34
|
+
: rule.min_amount !== undefined
|
|
35
|
+
? `purchases of ${wholeDollars(rule.min_amount)} or more`
|
|
36
|
+
: rule.requires?.payment !== undefined
|
|
37
|
+
? 'purchases'
|
|
38
|
+
: card?.rules.every((r) => r.match.any)
|
|
39
|
+
? 'every purchase'
|
|
40
|
+
: 'everything else',
|
|
39
41
|
];
|
|
40
42
|
if (m.choice !== undefined) {
|
|
41
43
|
const choice = card?.choices.find((c) => c.id === m.choice);
|
|
@@ -85,7 +87,10 @@ export function shortenedMerchants(catalog, merchants) {
|
|
|
85
87
|
}
|
|
86
88
|
export function ruleWhat(catalog, rule, card, options = {}) {
|
|
87
89
|
const payment = rule.requires?.payment;
|
|
88
|
-
const
|
|
90
|
+
const parts = listed(ruleParts(catalog, rule, card, options));
|
|
91
|
+
const what = rule.min_amount === undefined || rule.match.any
|
|
92
|
+
? parts
|
|
93
|
+
: `${parts}, on purchases of ${wholeDollars(rule.min_amount)} or more`;
|
|
89
94
|
return payment === undefined ? what : `${what} ${PAYMENT_PHRASES[payment]}`;
|
|
90
95
|
}
|
|
91
96
|
// "on dining", "on your picks", "at Costco", "on Walmart+ membership": a
|
|
@@ -141,6 +146,14 @@ export function creditName(catalog, credit) {
|
|
|
141
146
|
...(credit.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
142
147
|
]);
|
|
143
148
|
}
|
|
149
|
+
// A short name for a list of credits: the first store a credit names
|
|
150
|
+
// ("Grubhub" for the dining credit at five chains); creditName has them all.
|
|
151
|
+
export function creditLabel(catalog, credit) {
|
|
152
|
+
const [first, ...more] = credit.match.merchants ?? [];
|
|
153
|
+
return first === undefined || more.length === 0
|
|
154
|
+
? creditName(catalog, credit)
|
|
155
|
+
: merchantName(catalog, first);
|
|
156
|
+
}
|
|
144
157
|
// "at Lululemon", "at restaurants", "on travel", "on Walmart+ membership":
|
|
145
158
|
// the category's or merchant's own preposition.
|
|
146
159
|
export const creditWhere = (catalog, credit) => `${(credit.match.categories ?? []).some((c) => categoryReadsOn(catalog, c)) ||
|
package/dist/engine/setup.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { isActivated, pickedCategories } from "./evaluate.js";
|
|
2
|
-
import { membership, membershipName, sameGroup } from "./memberships.js";
|
|
3
|
-
import { milestoneWhat } from "./milestones.js";
|
|
2
|
+
import { effectiveMemberships, membership, membershipName, sameGroup, } from "./memberships.js";
|
|
3
|
+
import { milestoneStarts, milestoneWhat } from "./milestones.js";
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
5
|
import { rotationGap, rotationGapText } from "./rotation.js";
|
|
6
6
|
import { creditName, creditWhere, introWhat } from "./rule-text.js";
|
|
7
|
-
import { creditBack, ENROLL_FIRST, idList, listed, possessive } from "./text.js";
|
|
7
|
+
import { creditBack, creditTerms, ENROLL_FIRST, idList, listed, possessive, } from "./text.js";
|
|
8
8
|
// Rules that start within this many days are worth activating now.
|
|
9
9
|
const ACTIVATION_LOOKAHEAD_DAYS = 45;
|
|
10
10
|
const searchWords = (s) => s
|
|
@@ -108,7 +108,7 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
108
108
|
period: m.period,
|
|
109
109
|
points: m.points ?? null,
|
|
110
110
|
rewards: m.rewards ?? [],
|
|
111
|
-
text: milestoneWhat(m).replace(/^the /, ''),
|
|
111
|
+
text: `${milestoneWhat(m).replace(/^the /, '')}${milestoneStarts(m, today)}`,
|
|
112
112
|
})),
|
|
113
113
|
introRates: card.intro_rates.map((i) => ({
|
|
114
114
|
id: i.id,
|
|
@@ -123,7 +123,7 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
123
123
|
amount: c.amount,
|
|
124
124
|
period: c.period,
|
|
125
125
|
enroll: c.enroll === true,
|
|
126
|
-
text: `${creditBack(c)} ${creditWhere(catalog, c)}${c.enroll ? `, ${ENROLL_FIRST}` : ''}`,
|
|
126
|
+
text: `${creditBack(c)} ${creditWhere(catalog, c)}${creditTerms(c)}${c.enroll ? `, ${ENROLL_FIRST}` : ''}`,
|
|
127
127
|
};
|
|
128
128
|
}),
|
|
129
129
|
};
|
|
@@ -177,7 +177,8 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
177
177
|
});
|
|
178
178
|
}
|
|
179
179
|
// With one tier of a group saved, the others aren't missing.
|
|
180
|
-
const
|
|
180
|
+
const has = effectiveMemberships(catalog, wallet.memberships, wallet.cards.map((c) => c.id));
|
|
181
|
+
const missing = membershipsUsed(card).filter((m) => !has.some((held) => held === m || sameGroup(catalog, held, m)));
|
|
181
182
|
if (missing.length > 0) {
|
|
182
183
|
notes.push({
|
|
183
184
|
kind: 'membership',
|
package/dist/engine/simulate.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { categoryResolution, findCategory, resolvePlace, } from "../merchants/resolve.js";
|
|
2
|
+
import { allCreditsYear, usedCreditsYear } from "./credits.js";
|
|
2
3
|
import { evaluateCard, purchaseContext, usableAt } from "./evaluate.js";
|
|
3
4
|
import { idealHolding, matchesFilters, } from "./explore.js";
|
|
4
5
|
import { membershipCosts, membershipNote, paidMemberships, } from "./memberships.js";
|
|
5
6
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
6
7
|
import { applyBooking, capBooking } from "./record.js";
|
|
7
|
-
import { milestoneWhat } from "./milestones.js";
|
|
8
|
+
import { liveMilestones, milestoneStarts, milestoneWhat } from "./milestones.js";
|
|
8
9
|
import { rotationGap } from "./rotation.js";
|
|
9
|
-
import { money, paymentNote, roundCents, wholeDollars, possessive, } from "./text.js";
|
|
10
|
+
import { creditsCountedText, money, paymentNote, roundCents, shortCardName, wholeDollars, possessive, } from "./text.js";
|
|
10
11
|
import { cardValuation } from "./valuation.js";
|
|
11
12
|
import { EPSILON, HALF_CENT } from "./compare.js";
|
|
12
13
|
const MONTHS = 12;
|
|
@@ -127,9 +128,11 @@ held = []) {
|
|
|
127
128
|
// cardmember-year total is the same twelve months' spending.
|
|
128
129
|
const milestones = [];
|
|
129
130
|
const milestoneNotes = [];
|
|
131
|
+
const lastMonth = monthDates(start).at(-1) ?? start;
|
|
130
132
|
for (const { card, cents } of cards) {
|
|
131
133
|
const spent = results.reduce((sum, r) => sum + (r.byCard.get(card.id) ?? 0), 0);
|
|
132
|
-
|
|
134
|
+
// One that starts during the year counts, as it will the year after.
|
|
135
|
+
for (const m of liveMilestones(card, lastMonth)) {
|
|
133
136
|
if (spent + EPSILON < m.spend)
|
|
134
137
|
continue;
|
|
135
138
|
const value = roundCents(((m.points ?? 0) * cents) / 100);
|
|
@@ -139,12 +142,25 @@ held = []) {
|
|
|
139
142
|
points: m.points ?? null,
|
|
140
143
|
value,
|
|
141
144
|
});
|
|
142
|
-
milestoneNotes.push(`${card.name}: ${wholeDollars(spent)} a year on it reaches ${milestoneWhat(m)}${m.points === undefined ? '' : ` (about ${money(value)}, counted in its rewards)`}, assuming the same spending each year.`);
|
|
145
|
+
milestoneNotes.push(`${shortCardName(card.name)}: ${wholeDollars(spent)} a year on it reaches ${milestoneWhat(m)}${milestoneStarts(m, start)}${m.points === undefined ? '' : ` (about ${money(value)}, counted in its rewards)`}, assuming the same spending each year.`);
|
|
143
146
|
}
|
|
144
147
|
}
|
|
145
148
|
const rewards = roundCents(lineResults.reduce((sum, l) => sum + l.earned, 0) +
|
|
146
149
|
milestones.reduce((sum, m) => sum + m.value, 0));
|
|
147
150
|
const annualFees = cards.reduce((sum, c) => sum + (c.card.annual_fee ?? 0), 0);
|
|
151
|
+
const credits = cards.flatMap(({ card }) => {
|
|
152
|
+
const holding = held.includes(card.id);
|
|
153
|
+
const raw = wallet.cards.find((h) => h.id === card.id);
|
|
154
|
+
const year = holding && raw
|
|
155
|
+
? usedCreditsYear(catalog, card, raw, start)
|
|
156
|
+
: allCreditsYear(catalog, card, start);
|
|
157
|
+
return year.creditIds.length === 0 ? [] : [{ ...year, held: holding }];
|
|
158
|
+
});
|
|
159
|
+
const creditSum = (heldOnes) => roundCents(credits
|
|
160
|
+
.filter((c) => c.held === heldOnes)
|
|
161
|
+
.reduce((sum, c) => sum + c.yearly, 0));
|
|
162
|
+
const heldCredits = creditSum(true);
|
|
163
|
+
const otherCredits = creditSum(false);
|
|
148
164
|
const paid = paidMemberships(catalog, {
|
|
149
165
|
memberships: wallet.memberships,
|
|
150
166
|
heldCards: held,
|
|
@@ -155,7 +171,12 @@ held = []) {
|
|
|
155
171
|
const notes = [...milestoneNotes];
|
|
156
172
|
for (const m of memberships)
|
|
157
173
|
notes.push(membershipNote(catalog, m));
|
|
158
|
-
const
|
|
174
|
+
for (const c of credits.filter((c) => c.held)) {
|
|
175
|
+
const card = cards.find((x) => x.card.id === c.cardId)?.card;
|
|
176
|
+
if (card)
|
|
177
|
+
notes.push(creditsCountedText(shortCardName(card.name), card.annual_fee ?? 0, c));
|
|
178
|
+
}
|
|
179
|
+
const paying = paymentNote(cards.map(({ card }) => ({ name: shortCardName(card.name), card })));
|
|
159
180
|
if (paying !== null)
|
|
160
181
|
notes.push(paying);
|
|
161
182
|
const dates = monthDates(start);
|
|
@@ -164,13 +185,13 @@ held = []) {
|
|
|
164
185
|
...new Set(dates.map((d) => rotationGap(card, d)).filter((g) => g !== null)),
|
|
165
186
|
];
|
|
166
187
|
if (gaps.length > 0)
|
|
167
|
-
notes.push(`${possessive(card.name)} rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
|
|
188
|
+
notes.push(`${possessive(shortCardName(card.name))} rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
|
|
168
189
|
if (card.annual_fee === undefined)
|
|
169
|
-
notes.push(`${possessive(card.name)} annual fee isn't on file, so none is subtracted.`);
|
|
190
|
+
notes.push(`${possessive(shortCardName(card.name))} annual fee isn't on file, so none is subtracted.`);
|
|
170
191
|
else if (!held.includes(card.id) &&
|
|
171
192
|
card.first_year_fee !== undefined &&
|
|
172
193
|
card.first_year_fee !== card.annual_fee)
|
|
173
|
-
notes.push(`${possessive(card.name)} fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
|
|
194
|
+
notes.push(`${possessive(shortCardName(card.name))} fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
|
|
174
195
|
}
|
|
175
196
|
return {
|
|
176
197
|
cards: wallet.cards.map((h) => ({
|
|
@@ -182,7 +203,11 @@ held = []) {
|
|
|
182
203
|
annualFees,
|
|
183
204
|
memberships,
|
|
184
205
|
membershipFees,
|
|
185
|
-
|
|
206
|
+
heldCredits,
|
|
207
|
+
net: roundCents(rewards + heldCredits - annualFees - membershipFees),
|
|
208
|
+
otherCredits,
|
|
209
|
+
netIfCreditsUsed: roundCents(rewards + heldCredits + otherCredits - annualFees - membershipFees),
|
|
210
|
+
credits,
|
|
186
211
|
lines: lineResults,
|
|
187
212
|
milestones,
|
|
188
213
|
notes,
|
|
@@ -317,6 +342,10 @@ function projectedWallet(wallet, catalog, start) {
|
|
|
317
342
|
...(held.anniversary !== undefined
|
|
318
343
|
? { anniversary: held.anniversary }
|
|
319
344
|
: {}),
|
|
345
|
+
...(held.enrolled !== undefined ? { enrolled: held.enrolled } : {}),
|
|
346
|
+
...(held.skipped_credits !== undefined
|
|
347
|
+
? { skipped_credits: held.skipped_credits }
|
|
348
|
+
: {}),
|
|
320
349
|
};
|
|
321
350
|
}),
|
|
322
351
|
};
|
|
@@ -339,13 +368,17 @@ export function simulateAdditions(catalog, wallet, lines, options) {
|
|
|
339
368
|
const extraRewards = roundCents(simulation.rewards - current.rewards);
|
|
340
369
|
const annualFee = card.annual_fee ?? 0;
|
|
341
370
|
const membershipFee = roundCents(simulation.membershipFees - current.membershipFees);
|
|
371
|
+
const gain = roundCents(extraRewards - annualFee - membershipFee);
|
|
372
|
+
const credits = allCreditsYear(catalog, card, options.start).yearly;
|
|
342
373
|
additions.push({
|
|
343
374
|
cardId: card.id,
|
|
344
375
|
picks: simulation.cards.at(-1)?.picks ?? {},
|
|
345
376
|
extraRewards,
|
|
346
377
|
annualFee,
|
|
347
378
|
membershipFee,
|
|
348
|
-
gain
|
|
379
|
+
gain,
|
|
380
|
+
credits,
|
|
381
|
+
gainIfCreditsUsed: roundCents(gain + credits),
|
|
349
382
|
simulation,
|
|
350
383
|
});
|
|
351
384
|
}
|
package/dist/engine/text.js
CHANGED
|
@@ -87,7 +87,7 @@ export function codesAs(catalog, merchant) {
|
|
|
87
87
|
const code = mcc === null ? '' : ` (MCC ${String(mcc)})`;
|
|
88
88
|
return `Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`;
|
|
89
89
|
}
|
|
90
|
-
// "
|
|
90
|
+
// "Point values: Membership Rewards (MR) at 1¢." for the currencies an
|
|
91
91
|
// answer ranks, or null when every card shown earns cash.
|
|
92
92
|
export function pointValuesText(catalog, shown) {
|
|
93
93
|
const values = new Map();
|
|
@@ -97,7 +97,7 @@ export function pointValuesText(catalog, shown) {
|
|
|
97
97
|
if (values.size === 0)
|
|
98
98
|
return null;
|
|
99
99
|
const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
|
|
100
|
-
return `
|
|
100
|
+
return `Point values: ${listed(list)}.`;
|
|
101
101
|
}
|
|
102
102
|
export const NETWORK_NAMES = {
|
|
103
103
|
visa: 'Visa',
|
|
@@ -115,6 +115,7 @@ export const PAYMENT_PHRASES = {
|
|
|
115
115
|
};
|
|
116
116
|
export const paidText = (payment) => `Counts this as paid ${PAYMENT_PHRASES[payment]}, as you said.`;
|
|
117
117
|
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.`;
|
|
118
|
+
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
119
|
// For answers that never ask how each purchase is paid (cheat sheets and
|
|
119
120
|
// simulations): which cards' rates that need a way of paying are left out.
|
|
120
121
|
export function paymentNote(cards) {
|
|
@@ -146,6 +147,14 @@ export function withArticle(name) {
|
|
|
146
147
|
: /^[aeiou]/i.test(name) && !/^(u[bnst]|u\.s\.|eu|one\b|once\b)/i.test(name);
|
|
147
148
|
return `${an ? 'an' : 'a'} ${name}`;
|
|
148
149
|
}
|
|
150
|
+
// A card's name as people say it: "Amex Blue Cash Preferred", not "American
|
|
151
|
+
// Express Blue Cash Preferred Card".
|
|
152
|
+
export const shortCardName = (name) => name
|
|
153
|
+
.replace(/^American Express /, 'Amex ')
|
|
154
|
+
.replace(/^The (.+) Card from American Express$/, 'Amex $1')
|
|
155
|
+
// "Card" is part of Apple Card's name, not a suffix.
|
|
156
|
+
.replace(/ (Visa Signature |Visa Infinite )?(Credit )?(?<!^Apple )Card$/, '')
|
|
157
|
+
.replace(/ Card by /, ' by ');
|
|
149
158
|
// "Amex Platinum's", "Capital One Savor Rewards'".
|
|
150
159
|
export const possessive = (name) => name.endsWith('s') ? `${name}'` : `${name}'s`;
|
|
151
160
|
export const listed = (parts, joiner = 'and') => {
|
|
@@ -225,14 +234,21 @@ export const CREDIT_THIS_PERIOD = {
|
|
|
225
234
|
export const ENROLL_FIRST = 'enroll first';
|
|
226
235
|
// The group of cards with a credit at the store, shown before the ranking.
|
|
227
236
|
export const CREDIT_GROUP = 'With a statement credit';
|
|
237
|
+
// A credit the cardholder said they don't use, in list_wallet.
|
|
238
|
+
export const CREDIT_SKIPPED = "marked as one you don't use, so it isn't counted";
|
|
228
239
|
// "$75 back each quarter"
|
|
229
240
|
export const creditBack = (c) => `${money(c.amount)} back ${CREDIT_PERIODS[c.period]}`;
|
|
241
|
+
const minPurchase = (amount) => `a purchase of ${wholeDollars(amount)} or more`;
|
|
242
|
+
// " on a purchase of $500 or more", " through Sep 30, 2027": the limits a
|
|
243
|
+
// credit's issuer sets beyond its amount and period.
|
|
244
|
+
export const creditTerms = (c) => `${c.min_amount === undefined ? '' : ` with ${minPurchase(c.min_amount)}`}${c.valid_to === undefined ? '' : ` through ${shortDate(c.valid_to)}`}`;
|
|
230
245
|
// "$75 back each quarter, then 1x Amex Membership Rewards"
|
|
231
|
-
export const creditThen = (c, earn) => `${creditBack(c)}, then ${earn}`;
|
|
246
|
+
export const creditThen = (c, earn) => `${creditBack(c)}${creditTerms(c)}, then ${earn}`;
|
|
232
247
|
// "$35 left this quarter"
|
|
233
248
|
export const creditLeft = (left, period) => `${money(left)} left ${CREDIT_THIS_PERIOD[period]}`;
|
|
234
249
|
// A line for an answer that uses the card's credit, or says why it doesn't.
|
|
235
250
|
export function creditAnswerText(c, dollars) {
|
|
251
|
+
const min = c.minAmount ?? null;
|
|
236
252
|
const credit = `${c.name} credit (${creditBack(c)})`;
|
|
237
253
|
if (!c.enrolled)
|
|
238
254
|
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 +257,9 @@ export function creditAnswerText(c, dollars) {
|
|
|
241
257
|
if (c.left <= 0)
|
|
242
258
|
return `Its ${c.name} credit is used up ${CREDIT_THIS_PERIOD[c.period]}${c.until === null ? '' : `; it renews after ${shortDate(c.until)}`}.`;
|
|
243
259
|
if (dollars === null)
|
|
244
|
-
return `Its ${credit} has ${creditLeft(c.left, c.period)}; give the amount to count it.`;
|
|
260
|
+
return `Its ${credit} has ${creditLeft(c.left, c.period)}; give the amount to count it${min === null ? '' : ` (it needs ${minPurchase(min)})`}.`;
|
|
261
|
+
if (dollars <= 0 && min !== null)
|
|
262
|
+
return `Its ${credit} needs ${minPurchase(min)}, so it doesn't count here.`;
|
|
245
263
|
return dollars < c.left
|
|
246
264
|
? `${money(dollars)} back from its ${credit}, on top of what it earns; ${creditLeft(roundCents(c.left - dollars), c.period)} after this.`
|
|
247
265
|
: `${money(dollars)} back from its ${credit}: all that's left ${CREDIT_THIS_PERIOD[c.period]}, on top of what it earns.`;
|
|
@@ -265,5 +283,28 @@ export const useCreditText = (card, c, left) => `Use your ${money(c.amount)} ${c
|
|
|
265
283
|
export const enrollCreditText = (card, names) => names.length === 1
|
|
266
284
|
? `Enroll in ${possessive(card)} ${names[0] ?? ''} credit with the issuer, then mark it enrolled.`
|
|
267
285
|
: `Enroll in ${possessive(card)} ${listed(names)} credits with the issuer, then mark them enrolled.`;
|
|
268
|
-
//
|
|
286
|
+
// Credits that need no enrolling, before the cardholder says whether they
|
|
287
|
+
// use them: they don't offset the annual fee until then.
|
|
288
|
+
export const askCreditText = (card, names) => names.length === 1
|
|
289
|
+
? `Do you use ${possessive(card)} ${names[0] ?? ''} credit? Mark it used and it counts against the annual fee.`
|
|
290
|
+
: `Do you use ${possessive(card)} ${listed(names)} credits? Mark them used and they count against the annual fee.`;
|
|
291
|
+
// The cheat sheet: its picks are by rate alone.
|
|
269
292
|
export const CREDITS_LEFT_OUT = 'Statement credits (dollars back at a named store) are left out.';
|
|
293
|
+
// "$424 a year: Dunkin', Uber, Grubhub and Resy"
|
|
294
|
+
export const creditsYearText = (c) => `${wholeDollars(c.yearly)} a year: ${listed(c.names)}`;
|
|
295
|
+
// A held card's credits the cardholder uses, counted against its fee:
|
|
296
|
+
// "Amex Gold's credits you use (Dunkin' and Uber) count against its fee: $204 a year."
|
|
297
|
+
export const creditsCountedText = (card, fee, c) => fee > 0
|
|
298
|
+
? `${possessive(card)} credits you use (${listed(c.names)}) count against its fee: ${wholeDollars(c.yearly)} a year.`
|
|
299
|
+
: `${possessive(card)} credits you use (${listed(c.names)}) add ${wholeDollars(c.yearly)} a year.`;
|
|
300
|
+
// Rankings of cards the person doesn't hold: the honest figure leaves
|
|
301
|
+
// credits out, and the one with them is shown beside it.
|
|
302
|
+
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.";
|
|
303
|
+
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.";
|
|
304
|
+
export const CREDITS_ONLY_IF = "They're worth that only if you'd spend at those places anyway.";
|
|
305
|
+
// "If you'd use all the statement credits on Amex Gold and Sapphire Reserve
|
|
306
|
+
// ($724 a year), the pair nets $X a year."
|
|
307
|
+
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.`;
|
|
308
|
+
// "If you'd use all its statement credits ($424 a year: …), it nets $X a
|
|
309
|
+
// year." `adds` for a card beside the ones already held.
|
|
310
|
+
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.`;
|
|
@@ -150,17 +150,25 @@ export function cardFields(card, held, input, today) {
|
|
|
150
150
|
fields.milestone_usage = usage;
|
|
151
151
|
}
|
|
152
152
|
if (input.enrolled !== undefined) {
|
|
153
|
-
const
|
|
154
|
-
.filter((c) => c.enroll === true)
|
|
155
|
-
.map((c) => c.id);
|
|
153
|
+
const ids = card.credits.map((c) => c.id);
|
|
156
154
|
for (const id of input.enrolled) {
|
|
157
|
-
if (!
|
|
158
|
-
problems.push(
|
|
159
|
-
?
|
|
160
|
-
: `${card.id} has no credits
|
|
155
|
+
if (!ids.includes(id))
|
|
156
|
+
problems.push(ids.length > 0
|
|
157
|
+
? `${card.id} has no credit '${id}'. Its credits are: ${idList(ids)}.`
|
|
158
|
+
: `${card.id} has no statement credits.`);
|
|
161
159
|
}
|
|
162
160
|
fields.enrolled = [...new Set(input.enrolled)];
|
|
163
161
|
}
|
|
162
|
+
if (input.skippedCredits !== undefined) {
|
|
163
|
+
const ids = card.credits.map((c) => c.id);
|
|
164
|
+
for (const id of input.skippedCredits) {
|
|
165
|
+
if (!ids.includes(id))
|
|
166
|
+
problems.push(ids.length > 0
|
|
167
|
+
? `${card.id} has no credit '${id}'. Its credits are: ${idList(ids)}.`
|
|
168
|
+
: `${card.id} has no statement credits.`);
|
|
169
|
+
}
|
|
170
|
+
fields.skipped_credits = [...new Set(input.skippedCredits)];
|
|
171
|
+
}
|
|
164
172
|
if (input.creditUsed !== undefined) {
|
|
165
173
|
const usage = { ...held?.credit_usage };
|
|
166
174
|
const anniversary = fields.anniversary ?? held?.anniversary ?? null;
|
|
@@ -251,6 +259,9 @@ const withFields = ({ bonus, checkout_label, opened, ...held }, f) => ({
|
|
|
251
259
|
: { milestone_usage: f.milestone_usage }),
|
|
252
260
|
...(f.enrolled === undefined ? {} : { enrolled: f.enrolled }),
|
|
253
261
|
...(f.credit_usage === undefined ? {} : { credit_usage: f.credit_usage }),
|
|
262
|
+
...(f.skipped_credits === undefined
|
|
263
|
+
? {}
|
|
264
|
+
: { skipped_credits: f.skipped_credits }),
|
|
254
265
|
...(f.bonus ? { bonus: f.bonus } : {}),
|
|
255
266
|
});
|
|
256
267
|
// `wallet` is null when there isn't one yet; adding a card starts it.
|
|
@@ -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,
|
|
@@ -243,13 +245,24 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
243
245
|
cards,
|
|
244
246
|
};
|
|
245
247
|
}
|
|
248
|
+
function usedCredits(catalog, card, held, today) {
|
|
249
|
+
const used = usedCreditsYear(catalog, card, held, today);
|
|
250
|
+
return {
|
|
251
|
+
usedCredits: used.yearly,
|
|
252
|
+
usedCreditsText: used.creditIds.length === 0
|
|
253
|
+
? null
|
|
254
|
+
: creditsCountedText(shortCardName(card.name), card.annual_fee ?? 0, used),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
246
257
|
function creditSummaries(catalog, card, held, today) {
|
|
247
258
|
return creditStates(catalog, card, held, today).map((s) => {
|
|
248
|
-
const status =
|
|
249
|
-
?
|
|
250
|
-
: s.
|
|
251
|
-
?
|
|
252
|
-
:
|
|
259
|
+
const status = s.skipped
|
|
260
|
+
? CREDIT_SKIPPED
|
|
261
|
+
: !s.enrolled
|
|
262
|
+
? `${ENROLL_FIRST}, then mark it enrolled`
|
|
263
|
+
: s.left === null
|
|
264
|
+
? "give the card's anniversary to track it"
|
|
265
|
+
: `${creditLeft(s.left, s.period)}${s.until === null ? '' : `, through ${shortDate(s.until)}`}`;
|
|
253
266
|
return {
|
|
254
267
|
id: s.credit.id,
|
|
255
268
|
name: s.name,
|
|
@@ -257,12 +270,14 @@ function creditSummaries(catalog, card, held, today) {
|
|
|
257
270
|
period: s.period,
|
|
258
271
|
enroll: s.credit.enroll === true,
|
|
259
272
|
enrolled: s.enrolled,
|
|
273
|
+
skipped: s.skipped,
|
|
274
|
+
confirmed: s.confirmed,
|
|
260
275
|
currentPeriod: s.key,
|
|
261
276
|
until: s.until,
|
|
262
277
|
used: s.used,
|
|
263
278
|
left: s.left,
|
|
264
279
|
tracked: held.credit_usage?.[s.credit.id] !== undefined,
|
|
265
|
-
text: `${creditBack(s)} ${creditWhere(catalog, s.credit)}: ${status}.`,
|
|
280
|
+
text: `${creditBack(s)} ${creditWhere(catalog, s.credit)}${creditTerms(s.credit)}: ${status}.`,
|
|
266
281
|
};
|
|
267
282
|
});
|
|
268
283
|
}
|
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, 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 } = {}) {
|
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
|
@@ -11,7 +11,7 @@ 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.' : ''}`);
|
|
14
|
+
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${wholeDollars(o.firstYearValue)} the first year${o.credits > 0 ? `, ${wholeDollars(o.firstYearValueIfCreditsUsed)} if you'd use all its credits (${wholeDollars(o.credits)} a 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
15
|
lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
|
|
16
16
|
if (o.ineligible)
|
|
17
17
|
lines.push(` ${o.ineligible}`);
|
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)
|
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()
|
|
@@ -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.'),
|