earnrate 0.2.14 → 0.2.15
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 +10 -10
- package/data/CHANGELOG.md +12 -0
- package/data/VERSION +1 -1
- package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
- package/data/cards/discover-it-cash-back.yaml +1 -1
- package/data/cards/venmo-credit-card.yaml +1 -1
- package/data/categories.yaml +10 -0
- package/dist/cli/import.js +14 -11
- package/dist/cli/todo.js +3 -2
- package/dist/cli/wallet.js +49 -16
- package/dist/engine/cheat-sheet.js +12 -9
- package/dist/engine/checklist.js +4 -9
- package/dist/engine/credits.js +2 -2
- package/dist/engine/evaluate.js +1 -1
- package/dist/engine/explore.js +2 -2
- package/dist/engine/intro.js +1 -5
- package/dist/engine/memberships.js +2 -6
- package/dist/engine/offers.js +3 -5
- package/dist/engine/periods.js +5 -0
- package/dist/engine/profiles.js +2 -1
- package/dist/engine/recommend.js +19 -17
- package/dist/engine/record.js +1 -1
- package/dist/engine/rule-text.js +71 -23
- package/dist/engine/setup.js +4 -0
- package/dist/engine/text.js +23 -6
- package/dist/engine/wallet-change.js +5 -5
- package/dist/engine/wallet-summary.js +24 -21
- package/dist/format/card-text.js +2 -3
- package/dist/format/cards.js +2 -2
- package/dist/format/explore.js +2 -2
- package/dist/format/wallet-yaml.js +2 -2
- package/dist/format/wallet.js +5 -5
- package/dist/import/analyze.js +13 -1
- package/dist/mcp/context.js +2 -3
- package/dist/mcp/inputs.js +3 -3
- package/dist/mcp/schemas.js +1 -0
- package/dist/mcp/server.js +0 -2
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +8 -4
- package/dist/mcp/tools/wallet.js +10 -6
- package/dist/schema/card.js +6 -10
- package/package.json +2 -1
package/dist/engine/recommend.js
CHANGED
|
@@ -9,7 +9,7 @@ import { creditAt, creditDollars } from "./credits.js";
|
|
|
9
9
|
import { ruleWhat } from "./rule-text.js";
|
|
10
10
|
import { rotationGap } from "./rotation.js";
|
|
11
11
|
import { EPSILON, isClose } from "./compare.js";
|
|
12
|
-
import { CAP_ADJECTIVES, categoryPhrase, creditAnswerText, codesAs, currencyLabel, currencyName, earnText, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, possessive, } from "./text.js";
|
|
12
|
+
import { CAP_ADJECTIVES, categoryPhrase, creditAnswerText, codesAs, currencyLabel, currencyName, earnText, heldName, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, possessive, } from "./text.js";
|
|
13
13
|
import { cardValuation } from "./valuation.js";
|
|
14
14
|
export const STALE_AFTER_DAYS = 180;
|
|
15
15
|
// The engine only tells the U.S. from everywhere else.
|
|
@@ -17,7 +17,7 @@ export const FOREIGN_ASSUMPTION = 'Treats this as a purchase outside the U.S.: e
|
|
|
17
17
|
export const isForeign = (purchase) => purchase.country !== 'US';
|
|
18
18
|
// A fee that isn't on file counts as the usual 3%, so a card can't win
|
|
19
19
|
// abroad on a guess that it charges none.
|
|
20
|
-
|
|
20
|
+
const ASSUMED_FOREIGN_FEE = 3;
|
|
21
21
|
export const foreignFeeFor = (card, purchase) => isForeign(purchase)
|
|
22
22
|
? (card.foreign_transaction_fee ?? ASSUMED_FOREIGN_FEE)
|
|
23
23
|
: 0;
|
|
@@ -149,7 +149,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
149
149
|
const standard = earnText(catalog, intro ? best.standardRate : best.nominalRate, currency);
|
|
150
150
|
if (best.rule) {
|
|
151
151
|
const note = best.rule.note ? ` ${best.rule.note}` : '';
|
|
152
|
-
lines.push(`${standard} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
|
|
152
|
+
lines.push(`${standard} on ${ruleWhat(catalog, best.rule, card)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
|
|
153
153
|
}
|
|
154
154
|
else {
|
|
155
155
|
lines.push(`${standard}, its base rate. No bonus category applies here.`);
|
|
@@ -169,10 +169,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
169
169
|
const partners = card.pools_into?.when_wallet_has ?? [];
|
|
170
170
|
const heldPartners = wallet.cards
|
|
171
171
|
.filter((h) => partners.includes(h.id))
|
|
172
|
-
.map((h) => h
|
|
173
|
-
|
|
174
|
-
h.id);
|
|
175
|
-
lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
|
|
172
|
+
.map((h) => heldName(catalog, h));
|
|
173
|
+
lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${listed(heldPartners)}.`);
|
|
176
174
|
}
|
|
177
175
|
if (currency !== 'cash') {
|
|
178
176
|
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.evaluation.best.rate * valuation.centsPerUnit)} back.`);
|
|
@@ -196,17 +194,17 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
196
194
|
warnings.push(`Add this card's anniversary (MM-DD) to your wallet so its ${capLabel(best)} can be tracked.`);
|
|
197
195
|
}
|
|
198
196
|
else if (purchase.amount !== null && purchase.amount > remaining) {
|
|
199
|
-
warnings.push(`This purchase crosses the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)}: only ${money(remaining)} is left for ${period}.`);
|
|
197
|
+
warnings.push(`This purchase crosses the ${capLabel(best)} on ${ruleWhat(catalog, best.rule, card)}: only ${money(remaining)} is left for ${period}.`);
|
|
200
198
|
}
|
|
201
199
|
else if (remaining < cap.amount * NEAR_CAP_SHARE) {
|
|
202
|
-
warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)} for ${period}.`);
|
|
200
|
+
warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on ${ruleWhat(catalog, best.rule, card)} for ${period}.`);
|
|
203
201
|
}
|
|
204
202
|
}
|
|
205
203
|
if (nominal.rule &&
|
|
206
204
|
nominal.cap &&
|
|
207
205
|
nominal !== best &&
|
|
208
206
|
nominal.cap.remaining === 0) {
|
|
209
|
-
warnings.push(`The ${capLabel(nominal)} on ${ruleWhat(catalog, nominal.rule)} is used up for ${nominal.cap.period ?? 'this period'}, so it earns ${earnText(catalog, nominal.afterCap ?? nominal.cap.cap.then_rate, valuation.currency)} until it resets.`);
|
|
207
|
+
warnings.push(`The ${capLabel(nominal)} on ${ruleWhat(catalog, nominal.rule, card)} is used up for ${nominal.cap.period ?? 'this period'}, so it earns ${earnText(catalog, nominal.afterCap ?? nominal.cap.cap.then_rate, valuation.currency)} until it resets.`);
|
|
210
208
|
}
|
|
211
209
|
if (best.rule?.unverified !== undefined) {
|
|
212
210
|
warnings.push(`This rate isn't confirmed against the issuer's terms yet: ${best.rule.unverified}`);
|
|
@@ -275,7 +273,7 @@ function tieNote(entries) {
|
|
|
275
273
|
const first = tied[0];
|
|
276
274
|
if (!first)
|
|
277
275
|
return null;
|
|
278
|
-
const names = tied.map(displayName)
|
|
276
|
+
const names = listed(tied.map(displayName));
|
|
279
277
|
const verb = tied.length === 1 ? 'earns' : 'earn';
|
|
280
278
|
const why = winner.upside - first.upside > EPSILON
|
|
281
279
|
? 'because its points can be worth more'
|
|
@@ -285,11 +283,11 @@ function tieNote(entries) {
|
|
|
285
283
|
: 'by a fixed order; either works';
|
|
286
284
|
return `${names} also ${verb} ${pct(winner.rateReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
|
|
287
285
|
}
|
|
286
|
+
// "Chase Ultimate Rewards points", but not "IHG One Rewards points points".
|
|
287
|
+
const pointsName = (label) => /\b(points|miles)$/i.test(label) ? label : `${label} points`;
|
|
288
288
|
// Finds the closest point inside a currency's value range where the winner
|
|
289
289
|
// stops winning, varying one currency at a time. Returns are linear in the
|
|
290
290
|
// cents-per-point value, so each pair of cards flips at most once.
|
|
291
|
-
// "Chase Ultimate Rewards points", but not "IHG One Rewards points points".
|
|
292
|
-
const pointsName = (label) => /\b(points|miles)$/i.test(label) ? label : `${label} points`;
|
|
293
291
|
function sensitivity(catalog, entries) {
|
|
294
292
|
const [winner, ...rest] = entries;
|
|
295
293
|
if (!winner)
|
|
@@ -338,7 +336,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
338
336
|
const rule = e.evaluation.best.rule;
|
|
339
337
|
return rule &&
|
|
340
338
|
merchantFamily(catalog, merchant.merchantId).some((id) => (rule.match.merchants ?? []).includes(id))
|
|
341
|
-
? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
|
|
339
|
+
? [`${displayName(e)} (${ruleWhat(catalog, rule, e.card)})`]
|
|
342
340
|
: [];
|
|
343
341
|
});
|
|
344
342
|
assumptions.push(naming.length === 0
|
|
@@ -488,9 +486,13 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
488
486
|
const ties = !isBest && gap <= EPSILON;
|
|
489
487
|
const bonus = open && (open.behind || ties) ? open : null;
|
|
490
488
|
const best = winner?.effectiveReturnPct ?? 0;
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
489
|
+
const toGo = open
|
|
490
|
+
? `${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}`
|
|
491
|
+
: '';
|
|
492
|
+
if (open && isBest && !bonus)
|
|
493
|
+
notes.push(`${displayName(open.entry)} is the best card here anyway, and this counts toward its welcome bonus (${toGo}).`);
|
|
494
|
+
else if (open && !bonus && isClose(open.entry.effectiveReturnPct, best))
|
|
495
|
+
notes.push(`${displayName(open.entry)} earns ${pct(open.entry.rateReturnPct)} here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${pct(best)}, and would help its welcome bonus (${toGo}). You're on pace, so this uses the higher rate.`);
|
|
494
496
|
const chosen = bonus?.entry ?? winner;
|
|
495
497
|
// A tie among the top cards doesn't matter when a welcome bonus decides.
|
|
496
498
|
const tie = bonus ? null : tieNote(entries);
|
package/dist/engine/record.js
CHANGED
|
@@ -80,7 +80,7 @@ export function applyBonusBooking(wallet, b) {
|
|
|
80
80
|
}
|
|
81
81
|
// Every purchase on a card counts toward each of its spend milestones. None
|
|
82
82
|
// for a cardmember-year milestone on a card with no anniversary.
|
|
83
|
-
|
|
83
|
+
function milestoneBookings(catalog, wallet, purchase) {
|
|
84
84
|
const held = wallet.cards.find((c) => c.id === purchase.cardId);
|
|
85
85
|
const card = catalog.cards.find((c) => c.id === purchase.cardId);
|
|
86
86
|
if (!held || !card)
|
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, words, } from "./text.js";
|
|
2
|
+
import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, PICK_PERIODS, 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.
|
|
@@ -22,51 +22,99 @@ export function optionName(catalog, card, choiceId, option) {
|
|
|
22
22
|
parts.push('online shopping');
|
|
23
23
|
return parts.length > 0 ? listed(parts) : words(option);
|
|
24
24
|
}
|
|
25
|
-
// What a rule covers, as a list of phrases.
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// What a rule covers, as a list of phrases. With the card, a pick reads
|
|
26
|
+
// "the 2 categories you pick each quarter" and a rule for every purchase reads
|
|
27
|
+
// "every purchase" when it's the card's only one.
|
|
28
|
+
export function ruleParts(catalog, rule, card, { everyStore = false } = {}) {
|
|
29
|
+
const m = rule.match;
|
|
30
|
+
if (m.any)
|
|
28
31
|
return [
|
|
29
32
|
rule.geography === 'foreign_only'
|
|
30
33
|
? 'purchases outside the U.S.'
|
|
31
34
|
: rule.requires?.payment !== undefined
|
|
32
35
|
? 'purchases'
|
|
33
|
-
:
|
|
36
|
+
: card?.rules.every((r) => r.match.any)
|
|
37
|
+
? 'every purchase'
|
|
38
|
+
: 'everything else',
|
|
34
39
|
];
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
if (m.choice !== undefined) {
|
|
41
|
+
const choice = card?.choices.find((c) => c.id === m.choice);
|
|
42
|
+
if (!choice)
|
|
43
|
+
return [`your ${choiceName(m.choice)} picks`];
|
|
44
|
+
const which = choice.pick === 1
|
|
45
|
+
? 'the category'
|
|
46
|
+
: `the ${String(choice.pick)} categories`;
|
|
47
|
+
return [
|
|
48
|
+
`${which} you pick${choice.period === 'until_changed' ? '' : ` ${PICK_PERIODS[choice.period]}`}`,
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
const merchants = m.merchants ?? [];
|
|
52
|
+
const categories = (m.categories ?? []).map((c) => categoryPhrase(catalog, c));
|
|
53
|
+
const merchantParts = (everyStore ? null : shortenedMerchants(catalog, merchants)) ??
|
|
54
|
+
merchants.map((id) => merchantName(catalog, id));
|
|
55
|
+
// A rule naming both matches a purchase that is both.
|
|
56
|
+
if (merchants.length > 0 && categories.length > 0)
|
|
57
|
+
return [`${listed(merchantParts)} when it codes as ${listed(categories)}`];
|
|
58
|
+
const parts = [...categories, ...(merchants.length > 0 ? merchantParts : [])];
|
|
59
|
+
if (parts.length > 0)
|
|
60
|
+
return parts;
|
|
61
|
+
return m.mccs?.length
|
|
62
|
+
? [`purchases coded MCC ${m.mccs.map(String).join(', ')}`]
|
|
63
|
+
: [words(rule.id)];
|
|
64
|
+
}
|
|
65
|
+
// A long list of mostly one kind of store reads as "select streaming
|
|
66
|
+
// services": Amex's streaming list also names Audible, which isn't one.
|
|
67
|
+
// Null when the list is read out in full.
|
|
68
|
+
function shortenedKind(catalog, merchants) {
|
|
40
69
|
const counts = new Map();
|
|
41
|
-
for (const
|
|
42
|
-
const category = findMerchant(catalog,
|
|
70
|
+
for (const id of merchants) {
|
|
71
|
+
const category = findMerchant(catalog, id)?.category;
|
|
43
72
|
if (category)
|
|
44
73
|
counts.set(category, (counts.get(category) ?? 0) + 1);
|
|
45
74
|
}
|
|
46
75
|
const [kind, count = 0] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
|
|
47
|
-
|
|
76
|
+
return merchants.length > 4 &&
|
|
48
77
|
kind !== undefined &&
|
|
49
78
|
count >= merchants.length * 0.75
|
|
50
|
-
?
|
|
51
|
-
:
|
|
52
|
-
const parts = [
|
|
53
|
-
...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
54
|
-
...merchantParts,
|
|
55
|
-
];
|
|
56
|
-
return parts.length > 0 ? parts : [words(rule.id)];
|
|
79
|
+
? kind
|
|
80
|
+
: null;
|
|
57
81
|
}
|
|
58
|
-
export function
|
|
82
|
+
export function shortenedMerchants(catalog, merchants) {
|
|
83
|
+
const kind = shortenedKind(catalog, merchants);
|
|
84
|
+
return kind === null ? null : [`select ${categoryPhrase(catalog, kind)}`];
|
|
85
|
+
}
|
|
86
|
+
export function ruleWhat(catalog, rule, card, options = {}) {
|
|
59
87
|
const payment = rule.requires?.payment;
|
|
60
|
-
const what = listed(ruleParts(catalog, rule));
|
|
88
|
+
const what = listed(ruleParts(catalog, rule, card, options));
|
|
61
89
|
return payment === undefined ? what : `${what} ${PAYMENT_PHRASES[payment]}`;
|
|
62
90
|
}
|
|
91
|
+
// "on dining", "on your picks", "at Costco", "on Walmart+ membership": a
|
|
92
|
+
// category's own preposition, "at" for a store unless its data says "on" or
|
|
93
|
+
// it's a booking through a travel site ("on hotels through Amex Travel"), and
|
|
94
|
+
// a shortened list ("select streaming services") reads like its kind.
|
|
95
|
+
export function rulePreposition(catalog, rules) {
|
|
96
|
+
const readsOn = (r) => {
|
|
97
|
+
if (r.match.any === true || r.match.choice !== undefined)
|
|
98
|
+
return true;
|
|
99
|
+
const merchants = r.match.merchants ?? [];
|
|
100
|
+
const kind = shortenedKind(catalog, merchants);
|
|
101
|
+
return ((r.match.categories ?? []).every((c) => categoryReadsOn(catalog, c)) &&
|
|
102
|
+
(kind !== null
|
|
103
|
+
? categoryReadsOn(catalog, kind)
|
|
104
|
+
: merchants.every((id) => {
|
|
105
|
+
const merchant = findMerchant(catalog, id);
|
|
106
|
+
return (merchant?.preposition === 'on' || merchant?.kind === 'booking');
|
|
107
|
+
})));
|
|
108
|
+
};
|
|
109
|
+
return rules.length > 0 && rules.every(readsOn) ? 'on' : 'at';
|
|
110
|
+
}
|
|
63
111
|
// "double every rate for the first year", "an extra 3% cash back on your 3%
|
|
64
112
|
// picks for the first year".
|
|
65
113
|
export function introWhat(catalog, card, intro) {
|
|
66
114
|
const covered = card.rules.filter((r) => intro.rules?.includes(r.id));
|
|
67
115
|
const on = intro.rules === undefined
|
|
68
116
|
? 'every rate'
|
|
69
|
-
: [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join('; ');
|
|
117
|
+
: [...new Set(covered.map((r) => ruleWhat(catalog, r, card)))].join('; ');
|
|
70
118
|
const change = intro.multiply === undefined
|
|
71
119
|
? `an extra ${earnText(catalog, intro.add ?? 0, card.currency)} on ${on}`
|
|
72
120
|
: intro.multiply === 2
|
package/dist/engine/setup.js
CHANGED
|
@@ -77,6 +77,10 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
77
77
|
network: card.network,
|
|
78
78
|
currency: card.currency,
|
|
79
79
|
annualFee: card.annual_fee ?? null,
|
|
80
|
+
firstYearFee: card.first_year_fee !== undefined &&
|
|
81
|
+
card.first_year_fee !== card.annual_fee
|
|
82
|
+
? card.first_year_fee
|
|
83
|
+
: null,
|
|
80
84
|
foreignTransactionFee: card.foreign_transaction_fee,
|
|
81
85
|
business: card.business,
|
|
82
86
|
onlyAt: card.only_at ?? null,
|
package/dist/engine/text.js
CHANGED
|
@@ -48,6 +48,11 @@ export function feeText(fees) {
|
|
|
48
48
|
return `${money(annualFee)} annual fee (${firstYearFee === 0 ? 'waived' : money(firstYearFee)} the first year)`;
|
|
49
49
|
return `${money(annualFee)} annual fee`;
|
|
50
50
|
}
|
|
51
|
+
export function heldName(catalog, held) {
|
|
52
|
+
return (held.checkout_label ??
|
|
53
|
+
catalog.cards.find((c) => c.id === held.id)?.name ??
|
|
54
|
+
held.id);
|
|
55
|
+
}
|
|
51
56
|
export function currencyName(catalog, id) {
|
|
52
57
|
return catalog.currencies.find((c) => c.id === id)?.name ?? id;
|
|
53
58
|
}
|
|
@@ -129,6 +134,18 @@ export function paymentNote(cards) {
|
|
|
129
134
|
}
|
|
130
135
|
// A name with its own "and" ("drugstores and pharmacies") would run into the
|
|
131
136
|
// list's: those lists use commas only, with those names last.
|
|
137
|
+
// "Up to $1,500 of spending a quarter, then 1% cash back"
|
|
138
|
+
export const capSentence = (cap, then, of = 'spending', aside = '') => `Up to ${wholeDollars(cap.amount)} of ${of} ${CAP_PERIODS[cap.period]}${aside}, then ${then}`;
|
|
139
|
+
// "a Costco membership", "an Instacart credit", "an IHG card", "a Uber credit":
|
|
140
|
+
// by sound, so a leading "you" or "won" takes "a" and a letter said with a
|
|
141
|
+
// vowel sound ("an REI") takes "an".
|
|
142
|
+
export function withArticle(name) {
|
|
143
|
+
const initialism = /^[A-Z][A-Z&.-]{1,5}(?![a-z])/.test(name);
|
|
144
|
+
const an = initialism
|
|
145
|
+
? /^[AEFHILMNORSX]/.test(name)
|
|
146
|
+
: /^[aeiou]/i.test(name) && !/^(u[bnst]|u\.s\.|eu|one\b|once\b)/i.test(name);
|
|
147
|
+
return `${an ? 'an' : 'a'} ${name}`;
|
|
148
|
+
}
|
|
132
149
|
// "Amex Platinum's", "Capital One Savor Rewards'".
|
|
133
150
|
export const possessive = (name) => name.endsWith('s') ? `${name}'` : `${name}'s`;
|
|
134
151
|
export const listed = (parts, joiner = 'and') => {
|
|
@@ -168,7 +185,7 @@ export const CAP_ADJECTIVES = {
|
|
|
168
185
|
calendar_year: 'calendar-year',
|
|
169
186
|
cardmember_year: 'cardmember-year',
|
|
170
187
|
};
|
|
171
|
-
// "the $15,000 calendar-year spend", for a spend
|
|
188
|
+
// "the $15,000 calendar-year spend", for a spend bonus.
|
|
172
189
|
export const MILESTONE_PERIODS = {
|
|
173
190
|
calendar_year: 'calendar-year',
|
|
174
191
|
cardmember_year: 'cardmember-year',
|
|
@@ -190,7 +207,7 @@ export const introRateText = (earn, standard, days, until) => `${earn} in your $
|
|
|
190
207
|
// For a card whose opening date isn't known: it's never assumed to be new.
|
|
191
208
|
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)}.`;
|
|
192
209
|
export const introEndsText = (name, days, until) => `${possessive(name)} ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
|
|
193
|
-
//
|
|
210
|
+
// Statement credits. Always dollars, never a percentage.
|
|
194
211
|
export const CREDIT_PERIODS = {
|
|
195
212
|
month: 'each month',
|
|
196
213
|
quarter: 'each quarter',
|
|
@@ -207,7 +224,7 @@ export const CREDIT_THIS_PERIOD = {
|
|
|
207
224
|
};
|
|
208
225
|
export const ENROLL_FIRST = 'enroll first';
|
|
209
226
|
// The group of cards with a credit at the store, shown before the ranking.
|
|
210
|
-
export const CREDIT_GROUP = 'With a
|
|
227
|
+
export const CREDIT_GROUP = 'With a statement credit';
|
|
211
228
|
// "$75 back each quarter"
|
|
212
229
|
export const creditBack = (c) => `${money(c.amount)} back ${CREDIT_PERIODS[c.period]}`;
|
|
213
230
|
// "$75 back each quarter, then 1x Amex Membership Rewards"
|
|
@@ -218,9 +235,9 @@ export const creditLeft = (left, period) => `${money(left)} left ${CREDIT_THIS_P
|
|
|
218
235
|
export function creditAnswerText(c, dollars) {
|
|
219
236
|
const credit = `${c.name} credit (${creditBack(c)})`;
|
|
220
237
|
if (!c.enrolled)
|
|
221
|
-
return `Has
|
|
238
|
+
return `Has ${withArticle(credit)} here; ${ENROLL_FIRST} with the issuer, then mark it enrolled in your wallet. It isn't counted until then.`;
|
|
222
239
|
if (c.left === null)
|
|
223
|
-
return `Has
|
|
240
|
+
return `Has ${withArticle(credit)} here; give the card's anniversary to track it.`;
|
|
224
241
|
if (c.left <= 0)
|
|
225
242
|
return `Its ${c.name} credit is used up ${CREDIT_THIS_PERIOD[c.period]}${c.until === null ? '' : `; it renews after ${shortDate(c.until)}`}.`;
|
|
226
243
|
if (dollars === null)
|
|
@@ -249,4 +266,4 @@ export const enrollCreditText = (card, names) => names.length === 1
|
|
|
249
266
|
? `Enroll in ${possessive(card)} ${names[0] ?? ''} credit with the issuer, then mark it enrolled.`
|
|
250
267
|
: `Enroll in ${possessive(card)} ${listed(names)} credits with the issuer, then mark them enrolled.`;
|
|
251
268
|
// Simulations, the cheat sheet and welcome-offer comparisons.
|
|
252
|
-
export const CREDITS_LEFT_OUT = '
|
|
269
|
+
export const CREDITS_LEFT_OUT = 'Statement credits (dollars back at a named store) are left out.';
|
|
@@ -134,15 +134,15 @@ export function cardFields(card, held, input, today) {
|
|
|
134
134
|
const milestone = card.milestones.find((m) => m.id === id);
|
|
135
135
|
if (!milestone) {
|
|
136
136
|
problems.push(card.milestones.length > 0
|
|
137
|
-
? `${card.id} has no spend
|
|
138
|
-
: `${card.id} has no spend
|
|
137
|
+
? `${card.id} has no spend bonus '${id}'. Its milestones are: ${idList(card.milestones.map((m) => m.id))}.`
|
|
138
|
+
: `${card.id} has no spend bonuses.`);
|
|
139
139
|
continue;
|
|
140
140
|
}
|
|
141
141
|
if (!(spent >= 0))
|
|
142
|
-
problems.push('Spending toward a
|
|
142
|
+
problems.push('Spending toward a spend bonus must be $0 or more.');
|
|
143
143
|
const period = periodKey(milestone.period, today, anniversary);
|
|
144
144
|
if (period === null) {
|
|
145
|
-
problems.push(`The '${id}'
|
|
145
|
+
problems.push(`The '${id}' spend bonus resets on the card's anniversary; give the anniversary (MM-DD) too.`);
|
|
146
146
|
continue;
|
|
147
147
|
}
|
|
148
148
|
usage[id] = { period, spent };
|
|
@@ -169,7 +169,7 @@ export function cardFields(card, held, input, today) {
|
|
|
169
169
|
if (!credit) {
|
|
170
170
|
problems.push(card.credits.length > 0
|
|
171
171
|
? `${card.id} has no credit '${id}'. Its credits are: ${idList(card.credits.map((c) => c.id))}.`
|
|
172
|
-
: `${card.id} has no
|
|
172
|
+
: `${card.id} has no statement credits.`);
|
|
173
173
|
continue;
|
|
174
174
|
}
|
|
175
175
|
if (!(used >= 0 && used <= credit.amount))
|
|
@@ -4,12 +4,12 @@ import { introUntil, introWindows, openedOn } from "./intro.js";
|
|
|
4
4
|
import { membershipName } from "./memberships.js";
|
|
5
5
|
import { milestoneSummaries } from "./milestones.js";
|
|
6
6
|
import { creditStates } from "./credits.js";
|
|
7
|
-
import { creditWhere, optionName, ruleParts } from "./rule-text.js";
|
|
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,
|
|
12
|
+
import { CAP_PERIODS, creditBack, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, earnText, listed, PAYMENT_PHRASES, 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
|
? []
|
|
@@ -30,11 +30,9 @@ function capLimit(card, shown, rule) {
|
|
|
30
30
|
shown.filter((r) => r.cap === rule.cap).length > 1;
|
|
31
31
|
return `first ${wholeDollars(cap.amount)} ${CAP_PERIODS[cap.period]}${shared ? ', combined' : ''}`;
|
|
32
32
|
}
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// Visa's 3% without Prime, once Prime is held). Rules at the same rate share
|
|
37
|
-
// a line.
|
|
33
|
+
// Best first. Leaves out rates behind a membership the wallet lacks and a
|
|
34
|
+
// lower tier an uncapped, always-on higher rate fully covers (Prime Visa's 3%
|
|
35
|
+
// without Prime, once Prime is held). Rules at the same rate share a line.
|
|
38
36
|
function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
39
37
|
const shown = card.rules
|
|
40
38
|
.filter((r) => inEffect(r, today) &&
|
|
@@ -65,22 +63,28 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
65
63
|
const pending = r.requires?.activation && !isActivated(card, held, r.id);
|
|
66
64
|
const payment = r.requires?.payment;
|
|
67
65
|
const key = [
|
|
68
|
-
|
|
66
|
+
earnText(catalog, r.rate, currency),
|
|
69
67
|
capLimit(card, shown, r),
|
|
70
68
|
pending ? ', once activated' : '',
|
|
71
69
|
payment === undefined ? '' : ` ${PAYMENT_PHRASES[payment]}`,
|
|
72
70
|
].join('|');
|
|
73
|
-
const
|
|
74
|
-
|
|
71
|
+
const line = lines.get(key) ?? { at: [], on: [] };
|
|
72
|
+
const whats = line[rulePreposition(catalog, [r])];
|
|
73
|
+
for (const part of ruleParts(catalog, r, card))
|
|
75
74
|
if (!whats.includes(part))
|
|
76
75
|
whats.push(part);
|
|
77
|
-
lines.set(key,
|
|
76
|
+
lines.set(key, line);
|
|
78
77
|
}
|
|
79
78
|
return [
|
|
80
|
-
...[...lines].map(([key,
|
|
79
|
+
...[...lines].map(([key, { at, on }]) => {
|
|
81
80
|
const [head = '', limit = '', activate = '', paid = ''] = key.split('|');
|
|
82
81
|
const after = `${limit}${activate}`;
|
|
83
|
-
|
|
82
|
+
// "at gas stations and on transit" when one line mixes the two.
|
|
83
|
+
const where = [
|
|
84
|
+
...(at.length > 0 ? [`at ${listed(at)}`] : []),
|
|
85
|
+
...(on.length > 0 ? [`on ${listed(on)}`] : []),
|
|
86
|
+
].join(at.length > 1 ? ', and ' : ' and ');
|
|
87
|
+
return `${head} ${where}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
|
|
84
88
|
}),
|
|
85
89
|
...(shown.some((r) => r.match.any &&
|
|
86
90
|
r.geography !== 'foreign_only' &&
|
|
@@ -103,13 +107,11 @@ export function activationsDue(catalog, card, held, today) {
|
|
|
103
107
|
seen.add(g);
|
|
104
108
|
const rules = card.rules.filter((r) => group.includes(r.id));
|
|
105
109
|
const first = rules[0];
|
|
106
|
-
const categories = rules.flatMap((r) => r.match.categories ?? []);
|
|
107
|
-
const servicesOnly = categories.length > 0 &&
|
|
108
|
-
rules.every((r) => (r.match.merchants ?? []).length === 0) &&
|
|
109
|
-
categories.every((c) => categoryReadsOn(catalog, c));
|
|
110
110
|
out.push({
|
|
111
|
-
what: listed([
|
|
112
|
-
|
|
111
|
+
what: listed([
|
|
112
|
+
...new Set(rules.flatMap((r) => ruleParts(catalog, r, card))),
|
|
113
|
+
]),
|
|
114
|
+
preposition: rulePreposition(catalog, rules),
|
|
113
115
|
from: first?.valid_from ?? null,
|
|
114
116
|
to: first?.valid_to ?? null,
|
|
115
117
|
activateBy: first?.activate_by ?? null,
|
|
@@ -154,8 +156,9 @@ function capSummaries(catalog, card, held, today) {
|
|
|
154
156
|
const shared = card.caps.find((c) => c.id === key);
|
|
155
157
|
const label = shared?.rotating
|
|
156
158
|
? 'the rotating bonus categories'
|
|
157
|
-
: listed([
|
|
158
|
-
|
|
159
|
+
: listed([
|
|
160
|
+
...new Set(covered.flatMap((r) => ruleParts(catalog, r, card))),
|
|
161
|
+
]) || words(key);
|
|
159
162
|
return {
|
|
160
163
|
key,
|
|
161
164
|
label,
|
package/dist/format/card-text.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { earnText } from "../engine/text.js";
|
|
2
1
|
// Card names, fees and requirements as earnrate.dev and its static search
|
|
3
2
|
// pages show them. Node-free: the site bundles it.
|
|
4
3
|
import { membershipName, membershipText } from "../engine/memberships.js";
|
|
5
|
-
import {
|
|
4
|
+
import { capSentence, earnText, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
|
|
6
5
|
// An issuer's name as people know it, from data/issuers.yaml.
|
|
7
6
|
export const issuerName = (catalog, id) => catalog.issuers.find((i) => i.id === id)?.name ?? id;
|
|
8
7
|
export function cardText(catalog) {
|
|
@@ -42,7 +41,7 @@ export function cardText(catalog) {
|
|
|
42
41
|
if (until !== null)
|
|
43
42
|
lines.push(`This rate runs through ${until}`);
|
|
44
43
|
if (r.cap)
|
|
45
|
-
lines.push(
|
|
44
|
+
lines.push(capSentence(r.cap, earnText(catalog, r.cap.thenRate, r.cap.currency)));
|
|
46
45
|
return lines;
|
|
47
46
|
}
|
|
48
47
|
const memberName = (id) => membershipName(catalog, id);
|
package/dist/format/cards.js
CHANGED
|
@@ -5,7 +5,7 @@ export function formatCardInfo(catalog, card) {
|
|
|
5
5
|
? ''
|
|
6
6
|
: card.annualFee === 0
|
|
7
7
|
? ', no annual fee'
|
|
8
|
-
: `, ${money(card.annualFee)} a year`;
|
|
8
|
+
: `, ${money(card.annualFee)} a year${card.firstYearFee === null ? '' : `, ${card.firstYearFee === 0 ? 'waived' : money(card.firstYearFee)} the first year`}`;
|
|
9
9
|
const needs = [
|
|
10
10
|
...card.choices.map((c) => `${c.id}: pick ${String(c.pick)} of ${c.options.join(', ')}${c.default ? ` (default ${c.default.join(', ')})` : ''}`),
|
|
11
11
|
...(card.requiresMembership
|
|
@@ -24,7 +24,7 @@ export function formatCardInfo(catalog, card) {
|
|
|
24
24
|
...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
|
|
25
25
|
...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
|
|
26
26
|
...card.introRates.map((i) => `new accounts: ${i.text}; needs the opening date`),
|
|
27
|
-
...card.credits.map((c) => `
|
|
27
|
+
...card.credits.map((c) => `statement credit ${c.id}: ${c.text}`),
|
|
28
28
|
...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
|
|
29
29
|
foreignFeeText(card.foreignTransactionFee).toLowerCase(),
|
|
30
30
|
];
|
package/dist/format/explore.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { membershipName, membershipNote, } from "../engine/memberships.js";
|
|
2
|
-
import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, 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, listed, } from "../engine/text.js";
|
|
3
3
|
const membershipFees = (memberships) => memberships
|
|
4
4
|
.map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
|
|
5
5
|
.join('');
|
|
@@ -51,7 +51,7 @@ export function formatExploration(r, catalog, all) {
|
|
|
51
51
|
return `${lines.join('\n')}\n`;
|
|
52
52
|
}
|
|
53
53
|
function picksText(picks) {
|
|
54
|
-
const parts = Object.entries(picks).map(([choice, options]) => `${options.map(words)
|
|
54
|
+
const parts = Object.entries(picks).map(([choice, options]) => `${listed(options.map(words))} as its ${choiceName(choice)} ${options.length === 1 ? 'pick' : 'picks'}`);
|
|
55
55
|
return parts.length === 0 ? '' : ` (with ${parts.join('; ')})`;
|
|
56
56
|
}
|
|
57
57
|
function cardNamer(catalog) {
|
|
@@ -25,8 +25,8 @@ function compact(wallet) {
|
|
|
25
25
|
cards: wallet.cards.map((c) => Object.fromEntries(CARD_FIELDS.flatMap((k) => (isEmpty(c[k]) ? [] : [[k, c[k]]])))),
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
|
-
// Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare
|
|
29
|
-
// date, not a string.
|
|
28
|
+
// Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare
|
|
29
|
+
// 2026-10-15 as a date, not a string.
|
|
30
30
|
function quotedYaml(value) {
|
|
31
31
|
const doc = new Document(value);
|
|
32
32
|
visit(doc, {
|
package/dist/format/wallet.js
CHANGED
|
@@ -48,10 +48,10 @@ export function formatWalletSummary(summary) {
|
|
|
48
48
|
else if (c.intro?.state === 'no-date')
|
|
49
49
|
lines.push(` Has ${introAdjective(c.intro.days)} rates for new accounts; give its opening date if it opened ${introSince(c.intro.days)}.`);
|
|
50
50
|
else if (c.opened !== null)
|
|
51
|
-
lines.push(` Opened ${c.opened}.`);
|
|
51
|
+
lines.push(` Opened ${shortDate(c.opened)}.`);
|
|
52
52
|
const b = c.bonus;
|
|
53
53
|
if (b?.state === 'open' && b.spend !== null)
|
|
54
|
-
lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline
|
|
54
|
+
lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline === null ? 'by its deadline' : shortDate(b.deadline)}.`);
|
|
55
55
|
else if (b)
|
|
56
56
|
lines.push(` Welcome bonus: ${b.state}.`);
|
|
57
57
|
return lines.join('\n');
|
|
@@ -65,19 +65,19 @@ export function formatWalletSummary(summary) {
|
|
|
65
65
|
return [...cards, memberships, values].join('\n');
|
|
66
66
|
}
|
|
67
67
|
export function formatBooking(amount, name, // the card's short name
|
|
68
|
-
cap, bonus, milestones = [], credit = null) {
|
|
68
|
+
cap, bonus, milestones = [], credit = null, capLabel = null) {
|
|
69
69
|
const messages = [];
|
|
70
70
|
if (credit)
|
|
71
71
|
messages.push(`Used ${money(roundCents(credit.after - credit.before))} of ${possessive(name)} ${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.`);
|
|
72
72
|
if (cap) {
|
|
73
73
|
const left = Math.max(0, cap.capAmount - cap.after);
|
|
74
|
-
messages.push(`Recorded ${money(amount)} on ${name}
|
|
74
|
+
messages.push(`Recorded ${money(amount)} on ${name} toward its ${capLabel ?? cap.capKey} cap: ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} ${periodPhrase(cap.periodKind, cap.period)}, ${money(left)} left.`);
|
|
75
75
|
}
|
|
76
76
|
if (bonus) {
|
|
77
77
|
const left = Math.max(0, bonus.spend - bonus.after);
|
|
78
78
|
messages.push(left === 0
|
|
79
79
|
? `Welcome bonus spend met on ${name}: ${money(bonus.after)} of ${money(bonus.spend)}.`
|
|
80
|
-
: `Welcome bonus on ${name}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline
|
|
80
|
+
: `Welcome bonus on ${name}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline === null ? 'the deadline' : shortDate(bonus.deadline)}.`);
|
|
81
81
|
}
|
|
82
82
|
// Every milestone counts the same purchases, so one line covers them.
|
|
83
83
|
const first = milestones[0];
|
package/dist/import/analyze.js
CHANGED
|
@@ -4,7 +4,7 @@ import { capByKey, categoryMatches } from "../engine/evaluate.js";
|
|
|
4
4
|
import { milestoneProgress } from "../engine/milestones.js";
|
|
5
5
|
import { periodKey } from "../engine/periods.js";
|
|
6
6
|
import { recommend } from "../engine/recommend.js";
|
|
7
|
-
import { applyBooking, capBooking } from "../engine/record.js";
|
|
7
|
+
import { applyBooking, applyCreditBooking, capBooking, creditBooking, } from "../engine/record.js";
|
|
8
8
|
import { roundCents } from "../engine/text.js";
|
|
9
9
|
import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
|
|
10
10
|
import { cleanDescriptor } from "./statement.js";
|
|
@@ -55,6 +55,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
55
55
|
const missed = new Map();
|
|
56
56
|
const coding = new Map();
|
|
57
57
|
const bookings = [];
|
|
58
|
+
const credits = [];
|
|
58
59
|
let outsideCurrentPeriod = 0;
|
|
59
60
|
let alreadyImported = 0;
|
|
60
61
|
let purchases = 0;
|
|
@@ -145,6 +146,16 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
145
146
|
outsideCurrentPeriod++;
|
|
146
147
|
}
|
|
147
148
|
}
|
|
149
|
+
if (line.amount > 0 && !alreadyRecorded(entry, 'credit')) {
|
|
150
|
+
const credit = creditBooking(catalog, current, recorded, merchant);
|
|
151
|
+
const held = current.cards.find((c) => c.id === cardId);
|
|
152
|
+
if (credit &&
|
|
153
|
+
periodKey(credit.periodKind, today, held?.anniversary ?? null) ===
|
|
154
|
+
credit.period) {
|
|
155
|
+
credits.push({ ...entry, booking: credit });
|
|
156
|
+
current = applyCreditBooking(current, credit);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
148
159
|
if (line.amount < 0) {
|
|
149
160
|
refunds += -line.amount;
|
|
150
161
|
continue;
|
|
@@ -244,6 +255,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
244
255
|
.sort((a, b) => b.best - b.earned - (a.best - a.earned)),
|
|
245
256
|
coding: [...coding.values()].sort((a, b) => b.count - a.count),
|
|
246
257
|
bookings,
|
|
258
|
+
credits,
|
|
247
259
|
outsideCurrentPeriod,
|
|
248
260
|
alreadyImported,
|
|
249
261
|
bonuses: [...bonuses.values()].filter((b) => b.entries.length > 0),
|
package/dist/mcp/context.js
CHANGED
|
@@ -72,9 +72,8 @@ export function makeContext(deps) {
|
|
|
72
72
|
}
|
|
73
73
|
// Cards named in the conversation come first, then the saved wallet; with
|
|
74
74
|
// neither (null), answers cover every card earnrate knows.
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// none.
|
|
75
|
+
// Saved point values and memberships apply to every answer, not just ones
|
|
76
|
+
// from saved cards. A wallet that can't be read has none.
|
|
78
77
|
async function saved() {
|
|
79
78
|
try {
|
|
80
79
|
const wallet = await deps.store.load();
|