earnrate 0.2.15 → 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 +36 -1
- package/dist/cli/run.js +14 -1
- 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/cheat-sheet.js +1 -1
- package/dist/engine/checklist.js +26 -6
- package/dist/engine/credits.js +56 -7
- package/dist/engine/evaluate.js +21 -1
- package/dist/engine/explore.js +32 -10
- package/dist/engine/memberships.js +22 -10
- 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 +45 -24
- package/dist/format/card-text.js +6 -12
- 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 +17 -1
- package/dist/mcp/schemas.js +24 -0
- package/dist/mcp/tools/account.js +50 -42
- package/dist/mcp/tools/wallet.js +207 -150
- package/dist/mcp/wallet-api.js +78 -0
- package/dist/schema/card.js +11 -0
- package/dist/schema/wallet.js +6 -2
- package/package.json +1 -1
package/dist/cli/args.js
CHANGED
|
@@ -95,6 +95,9 @@ export function parseCliArgs(argv) {
|
|
|
95
95
|
llm: { type: 'boolean' },
|
|
96
96
|
cards: { type: 'string' },
|
|
97
97
|
member: { type: 'string', multiple: true },
|
|
98
|
+
enrolled: { type: 'string', multiple: true },
|
|
99
|
+
'skipped-credits': { type: 'string', multiple: true },
|
|
100
|
+
opened: { type: 'string', multiple: true },
|
|
98
101
|
help: { type: 'boolean', short: 'h' },
|
|
99
102
|
},
|
|
100
103
|
});
|
|
@@ -129,6 +132,28 @@ export function parseCliArgs(argv) {
|
|
|
129
132
|
const paying = paymentOf(values.paying);
|
|
130
133
|
if ('problem' in paying)
|
|
131
134
|
return { kind: 'error', message: paying.problem };
|
|
135
|
+
const cards = cardList(values.cards);
|
|
136
|
+
const perCard = (flag, example) => {
|
|
137
|
+
const out = {};
|
|
138
|
+
for (const pair of values[flag] ?? []) {
|
|
139
|
+
const [id = '', value = ''] = pair.split('=');
|
|
140
|
+
if (id.trim() === '' || value.trim() === '')
|
|
141
|
+
return `--${flag} takes <card>=<${flag === 'opened' ? 'date' : 'credits'}>, like ${example}.`;
|
|
142
|
+
if (!cards?.includes(id.trim()))
|
|
143
|
+
return `--${flag} is for a card named with --cards; ${id.trim()} isn't one.`;
|
|
144
|
+
out[id.trim()] = value.trim();
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
};
|
|
148
|
+
const enrolled = perCard('enrolled', 'amex-platinum=lululemon');
|
|
149
|
+
if (typeof enrolled === 'string')
|
|
150
|
+
return { kind: 'error', message: enrolled };
|
|
151
|
+
const skipped = perCard('skipped-credits', 'amex-gold=dunkin');
|
|
152
|
+
if (typeof skipped === 'string')
|
|
153
|
+
return { kind: 'error', message: skipped };
|
|
154
|
+
const opened = perCard('opened', 'discover-it-cash-back=2026-03-04');
|
|
155
|
+
if (typeof opened === 'string')
|
|
156
|
+
return { kind: 'error', message: opened };
|
|
132
157
|
return {
|
|
133
158
|
kind: 'run',
|
|
134
159
|
options: {
|
|
@@ -143,8 +168,18 @@ export function parseCliArgs(argv) {
|
|
|
143
168
|
all: values.all ?? false,
|
|
144
169
|
category: values.category ?? null,
|
|
145
170
|
llm: values.llm ?? null,
|
|
146
|
-
cards
|
|
171
|
+
cards,
|
|
147
172
|
members: values.member ?? [],
|
|
173
|
+
enrolled: creditLists(enrolled),
|
|
174
|
+
skipped: creditLists(skipped),
|
|
175
|
+
opened,
|
|
148
176
|
},
|
|
149
177
|
};
|
|
150
178
|
}
|
|
179
|
+
const creditLists = (byCard) => Object.fromEntries(Object.entries(byCard).map(([id, list]) => [
|
|
180
|
+
id,
|
|
181
|
+
list
|
|
182
|
+
.split(',')
|
|
183
|
+
.map((c) => c.trim())
|
|
184
|
+
.filter(Boolean),
|
|
185
|
+
]));
|
package/dist/cli/run.js
CHANGED
|
@@ -70,6 +70,14 @@ Options:
|
|
|
70
70
|
${PAYING_HELP}
|
|
71
71
|
--cards <ids> Answer for these cards instead of your wallet, comma
|
|
72
72
|
separated: --cards amex-gold,chase-prime-visa.
|
|
73
|
+
--enrolled <card>=<credits>
|
|
74
|
+
For a named card, statement credits you've enrolled
|
|
75
|
+
in: --enrolled amex-platinum=lululemon. Repeat per card.
|
|
76
|
+
--skipped-credits <card>=<credits>
|
|
77
|
+
For a named card, statement credits you don't use:
|
|
78
|
+
--skipped-credits amex-gold=dunkin. Repeat per card.
|
|
79
|
+
--opened <card>=<date> For a named card, the day it opened (YYYY-MM-DD), for
|
|
80
|
+
a first-year rate. Repeat per card.
|
|
73
81
|
--member <name> A membership you have, in place of the saved ones.
|
|
74
82
|
--category <id> For a merchant earnrate doesn't know: its category,
|
|
75
83
|
e.g. grocery.supermarket. Ignored for known merchants.
|
|
@@ -106,7 +114,12 @@ export async function cardsToUse(catalog, file, io, named) {
|
|
|
106
114
|
if (named.cards !== null) {
|
|
107
115
|
const saved = await savedSettings(io);
|
|
108
116
|
const wallet = walletFromCards(catalog, {
|
|
109
|
-
cards: named.cards.map((id) => ({
|
|
117
|
+
cards: named.cards.map((id) => ({
|
|
118
|
+
id,
|
|
119
|
+
...(named.enrolled?.[id] ? { enrolled: named.enrolled[id] } : {}),
|
|
120
|
+
...(named.skipped?.[id] ? { skippedCredits: named.skipped[id] } : {}),
|
|
121
|
+
...(named.opened?.[id] ? { opened: named.opened[id] } : {}),
|
|
122
|
+
})),
|
|
110
123
|
memberships: named.members.length > 0 ? named.members : saved.memberships,
|
|
111
124
|
}, io.today);
|
|
112
125
|
if (typeof wallet === 'string') {
|
package/dist/cli/wallet.js
CHANGED
|
@@ -20,11 +20,17 @@ const CARD_OPTIONS = `Options:
|
|
|
20
20
|
--activated <rules> Rules you've activated with the issuer, comma
|
|
21
21
|
separated. Replaces the current list.
|
|
22
22
|
--enrolled <credits> Statement credits you've enrolled in with the
|
|
23
|
-
issuer,
|
|
24
|
-
|
|
23
|
+
issuer, or use if they need no enrolling, comma
|
|
24
|
+
separated, e.g. --enrolled lululemon. They count
|
|
25
|
+
against the annual fee. Replaces the current list.
|
|
25
26
|
--credit-used <credit>=<dollars>
|
|
26
27
|
How much of a credit you've received this period,
|
|
27
28
|
e.g. --credit-used lululemon=40. Repeat for each.
|
|
29
|
+
--skipped-credits <credits>
|
|
30
|
+
Statement credits you don't use, comma separated,
|
|
31
|
+
e.g. --skipped-credits dunkin. They're left out of
|
|
32
|
+
picks, the to-do list and annual-fee math.
|
|
33
|
+
Replaces the current list.
|
|
28
34
|
--cap-spent <cap>=<dollars>
|
|
29
35
|
Spending toward a cap so far this period, e.g.
|
|
30
36
|
--cap-spent us-supermarkets=1200. Repeat for each.
|
|
@@ -150,6 +156,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
150
156
|
opened: { type: 'string' },
|
|
151
157
|
activated: { type: 'string' },
|
|
152
158
|
enrolled: { type: 'string' },
|
|
159
|
+
'skipped-credits': { type: 'string' },
|
|
153
160
|
'credit-used': { type: 'string', multiple: true },
|
|
154
161
|
'cap-spent': { type: 'string', multiple: true },
|
|
155
162
|
'milestone-spent': { type: 'string', multiple: true },
|
|
@@ -183,6 +190,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
183
190
|
values.opened !== undefined ||
|
|
184
191
|
values.activated !== undefined ||
|
|
185
192
|
values.enrolled !== undefined ||
|
|
193
|
+
values['skipped-credits'] !== undefined ||
|
|
186
194
|
values['credit-used'] !== undefined ||
|
|
187
195
|
values['cap-spent'] !== undefined ||
|
|
188
196
|
values['milestone-spent'] !== undefined ||
|
|
@@ -288,12 +296,14 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
288
296
|
const n = Number(text.replace(/[$,]/g, ''));
|
|
289
297
|
return Number.isFinite(n) ? n : `--${flag} takes dollars, like 1200.`;
|
|
290
298
|
};
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
299
|
+
const idList = (text) => text
|
|
300
|
+
.split(',')
|
|
301
|
+
.map((r) => r.trim())
|
|
302
|
+
.filter((r) => r !== '');
|
|
303
|
+
if (values.enrolled !== undefined)
|
|
304
|
+
input.enrolled = idList(values.enrolled);
|
|
305
|
+
if (values['skipped-credits'] !== undefined)
|
|
306
|
+
input.skippedCredits = idList(values['skipped-credits']);
|
|
297
307
|
// --credit-used, --cap-spent and --milestone-spent: <id>=<dollars>, repeated.
|
|
298
308
|
const dollarPairs = (flag, example) => {
|
|
299
309
|
const pairs = values[flag];
|
package/dist/data/integrity.js
CHANGED
|
@@ -160,6 +160,19 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
160
160
|
...card.intro_rates.map((i) => i.id),
|
|
161
161
|
...card.credits.map((c) => c.id),
|
|
162
162
|
]);
|
|
163
|
+
// An end or start date has to come from the issuer, so a quote names its
|
|
164
|
+
// year ("through 12/31/2027", "Starting in 2027").
|
|
165
|
+
for (const [id, date] of [
|
|
166
|
+
...card.credits.map((c) => [c.id, c.valid_to]),
|
|
167
|
+
...card.milestones.map((m) => [m.id, m.valid_from]),
|
|
168
|
+
]) {
|
|
169
|
+
if (date === undefined)
|
|
170
|
+
continue;
|
|
171
|
+
const year = date.slice(0, 4);
|
|
172
|
+
const named = new RegExp(`(^|\\D)(${year}|\\d{1,2}/\\d{1,2}/${year.slice(2)})(\\D|$)`);
|
|
173
|
+
if (!card.evidence.some((e) => e.rule === id && named.test(e.quote)))
|
|
174
|
+
problems.push(`${where} ${id}: no evidence quote names its date's year`);
|
|
175
|
+
}
|
|
163
176
|
const sourcePages = new Set(card.source_urls.map((u) => u.split('#')[0]));
|
|
164
177
|
const backed = new Set(card.evidence.map((e) => e.rule));
|
|
165
178
|
for (const e of card.evidence) {
|
package/dist/data/wallet-edit.js
CHANGED
|
@@ -44,6 +44,10 @@ function fieldLines(field, f, indent) {
|
|
|
44
44
|
return f.enrolled === undefined
|
|
45
45
|
? ''
|
|
46
46
|
: `${indent}enrolled: ${flowList(f.enrolled)}\n`;
|
|
47
|
+
case 'skipped_credits':
|
|
48
|
+
return f.skipped_credits === undefined
|
|
49
|
+
? ''
|
|
50
|
+
: `${indent}skipped_credits: ${flowList(f.skipped_credits)}\n`;
|
|
47
51
|
case 'credit_usage': {
|
|
48
52
|
if (f.credit_usage === undefined)
|
|
49
53
|
return '';
|
|
@@ -106,6 +110,7 @@ const FIELDS = [
|
|
|
106
110
|
'milestone_usage',
|
|
107
111
|
'enrolled',
|
|
108
112
|
'credit_usage',
|
|
113
|
+
'skipped_credits',
|
|
109
114
|
'bonus',
|
|
110
115
|
];
|
|
111
116
|
function cardsSeq(text) {
|
|
@@ -86,7 +86,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
|
86
86
|
];
|
|
87
87
|
return [
|
|
88
88
|
`Working on ${possessive(name)} welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
|
|
89
|
-
? `It ${where.join(' and ')}, so those are the places to use it.`
|
|
89
|
+
? `It ${where.join(' and ')}, so ${matches.length + near.length === 1 ? "that's the place" : 'those are the places'} to use it.`
|
|
90
90
|
: 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
|
|
91
91
|
];
|
|
92
92
|
});
|
package/dist/engine/checklist.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { addDays, currentPeriodEnd, daysBetween } from "./periods.js";
|
|
2
|
-
import { CAP_PERIODS, choiceName, enrollCreditText, introEndsText, listed, money, shortDate, useCreditText, wholeDollars, possessive, } from "./text.js";
|
|
2
|
+
import { CAP_PERIODS, choiceName, askCreditText, enrollCreditText, introEndsText, listed, money, shortDate, useCreditText, wholeDollars, possessive, } from "./text.js";
|
|
3
3
|
import { milestonePays } from "./milestones.js";
|
|
4
|
-
import { optionName } from "./rule-text.js";
|
|
4
|
+
import { creditLabel, optionName } from "./rule-text.js";
|
|
5
5
|
import { activationsDue, walletSummary, } from "./wallet-summary.js";
|
|
6
|
+
// "5% and 2% categories", "top category": a choice already named for its
|
|
7
|
+
// category isn't followed by "categories" again.
|
|
8
|
+
function pickWhat(names) {
|
|
9
|
+
const [only] = names;
|
|
10
|
+
if (names.length === 1 && only !== undefined && / category$/.test(only))
|
|
11
|
+
return only;
|
|
12
|
+
return `${listed(names)} categories`;
|
|
13
|
+
}
|
|
6
14
|
// What the wallet's cards need this period, soonest first: bonuses to
|
|
7
15
|
// activate, categories to pick, caps running low, welcome bonuses to finish,
|
|
8
16
|
// merchant credits to enroll in or use, and rotating categories the issuer
|
|
@@ -24,7 +32,7 @@ shortName) {
|
|
|
24
32
|
items.push({
|
|
25
33
|
kind: 'activate',
|
|
26
34
|
cardId: card.id,
|
|
27
|
-
text: `Activate ${possessive(name)} bonus ${a.
|
|
35
|
+
text: `Activate ${possessive(name)} bonus ${a.where}${a.activateBy === null ? '' : ` by ${shortDate(a.activateBy)}`}${starts}.`,
|
|
28
36
|
due: a.activateBy ?? (a.from !== null && a.from > today ? a.from : a.to),
|
|
29
37
|
});
|
|
30
38
|
}
|
|
@@ -41,7 +49,7 @@ shortName) {
|
|
|
41
49
|
items.push({
|
|
42
50
|
kind: 'pick',
|
|
43
51
|
cardId: card.id,
|
|
44
|
-
text: `Pick ${possessive(name)} ${
|
|
52
|
+
text: `Pick ${possessive(name)} ${pickWhat(unpicked.map((c) => choiceName(c.id)))} for this ${first.period.replace('_', ' ')}; until then they earn the base rate.`,
|
|
45
53
|
due,
|
|
46
54
|
});
|
|
47
55
|
}
|
|
@@ -94,7 +102,19 @@ shortName) {
|
|
|
94
102
|
due: m.until,
|
|
95
103
|
});
|
|
96
104
|
}
|
|
97
|
-
const
|
|
105
|
+
const toAsk = s.credits.filter((c) => !c.enroll && !c.confirmed && !c.skipped);
|
|
106
|
+
if (toAsk.length > 0)
|
|
107
|
+
items.push({
|
|
108
|
+
kind: 'enroll',
|
|
109
|
+
cardId: card.id,
|
|
110
|
+
ref: toAsk.map((c) => c.id).join(','),
|
|
111
|
+
text: askCreditText(name, toAsk.map((c) => {
|
|
112
|
+
const credit = card.credits.find((x) => x.id === c.id);
|
|
113
|
+
return credit ? creditLabel(catalog, credit) : c.name;
|
|
114
|
+
})),
|
|
115
|
+
due: null,
|
|
116
|
+
});
|
|
117
|
+
const toEnroll = s.credits.filter((c) => !c.enrolled && !c.skipped);
|
|
98
118
|
if (toEnroll.length > 0)
|
|
99
119
|
items.push({
|
|
100
120
|
kind: 'enroll',
|
|
@@ -104,7 +124,7 @@ shortName) {
|
|
|
104
124
|
due: null,
|
|
105
125
|
});
|
|
106
126
|
for (const c of s.credits) {
|
|
107
|
-
if (!c.enrolled)
|
|
127
|
+
if (!c.enrolled || c.skipped)
|
|
108
128
|
continue;
|
|
109
129
|
// One that needs no enrolling shows once the cardholder uses it, so a
|
|
110
130
|
// card's every credit isn't a to-do for someone who never shops there.
|
package/dist/engine/credits.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { merchantFamily } from "../merchants/resolve.js";
|
|
2
2
|
import { categoryMatches } from "./evaluate.js";
|
|
3
|
-
import { periodEnd, periodKey } from "./periods.js";
|
|
4
|
-
import { creditName } from "./rule-text.js";
|
|
3
|
+
import { inEffect, periodEnd, periodKey } from "./periods.js";
|
|
4
|
+
import { creditLabel, creditName } from "./rule-text.js";
|
|
5
5
|
import { roundCents } from "./text.js";
|
|
6
6
|
// Only a store itself has a credit: a search for a kind of purchase
|
|
7
7
|
// ("clothing") never does.
|
|
@@ -16,6 +16,15 @@ export function creditCovers(catalog, credit, merchant) {
|
|
|
16
16
|
}
|
|
17
17
|
// A credit that needs enrolling counts only once the wallet says it's done.
|
|
18
18
|
const isEnrolled = (credit, held) => credit.enroll !== true || (held.enrolled ?? []).includes(credit.id);
|
|
19
|
+
// The cardholder said they don't use it: it counts nowhere, enrolled or not.
|
|
20
|
+
export const isSkipped = (credit, held) => (held.skipped_credits ?? []).includes(credit.id);
|
|
21
|
+
// The cardholder said they use it: it's in `enrolled` (for one that needs
|
|
22
|
+
// no enrolling, that means "using it"), or one that needs no enrolling has
|
|
23
|
+
// already paid out. Until then a credit doesn't offset the annual fee.
|
|
24
|
+
export const creditConfirmed = (credit, held) => (held.enrolled ?? []).includes(credit.id) ||
|
|
25
|
+
(credit.enroll !== true && held.credit_usage?.[credit.id] !== undefined);
|
|
26
|
+
// A credit that counts against the fee: confirmed and not skipped.
|
|
27
|
+
export const creditInUse = (credit, held) => creditConfirmed(credit, held) && !isSkipped(credit, held);
|
|
19
28
|
function creditState(catalog, credit, held, date) {
|
|
20
29
|
const anniversary = held.anniversary ?? null;
|
|
21
30
|
const period = periodKey(credit.period, date, anniversary);
|
|
@@ -30,18 +39,23 @@ function creditState(catalog, credit, held, date) {
|
|
|
30
39
|
amount: credit.amount,
|
|
31
40
|
period: credit.period,
|
|
32
41
|
enrolled: isEnrolled(credit, held),
|
|
42
|
+
skipped: isSkipped(credit, held),
|
|
43
|
+
confirmed: creditConfirmed(credit, held),
|
|
33
44
|
used,
|
|
45
|
+
minAmount: credit.min_amount ?? null,
|
|
34
46
|
left: period === null ? null : roundCents(Math.max(0, credit.amount - used)),
|
|
35
47
|
until: period === null ? null : periodEnd(credit.period, period, anniversary),
|
|
36
48
|
};
|
|
37
49
|
}
|
|
38
|
-
|
|
39
|
-
export const
|
|
50
|
+
// The card's credits the issuer still offers on `date`.
|
|
51
|
+
export const liveCredits = (card, date) => card.credits.filter((c) => inEffect(c, date));
|
|
52
|
+
export const creditStates = (catalog, card, held, date) => liveCredits(card, date).map((c) => creditState(catalog, c, held, date));
|
|
53
|
+
export const counts = (s) => s.enrolled && !s.skipped && s.left !== null && s.left > 0;
|
|
40
54
|
// The card's credit at this merchant, if any: one that counts first, then
|
|
41
|
-
// the one with the most left.
|
|
55
|
+
// the one with the most left. A skipped credit isn't mentioned at all.
|
|
42
56
|
export function creditAt(catalog, card, held, merchant, date) {
|
|
43
|
-
const here = card
|
|
44
|
-
.filter((c) => creditCovers(catalog, c, merchant))
|
|
57
|
+
const here = liveCredits(card, date)
|
|
58
|
+
.filter((c) => !isSkipped(c, held) && creditCovers(catalog, c, merchant))
|
|
45
59
|
.map((c) => creditState(catalog, c, held, date))
|
|
46
60
|
.sort((a, b) => Number(counts(b)) - Number(counts(a)) || (b.left ?? 0) - (a.left ?? 0));
|
|
47
61
|
return here[0] ?? null;
|
|
@@ -53,6 +67,8 @@ export function creditDollars(s, amount) {
|
|
|
53
67
|
return null;
|
|
54
68
|
if (!counts(s) || amount <= 0)
|
|
55
69
|
return 0;
|
|
70
|
+
if (s.credit.min_amount !== undefined && amount < s.credit.min_amount)
|
|
71
|
+
return 0;
|
|
56
72
|
return roundCents(Math.min(amount, s.left ?? 0));
|
|
57
73
|
}
|
|
58
74
|
const PER_YEAR = {
|
|
@@ -62,4 +78,37 @@ const PER_YEAR = {
|
|
|
62
78
|
calendar_year: 1,
|
|
63
79
|
cardmember_year: 1,
|
|
64
80
|
};
|
|
81
|
+
const monthIndex = (date) => Number(date.slice(0, 4)) * 12 + Number(date.slice(5, 7)) - 1;
|
|
82
|
+
// How many times the credit pays in the 12 months from `date`, counting the
|
|
83
|
+
// period `date` is in: every period's, or for a credit that ends, those that
|
|
84
|
+
// start by its last day.
|
|
85
|
+
function payoutsAYear(credit, date) {
|
|
86
|
+
const all = PER_YEAR[credit.period];
|
|
87
|
+
if (credit.valid_to === undefined)
|
|
88
|
+
return all;
|
|
89
|
+
if (!inEffect(credit, date))
|
|
90
|
+
return 0;
|
|
91
|
+
if (all === 1)
|
|
92
|
+
return 1;
|
|
93
|
+
const step = 12 / all;
|
|
94
|
+
const first = monthIndex(date) - (monthIndex(date) % step);
|
|
95
|
+
const starts = Math.floor((monthIndex(credit.valid_to) - first) / step) + 1;
|
|
96
|
+
return Math.min(all, starts);
|
|
97
|
+
}
|
|
98
|
+
// Dollars a year at face value, the most first: for ordering cards by credit.
|
|
65
99
|
export const creditPerYear = (c) => c.amount * PER_YEAR[c.period];
|
|
100
|
+
function creditsYear(catalog, card, credits, date) {
|
|
101
|
+
const live = credits.filter((c) => inEffect(c, date));
|
|
102
|
+
return {
|
|
103
|
+
cardId: card.id,
|
|
104
|
+
yearly: roundCents(live.reduce((sum, c) => sum + c.amount * payoutsAYear(c, date), 0)),
|
|
105
|
+
creditIds: live.map((c) => c.id),
|
|
106
|
+
names: live.map((c) => creditLabel(catalog, c)),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// Every credit on the card, as if each were used in full: for a card the
|
|
110
|
+
// person doesn't hold.
|
|
111
|
+
export const allCreditsYear = (catalog, card, date) => creditsYear(catalog, card, card.credits, date);
|
|
112
|
+
// The credits a cardholder uses, in full each period: what counts against
|
|
113
|
+
// the fee of a card they hold.
|
|
114
|
+
export const usedCreditsYear = (catalog, card, held, date) => creditsYear(catalog, card, card.credits.filter((c) => creditInUse(c, held)), date);
|
package/dist/engine/evaluate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { merchantFamily } from "../merchants/resolve.js";
|
|
2
2
|
import { EPSILON } from "./compare.js";
|
|
3
3
|
import { introCovers, introWindows, withIntro, } from "./intro.js";
|
|
4
|
+
import { effectiveMemberships } from "./memberships.js";
|
|
4
5
|
import { inEffect, periodKey } from "./periods.js";
|
|
5
6
|
// PayPal checkout happens online, so paying through it says the channel.
|
|
6
7
|
const paymentChannel = (channel, payment) => channel ?? (payment === 'paypal' ? 'online' : null);
|
|
@@ -16,7 +17,7 @@ export function purchaseContext(catalog, wallet, merchant, category, purchase) {
|
|
|
16
17
|
country: purchase.country,
|
|
17
18
|
date: purchase.date,
|
|
18
19
|
amount: purchase.amount,
|
|
19
|
-
memberships: new Set(wallet.memberships),
|
|
20
|
+
memberships: new Set(effectiveMemberships(catalog, wallet.memberships, wallet.cards.map((c) => c.id))),
|
|
20
21
|
payment,
|
|
21
22
|
};
|
|
22
23
|
}
|
|
@@ -120,6 +121,9 @@ function applicability(card, rule, ctx, held, picked) {
|
|
|
120
121
|
return 'no';
|
|
121
122
|
if (!inEffect(rule, ctx.date))
|
|
122
123
|
return 'no';
|
|
124
|
+
if (rule.min_amount !== undefined &&
|
|
125
|
+
(ctx.amount === null || ctx.amount < rule.min_amount))
|
|
126
|
+
return 'no';
|
|
123
127
|
const membership = rule.requires?.membership;
|
|
124
128
|
if (membership !== undefined && !ctx.memberships.has(membership))
|
|
125
129
|
return 'no';
|
|
@@ -249,3 +253,19 @@ export function paymentUpgrade(card, held, ctx, rate) {
|
|
|
249
253
|
}
|
|
250
254
|
return best;
|
|
251
255
|
}
|
|
256
|
+
// The smallest purchase amount (one a rule on the card asks for) that would
|
|
257
|
+
// earn more than `rate`, and what it would earn. Only while the purchase has
|
|
258
|
+
// no amount.
|
|
259
|
+
export function amountUpgrade(card, held, ctx, rate) {
|
|
260
|
+
if (ctx.amount !== null)
|
|
261
|
+
return null;
|
|
262
|
+
const amounts = [
|
|
263
|
+
...new Set(card.rules.flatMap((r) => r.min_amount ?? [])),
|
|
264
|
+
].sort((a, b) => a - b);
|
|
265
|
+
for (const amount of amounts) {
|
|
266
|
+
const larger = evaluateCard(card, held, { ...ctx, amount }).best;
|
|
267
|
+
if (larger.rate > rate + EPSILON)
|
|
268
|
+
return { amount, rate: larger.rate, nominalRate: larger.nominalRate };
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
package/dist/engine/explore.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { categoryResolution, resolvePlace } from "../merchants/resolve.js";
|
|
2
|
-
import { creditCovers, creditPerYear } from "./credits.js";
|
|
3
|
-
import { evaluateCard, paymentUpgrade, purchaseContext } from "./evaluate.js";
|
|
2
|
+
import { creditCovers, creditPerYear, liveCredits } from "./credits.js";
|
|
3
|
+
import { amountUpgrade, evaluateCard, paymentUpgrade, purchaseContext, } from "./evaluate.js";
|
|
4
4
|
import { creditName } from "./rule-text.js";
|
|
5
5
|
import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
|
|
6
6
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
@@ -8,7 +8,7 @@ import { FOREIGN_ASSUMPTION, foreignFeeFor, isForeign, recommend, } from "./reco
|
|
|
8
8
|
import { rotationGap } from "./rotation.js";
|
|
9
9
|
import { membershipsUsed } from "./setup.js";
|
|
10
10
|
import { optionName } from "./rule-text.js";
|
|
11
|
-
import { categoryPhrase, choiceName, codesAs, creditBack, creditThen, earnText, listed,
|
|
11
|
+
import { amountUnknownText, capSentence, categoryPhrase, choiceName, codesAs, creditBack, creditThen, creditTerms, earnText, listed, paidText, PAYMENT_PHRASES, paymentUnknownText, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
12
12
|
import { cardValuation } from "./valuation.js";
|
|
13
13
|
import { EPSILON } from "./compare.js";
|
|
14
14
|
export const ALL_PERSONAL_CARDS = {
|
|
@@ -169,6 +169,7 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
169
169
|
};
|
|
170
170
|
const ranked = [];
|
|
171
171
|
const ifPaid = new Map();
|
|
172
|
+
const ifLarger = new Map();
|
|
172
173
|
const excluded = [];
|
|
173
174
|
// A Costco purchase means a Costco member: no membership to point out.
|
|
174
175
|
const members = paidMemberships(catalog, {
|
|
@@ -192,6 +193,14 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
192
193
|
const requirements = requirementsFor(catalog, card, held, wallet, merchant, purchase);
|
|
193
194
|
const gap = rotationGap(card, query.date);
|
|
194
195
|
const paid = paymentUpgrade(card, held, purchaseContext(catalog, wallet, merchant, merchant.category, purchase), top.rate);
|
|
196
|
+
const larger = amountUpgrade(card, held, purchaseContext(catalog, wallet, merchant, merchant.category, purchase), top.rate);
|
|
197
|
+
if (larger)
|
|
198
|
+
ifLarger.set(card.id, {
|
|
199
|
+
amount: larger.amount,
|
|
200
|
+
earn: earnText(catalog, larger.nominalRate, top.currency),
|
|
201
|
+
standard: earnText(catalog, top.rate, top.currency),
|
|
202
|
+
returnPct: larger.rate * top.centsPerUnit - (top.foreignFee ?? 0),
|
|
203
|
+
});
|
|
195
204
|
if (paid)
|
|
196
205
|
ifPaid.set(card.id, {
|
|
197
206
|
payment: paid.payment,
|
|
@@ -223,23 +232,27 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
223
232
|
...membershipNeeded(catalog, card, members),
|
|
224
233
|
],
|
|
225
234
|
evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
|
|
235
|
+
needsMembership: card.requires_membership !== undefined &&
|
|
236
|
+
!members.has(card.requires_membership)
|
|
237
|
+
? card.requires_membership
|
|
238
|
+
: null,
|
|
226
239
|
withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
|
|
227
240
|
});
|
|
228
241
|
}
|
|
229
242
|
ranked.sort(compareExplored);
|
|
230
243
|
return {
|
|
231
244
|
merchant,
|
|
232
|
-
credits: creditPicks(catalog, merchant, ranked),
|
|
245
|
+
credits: creditPicks(catalog, merchant, ranked, query.date),
|
|
233
246
|
ranked,
|
|
234
247
|
excluded,
|
|
235
|
-
assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid),
|
|
248
|
+
assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid, ifLarger),
|
|
236
249
|
warnings: exploreWarnings(catalog, merchant),
|
|
237
250
|
};
|
|
238
251
|
}
|
|
239
|
-
function creditPicks(catalog, merchant, ranked) {
|
|
252
|
+
function creditPicks(catalog, merchant, ranked, date) {
|
|
240
253
|
const picks = ranked.flatMap((entry) => {
|
|
241
254
|
const card = catalog.cards.find((c) => c.id === entry.cardId);
|
|
242
|
-
return ((card
|
|
255
|
+
return ((card ? liveCredits(card, date) : [])
|
|
243
256
|
// A credit for a whole category (every restaurant) isn't a store's.
|
|
244
257
|
.filter((c) => c.match.merchants !== undefined &&
|
|
245
258
|
creditCovers(catalog, c, merchant))
|
|
@@ -259,7 +272,8 @@ function creditPicks(catalog, merchant, ranked) {
|
|
|
259
272
|
period: credit.period,
|
|
260
273
|
enroll: credit.enroll === true,
|
|
261
274
|
note: credit.note ?? null,
|
|
262
|
-
back: creditBack(credit)
|
|
275
|
+
back: `${creditBack(credit)}${creditTerms(credit)}`,
|
|
276
|
+
terms: creditTerms(credit),
|
|
263
277
|
earn,
|
|
264
278
|
text: creditThen(credit, earn),
|
|
265
279
|
evidence: evidence
|
|
@@ -270,7 +284,7 @@ function creditPicks(catalog, merchant, ranked) {
|
|
|
270
284
|
});
|
|
271
285
|
return picks.sort((a, b) => creditPerYear(b) - creditPerYear(a) || a.cardId.localeCompare(b.cardId));
|
|
272
286
|
}
|
|
273
|
-
function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid) {
|
|
287
|
+
function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid, ifLarger) {
|
|
274
288
|
const assumptions = [];
|
|
275
289
|
if (merchant.source === 'category') {
|
|
276
290
|
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
@@ -298,12 +312,20 @@ function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid
|
|
|
298
312
|
continue;
|
|
299
313
|
assumptions.push(paymentUnknownText(entry.cardName, paid.payment, paid.earn, paid.standard));
|
|
300
314
|
}
|
|
315
|
+
for (const entry of ranked) {
|
|
316
|
+
const larger = ifLarger.get(entry.cardId);
|
|
317
|
+
if (!larger || !top)
|
|
318
|
+
continue;
|
|
319
|
+
if (entry !== top && larger.returnPct <= top.effectiveReturnPct + EPSILON)
|
|
320
|
+
continue;
|
|
321
|
+
assumptions.push(amountUnknownText(entry.cardName, larger.amount, larger.earn, larger.standard));
|
|
322
|
+
}
|
|
301
323
|
return assumptions;
|
|
302
324
|
}
|
|
303
325
|
function exploreWarnings(catalog, merchant) {
|
|
304
326
|
const warnings = [];
|
|
305
327
|
if (merchant.source === 'none') {
|
|
306
|
-
warnings.push(`
|
|
328
|
+
warnings.push(`earnrate doesn't know "${merchant.input}" yet, so only base rates apply. Try a category, or the name of a larger chain.`);
|
|
307
329
|
}
|
|
308
330
|
if (merchant.ambiguity) {
|
|
309
331
|
const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { listed, money, withArticle, words } from "./text.js";
|
|
1
|
+
import { listed, money, shortCardName, withArticle, words } from "./text.js";
|
|
2
2
|
export function membership(catalog, id) {
|
|
3
3
|
return catalog.memberships.find((m) => m.id === id);
|
|
4
4
|
}
|
|
@@ -11,16 +11,24 @@ export function sameGroup(catalog, a, b) {
|
|
|
11
11
|
const group = membership(catalog, a)?.group;
|
|
12
12
|
return group !== undefined && group === membership(catalog, b)?.group;
|
|
13
13
|
}
|
|
14
|
+
// The memberships a person has: the ones they've said, plus the one each of
|
|
15
|
+
// their cards can't be held without (a Costco membership for Costco
|
|
16
|
+
// Anywhere). Gates a rule's `requires.membership` as well as fees. Only the
|
|
17
|
+
// person's own cards (or those named for an answer) belong in `cardIds`.
|
|
18
|
+
export function effectiveMemberships(catalog, memberships, cardIds) {
|
|
19
|
+
const out = new Set(memberships);
|
|
20
|
+
for (const card of catalog.cards) {
|
|
21
|
+
const id = card.requires_membership;
|
|
22
|
+
if (id !== undefined && cardIds.includes(card.id))
|
|
23
|
+
out.add(id);
|
|
24
|
+
}
|
|
25
|
+
return [...out];
|
|
26
|
+
}
|
|
14
27
|
// Memberships the person already pays for: the ones they've said, the ones a
|
|
15
28
|
// card they hold needs, and the ones their spending shows (only members shop
|
|
16
29
|
// at Costco).
|
|
17
30
|
export function paidMemberships(catalog, { memberships, heldCards = [], merchantIds = [], }) {
|
|
18
|
-
const paid = new Set(memberships);
|
|
19
|
-
for (const card of catalog.cards) {
|
|
20
|
-
const id = card.requires_membership;
|
|
21
|
-
if (id !== undefined && heldCards.includes(card.id))
|
|
22
|
-
paid.add(id);
|
|
23
|
-
}
|
|
31
|
+
const paid = new Set(effectiveMemberships(catalog, memberships, heldCards));
|
|
24
32
|
for (const m of catalog.memberships)
|
|
25
33
|
if (m.members_shop_at.some((id) => merchantIds.includes(id)))
|
|
26
34
|
paid.add(m.id);
|
|
@@ -50,7 +58,7 @@ export function membershipCosts(catalog, cards, paid) {
|
|
|
50
58
|
export function membershipText(catalog, id) {
|
|
51
59
|
const m = membership(catalog, id);
|
|
52
60
|
const name = withArticle(m?.name ?? words(id));
|
|
53
|
-
return m?.annual_fee === undefined
|
|
61
|
+
return m?.annual_fee === undefined || m.annual_fee === 0
|
|
54
62
|
? name
|
|
55
63
|
: `${name} (${money(m.annual_fee)} a year)`;
|
|
56
64
|
}
|
|
@@ -62,6 +70,10 @@ export function membershipNeeded(catalog, card, paid) {
|
|
|
62
70
|
: [];
|
|
63
71
|
}
|
|
64
72
|
export function membershipNote(catalog, m) {
|
|
65
|
-
const names = m.cardIds.map((id) => catalog.cards.find((c) => c.id === id)?.name ?? id);
|
|
66
|
-
|
|
73
|
+
const names = m.cardIds.map((id) => shortCardName(catalog.cards.find((c) => c.id === id)?.name ?? id));
|
|
74
|
+
const needs = `${listed(names)} ${names.length === 1 ? 'needs' : 'need'} ${withArticle(m.name)}`;
|
|
75
|
+
const cost = m.annualFee === 0
|
|
76
|
+
? ''
|
|
77
|
+
: `, ${money(m.annualFee)} a year, counted here like an annual fee`;
|
|
78
|
+
return `${needs}${cost}. If you have one, add it to your memberships.`;
|
|
67
79
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { periodEnd, periodKey } from "./periods.js";
|
|
2
|
-
import { listed, MILESTONE_PERIODS, money, roundCents, wholeDollars, } from "./text.js";
|
|
1
|
+
import { inEffect, periodEnd, periodKey } from "./periods.js";
|
|
2
|
+
import { listed, MILESTONE_PERIODS, shortDate, money, roundCents, wholeDollars, } from "./text.js";
|
|
3
|
+
// Spend milestones: a bonus paid once a period after a spending total. Only
|
|
4
|
+
// spending the wallet tracks counts; earnrate never assumes the rest.
|
|
5
|
+
// The card's milestones the issuer runs on `date`.
|
|
6
|
+
export const liveMilestones = (card, date) => card.milestones.filter((m) => inEffect(m, date));
|
|
3
7
|
export function milestoneProgress(card, held, date) {
|
|
4
|
-
return card.
|
|
8
|
+
return liveMilestones(card, date).map((milestone) => {
|
|
5
9
|
const period = periodKey(milestone.period, date, held.anniversary ?? null);
|
|
6
10
|
const usage = held.milestone_usage?.[milestone.id];
|
|
7
11
|
return {
|
|
@@ -45,6 +49,10 @@ export function milestonePays(m) {
|
|
|
45
49
|
}
|
|
46
50
|
// "the $15,000 calendar-year spend for 10,000 bonus points"
|
|
47
51
|
export const milestoneWhat = (m) => `the ${wholeDollars(m.spend)} ${MILESTONE_PERIODS[m.period]} spend for ${milestonePays(m)}`;
|
|
52
|
+
// " starting Jan 1, 2027" for a milestone that hasn't started by `today`.
|
|
53
|
+
export const milestoneStarts = (m, today) => m.valid_from === undefined || m.valid_from <= today
|
|
54
|
+
? ''
|
|
55
|
+
: ` starting ${shortDate(m.valid_from)}`;
|
|
48
56
|
export function reachedText(card, hit, centsPerPoint) {
|
|
49
57
|
const m = card.milestones.find((x) => x.id === hit.id);
|
|
50
58
|
if (!m)
|
package/dist/engine/offers.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { bonusDollars } from "./bonus.js";
|
|
2
|
+
import { allCreditsYear } from "./credits.js";
|
|
2
3
|
import { matchesFilters } from "./explore.js";
|
|
3
4
|
import { membershipCosts, paidMemberships } from "./memberships.js";
|
|
4
5
|
import { inEffect } from "./periods.js";
|
|
5
|
-
import {
|
|
6
|
+
import { CREDITS_APART, heldName, listed, money, roundCents } from "./text.js";
|
|
6
7
|
import { cardValuation } from "./valuation.js";
|
|
7
8
|
function ineligibleText(catalog, wallet, notWith) {
|
|
8
9
|
const held = wallet.cards
|
|
@@ -49,6 +50,8 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
49
50
|
0;
|
|
50
51
|
const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
|
|
51
52
|
const membershipFee = membershipCosts(catalog, [card], paid)[0]?.annualFee ?? 0;
|
|
53
|
+
const firstYearValue = roundCents(value - (firstYearFee ?? 0) - membershipFee);
|
|
54
|
+
const credits = allCreditsYear(catalog, card, today).yearly;
|
|
52
55
|
offers.push({
|
|
53
56
|
cardId: card.id,
|
|
54
57
|
cardName: card.name,
|
|
@@ -68,7 +71,9 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
68
71
|
bonusValue: roundCents(value),
|
|
69
72
|
membershipFee,
|
|
70
73
|
ineligible: ineligibleText(catalog, wallet, offer.not_with ?? []),
|
|
71
|
-
firstYearValue
|
|
74
|
+
firstYearValue,
|
|
75
|
+
credits,
|
|
76
|
+
firstYearValueIfCreditsUsed: roundCents(firstYearValue + credits),
|
|
72
77
|
held: held.has(card.id),
|
|
73
78
|
lastVerified: card.last_verified,
|
|
74
79
|
verifiedBy: card.verified_by,
|
|
@@ -80,8 +85,8 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
80
85
|
const notes = [
|
|
81
86
|
`Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
|
|
82
87
|
`Each offer is the public one on the issuer's page on the date shown, checked weekly. ${OFFERS_MAY_DIFFER} Issuers also limit who can get a bonus; each card's note says how.`,
|
|
83
|
-
"First-year value is the bonus at your point values minus the first year's annual fee and any membership the card needs that you don't have. It leaves out what the card earns on your spending and
|
|
84
|
-
|
|
88
|
+
"First-year value is the bonus at your point values minus the first year's annual fee and any membership the card needs that you don't have. It leaves out what the card earns on your spending and its perks.",
|
|
89
|
+
CREDITS_APART,
|
|
85
90
|
];
|
|
86
91
|
return { offers, withoutOffer, notes };
|
|
87
92
|
}
|