earnrate 0.2.17 → 0.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/data/CHANGELOG.md +19 -1
- package/data/VERSION +1 -1
- package/data/cards/amex-business-gold.yaml +1 -0
- package/data/cards/amex-platinum.yaml +1 -1
- package/data/cards/chase-freedom-flex.yaml +3 -6
- package/data/cards/chase-ihg-one-rewards-premier.yaml +2 -0
- package/data/cards/chase-united-business.yaml +3 -0
- package/data/cards/chase-united-club-business.yaml +5 -0
- package/data/cards/citi-aadvantage-business.yaml +5 -0
- package/data/cards/citi-aadvantage-executive.yaml +2 -0
- package/data/cards/citi-aadvantage-globe.yaml +2 -0
- package/data/cards/citi-aadvantage-mileup.yaml +4 -0
- package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
- package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
- package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
- package/data/cards/citi-custom-cash.yaml +8 -10
- package/data/cards/citi-double-cash.yaml +1 -0
- package/data/cards/citi-my-best-buy-visa.yaml +1 -0
- package/data/cards/citi-strata-elite.yaml +2 -0
- package/data/cards/citi-strata-premier.yaml +2 -0
- package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
- package/data/changes.yaml +2840 -0
- package/dist/cli/offers.js +21 -5
- package/dist/cli/run.js +14 -3
- package/dist/cli/wallet.js +28 -3
- package/dist/data/change-integrity.js +105 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +7 -0
- package/dist/data/load.js +5 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +16 -5
- package/dist/engine/changes.js +124 -0
- package/dist/engine/checklist.js +18 -3
- package/dist/engine/explore.js +28 -9
- package/dist/engine/offers.js +52 -3
- package/dist/engine/periods.js +7 -0
- package/dist/engine/renewal.js +101 -0
- package/dist/engine/rule-text.js +2 -7
- package/dist/engine/setup.js +6 -3
- package/dist/engine/simulate.js +14 -7
- package/dist/engine/text.js +64 -0
- package/dist/engine/wallet-change.js +16 -8
- package/dist/engine/wallet-summary.js +4 -0
- package/dist/format/card-text.js +9 -2
- package/dist/format/offers.js +5 -3
- package/dist/format/wallet-yaml.js +3 -0
- package/dist/format/wallet.js +6 -1
- package/dist/mcp/context.js +11 -3
- package/dist/mcp/inputs.js +3 -1
- package/dist/mcp/prompts.js +3 -3
- package/dist/mcp/schemas.js +24 -0
- package/dist/mcp/tools/answers.js +42 -24
- package/dist/mcp/tools/wallet.js +28 -8
- package/dist/schema/card.js +7 -0
- package/dist/schema/changes.js +73 -0
- package/dist/schema/wallet.js +5 -1
- package/package.json +1 -1
package/dist/cli/offers.js
CHANGED
|
@@ -9,9 +9,11 @@ import { cardList, valuationProblem } from "./args.js";
|
|
|
9
9
|
import { cardsToUse, RUNTIME_ERROR, savedSettings, USAGE_ERROR, } from "./run.js";
|
|
10
10
|
const OFFERS_HELP = `Usage: ${CLI_NAME} offers [--cash | --points] [--no-fee] [--business] [options]
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
Which card to get next: ranks cards you don't have by their first two years
|
|
13
|
+
together. That's what each adds to your cards at typical spending, plus its
|
|
14
|
+
welcome bonus in year one if the minimum spend fits in the window, less each
|
|
15
|
+
year's annual fee and any membership it needs. Each also shows the bonus
|
|
16
|
+
alone. Issuers saved with ${CLI_NAME} skip-issuers are left out.
|
|
15
17
|
|
|
16
18
|
Options:
|
|
17
19
|
--cash Cash back cards only.
|
|
@@ -21,6 +23,8 @@ Options:
|
|
|
21
23
|
--held Include cards already in your wallet.
|
|
22
24
|
--cards <ids> Cards you have, instead of your wallet, comma separated.
|
|
23
25
|
--member <name> A membership you have, in place of the saved ones.
|
|
26
|
+
--skip-issuer <id> Leave out an issuer's cards (chase), in place of the
|
|
27
|
+
saved list. Repeat for more.
|
|
24
28
|
--valuation <profile> flat or points-heavy, instead of your wallet's values.
|
|
25
29
|
--all List every offer, not just the top 10.
|
|
26
30
|
--json Print the full comparison as JSON.
|
|
@@ -39,6 +43,7 @@ export async function runOffers(argv, io) {
|
|
|
39
43
|
held: { type: 'boolean' },
|
|
40
44
|
cards: { type: 'string' },
|
|
41
45
|
member: { type: 'string', multiple: true },
|
|
46
|
+
'skip-issuer': { type: 'string', multiple: true },
|
|
42
47
|
valuation: { type: 'string' },
|
|
43
48
|
all: { type: 'boolean' },
|
|
44
49
|
json: { type: 'boolean' },
|
|
@@ -75,12 +80,23 @@ export async function runOffers(argv, io) {
|
|
|
75
80
|
? await savedSettings(io).then((saved) => ({
|
|
76
81
|
valuations: saved.valuations,
|
|
77
82
|
memberships: values.member ?? saved.memberships,
|
|
83
|
+
...(saved.skipped_issuers
|
|
84
|
+
? { skipped_issuers: saved.skipped_issuers }
|
|
85
|
+
: {}),
|
|
78
86
|
cards: [],
|
|
79
87
|
}))
|
|
80
88
|
: found;
|
|
89
|
+
const skips = values['skip-issuer'];
|
|
90
|
+
const issuers = new Set(catalog.cards.map((c) => c.issuer));
|
|
91
|
+
const unknown = (skips ?? []).filter((i) => !issuers.has(i));
|
|
92
|
+
if (unknown.length > 0) {
|
|
93
|
+
io.stderr(`No card earnrate knows is from ${unknown.map((i) => `'${i}'`).join(', ')}. Issuers: ${[...issuers].sort().join(', ')}.\n`);
|
|
94
|
+
return USAGE_ERROR;
|
|
95
|
+
}
|
|
96
|
+
const skipped = skips ? { ...loaded, skipped_issuers: skips } : loaded;
|
|
81
97
|
const wallet = values.valuation && isValuationProfile(values.valuation)
|
|
82
|
-
? withValuationProfile(
|
|
83
|
-
:
|
|
98
|
+
? withValuationProfile(skipped, values.valuation)
|
|
99
|
+
: skipped;
|
|
84
100
|
const comparison = compareOffers(catalog, wallet, {
|
|
85
101
|
kind: values.cash ? 'cash' : values.points ? 'points' : 'any',
|
|
86
102
|
business: values.business ?? false,
|
package/dist/cli/run.js
CHANGED
|
@@ -47,6 +47,7 @@ Save your cards (optional):
|
|
|
47
47
|
${CLI_NAME} remove <card-id> Remove a card.
|
|
48
48
|
${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime, costco).
|
|
49
49
|
${CLI_NAME} values [UR=1.5 ...] Show or set what your points are worth.
|
|
50
|
+
${CLI_NAME} skip-issuers [chase ...] Issuers to leave out of next-card answers.
|
|
50
51
|
|
|
51
52
|
Other commands:
|
|
52
53
|
${CLI_NAME} cheatsheet The best card for each category this quarter.
|
|
@@ -126,7 +127,13 @@ export async function cardsToUse(catalog, file, io, named) {
|
|
|
126
127
|
io.stderr(`${wallet}\n`);
|
|
127
128
|
return null;
|
|
128
129
|
}
|
|
129
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
...wallet,
|
|
132
|
+
valuations: saved.valuations,
|
|
133
|
+
...(saved.skipped_issuers
|
|
134
|
+
? { skipped_issuers: saved.skipped_issuers }
|
|
135
|
+
: {}),
|
|
136
|
+
};
|
|
130
137
|
}
|
|
131
138
|
try {
|
|
132
139
|
await access(file);
|
|
@@ -150,8 +157,12 @@ export const NO_WALLET_HINT = `These are every card earnrate knows. For the best
|
|
|
150
157
|
// can't be read, has none.
|
|
151
158
|
export async function savedSettings(io) {
|
|
152
159
|
try {
|
|
153
|
-
const { valuations, memberships } = await loadWallet(walletPath(io.env));
|
|
154
|
-
return {
|
|
160
|
+
const { valuations, memberships, skipped_issuers } = await loadWallet(walletPath(io.env));
|
|
161
|
+
return {
|
|
162
|
+
valuations,
|
|
163
|
+
memberships,
|
|
164
|
+
...(skipped_issuers ? { skipped_issuers } : {}),
|
|
165
|
+
};
|
|
155
166
|
}
|
|
156
167
|
catch {
|
|
157
168
|
return { valuations: {}, memberships: [] };
|
package/dist/cli/wallet.js
CHANGED
|
@@ -4,6 +4,7 @@ import { FileWalletStore } from "../data/file-store.js";
|
|
|
4
4
|
import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
|
|
5
5
|
import { isValuationProfile, profileText, VALUATION_PROFILES, } from "../engine/profiles.js";
|
|
6
6
|
import { membershipName } from "../engine/memberships.js";
|
|
7
|
+
import { issuerName } from "../format/card-text.js";
|
|
7
8
|
import { knownMemberships, searchCards } from "../engine/setup.js";
|
|
8
9
|
import { currencyChanges } from "../engine/wallet-change.js";
|
|
9
10
|
import { formatCardInfo, formatNotes } from "../format/cards.js";
|
|
@@ -13,7 +14,8 @@ const CARD_OPTIONS = `Options:
|
|
|
13
14
|
--pick <choice>=<options> Bonus categories you picked for this period, e.g.
|
|
14
15
|
--pick five-percent=fast-food,movie-theaters.
|
|
15
16
|
Repeat for each choice.
|
|
16
|
-
--anniversary <MM-DD> Account anniversary, for caps that reset on it
|
|
17
|
+
--anniversary <MM-DD> Account anniversary, for caps that reset on it
|
|
18
|
+
and a fee card's renewal check.
|
|
17
19
|
--opened <date> Optional: the day the account opened (YYYY-MM-DD),
|
|
18
20
|
for cards with a first-year rate. Without it the
|
|
19
21
|
card is ranked at its standard rates.
|
|
@@ -81,6 +83,13 @@ and point values stay.
|
|
|
81
83
|
|
|
82
84
|
With no arguments, shows your memberships and the ones earnrate knows. With
|
|
83
85
|
names, replaces the list. --none clears it. No cards need to be saved first.
|
|
86
|
+
`,
|
|
87
|
+
'skip-issuers': `Usage: ${CLI_NAME} skip-issuers [<issuer>...] [--none]
|
|
88
|
+
|
|
89
|
+
Issuers you can't or won't get a new card from (for example chase, if
|
|
90
|
+
you're over its 5/24 rule). ${CLI_NAME} offers and the cards that would add
|
|
91
|
+
the most leave them out. With no arguments, shows the list and the issuers
|
|
92
|
+
earnrate knows. With issuers, replaces the list. --none clears it.
|
|
84
93
|
`,
|
|
85
94
|
values: `Usage: ${CLI_NAME} values [<currency>=<cents>...] [--profile <name>]
|
|
86
95
|
|
|
@@ -202,8 +211,8 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
202
211
|
if (command !== 'add' && command !== 'update' && cardOptionsGiven) {
|
|
203
212
|
return usage(`${CLI_NAME} ${command} doesn't take card options.`);
|
|
204
213
|
}
|
|
205
|
-
if (command !== 'memberships' && values.none) {
|
|
206
|
-
return usage('--none is only for memberships.');
|
|
214
|
+
if (command !== 'memberships' && command !== 'skip-issuers' && values.none) {
|
|
215
|
+
return usage('--none is only for memberships and skip-issuers.');
|
|
207
216
|
}
|
|
208
217
|
const catalog = await loadCatalog();
|
|
209
218
|
const file = walletPath(io.env);
|
|
@@ -249,6 +258,22 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
249
258
|
.map(([id, c]) => `${id} ${c === null ? 'back to the default' : `${String(c)}¢`}`)
|
|
250
259
|
.join(', ')}.`, null, io);
|
|
251
260
|
}
|
|
261
|
+
if (command === 'skip-issuers') {
|
|
262
|
+
const issuerList = (ids) => ids.map((i) => `${issuerName(catalog, i)} (${i})`).join(', ');
|
|
263
|
+
if (positionals.length === 0 && !values.none) {
|
|
264
|
+
const held = await heldWallet(io);
|
|
265
|
+
const known = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
|
|
266
|
+
io.stdout(`Left out: ${held?.skipped_issuers?.length ? issuerList(held.skipped_issuers) : 'none'}\nKnown: ${known.join(', ')}\n`);
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
if (positionals.length > 0 && values.none) {
|
|
270
|
+
return usage('Give issuers or --none, not both.');
|
|
271
|
+
}
|
|
272
|
+
const list = values.none ? [] : positionals;
|
|
273
|
+
return report(await store.setSkippedIssuers(list), list.length > 0
|
|
274
|
+
? `Next-card answers now leave out ${issuerList(list)} (${where}).`
|
|
275
|
+
: `Next-card answers include every issuer again (${where}).`, null, io);
|
|
276
|
+
}
|
|
252
277
|
if (command === 'memberships') {
|
|
253
278
|
if (positionals.length === 0 && !values.none) {
|
|
254
279
|
const held = await heldWallet(io);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { changeHeadline, fieldValue, subjectOf } from "../engine/changes.js";
|
|
3
|
+
import { WelcomeOfferSchema } from "../schema/card.js";
|
|
4
|
+
import { CHANGE_SUBJECTS, } from "../schema/changes.js";
|
|
5
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
6
|
+
// Every key the record gives matches the data; keys it leaves out (a note,
|
|
7
|
+
// a default) don't matter. null means absent.
|
|
8
|
+
export function matches(expected, actual) {
|
|
9
|
+
if (expected === null)
|
|
10
|
+
return actual === undefined || actual === null;
|
|
11
|
+
if (Array.isArray(expected))
|
|
12
|
+
return (Array.isArray(actual) &&
|
|
13
|
+
actual.length === expected.length &&
|
|
14
|
+
expected.every((e, i) => matches(e, actual[i])));
|
|
15
|
+
if (isRecord(expected))
|
|
16
|
+
return (isRecord(actual) &&
|
|
17
|
+
Object.entries(expected).every(([k, v]) => matches(v, actual[k])));
|
|
18
|
+
return isDeepStrictEqual(expected, actual);
|
|
19
|
+
}
|
|
20
|
+
const subjectKind = (c) => CHANGE_SUBJECTS.find((s) => c[s] !== undefined) ?? 'cards';
|
|
21
|
+
// One path covers another when they're the same or one is inside the other.
|
|
22
|
+
const overlaps = (a, b) => a === undefined ||
|
|
23
|
+
b === undefined ||
|
|
24
|
+
a === b ||
|
|
25
|
+
a.startsWith(`${b}.`) ||
|
|
26
|
+
b.startsWith(`${a}.`);
|
|
27
|
+
const removes = (later, field) => later.now === null &&
|
|
28
|
+
(later.field === undefined ||
|
|
29
|
+
(field !== undefined &&
|
|
30
|
+
(field === later.field || field.startsWith(`${later.field}.`))));
|
|
31
|
+
export function changeProblems(releases, catalog, changelog, version) {
|
|
32
|
+
const problems = [];
|
|
33
|
+
const sections = [...changelog.matchAll(/^## (\S+)$/gm)].map((m) => m[1]);
|
|
34
|
+
const versions = releases.map((r) => r.version);
|
|
35
|
+
if (!isDeepStrictEqual(sections, versions))
|
|
36
|
+
problems.push('data/changes.yaml releases must match data/CHANGELOG.md sections, newest first');
|
|
37
|
+
if (versions[0] !== version)
|
|
38
|
+
problems.push(`data/changes.yaml must start with ${version}`);
|
|
39
|
+
for (let i = 1; i < releases.length; i++)
|
|
40
|
+
if ((releases[i]?.date ?? '') > (releases[i - 1]?.date ?? ''))
|
|
41
|
+
problems.push(`${versions[i] ?? ''}: releases must be newest first`);
|
|
42
|
+
const touches = releases.flatMap((r, at) => r.changes.flatMap((change) => {
|
|
43
|
+
const kind = subjectKind(change);
|
|
44
|
+
return (change[kind] ?? []).map((id) => ({ at, kind, id, change }));
|
|
45
|
+
}));
|
|
46
|
+
for (const t of touches) {
|
|
47
|
+
const { change, kind, id, at } = t;
|
|
48
|
+
const release = releases[at];
|
|
49
|
+
const where = `${release?.version ?? ''} ${id}${change.field ? ` ${change.field}` : ''}`;
|
|
50
|
+
if (change.date !== undefined && change.date > (release?.date ?? ''))
|
|
51
|
+
problems.push(`${where}: date is after its release`);
|
|
52
|
+
if (change.detected !== undefined &&
|
|
53
|
+
change.detected > (change.date ?? release?.date ?? ''))
|
|
54
|
+
problems.push(`${where}: detected after it was released`);
|
|
55
|
+
const later = touches.filter((o) => o.kind === kind &&
|
|
56
|
+
o.id === id &&
|
|
57
|
+
o.change !== change &&
|
|
58
|
+
o.at <= at &&
|
|
59
|
+
overlaps(o.change.field, change.field));
|
|
60
|
+
const removedLater = later.some((o) => o.at < at && removes(o.change, change.field));
|
|
61
|
+
const subject = subjectOf(catalog, kind, id);
|
|
62
|
+
const value = fieldValue(subject, change.field);
|
|
63
|
+
const removal = change.now === null;
|
|
64
|
+
if (subject === undefined && !removedLater && !(removal && !change.field))
|
|
65
|
+
problems.push(`${where}: no ${kind} '${id}' in the data`);
|
|
66
|
+
else if (subject !== undefined &&
|
|
67
|
+
value === undefined &&
|
|
68
|
+
!removal &&
|
|
69
|
+
!removedLater)
|
|
70
|
+
problems.push(`${where}: no such field`);
|
|
71
|
+
// The newest change to a path must still describe the data.
|
|
72
|
+
if (later.length === 0 && change.now !== undefined) {
|
|
73
|
+
const current = removal && !change.field ? subject : value;
|
|
74
|
+
if (!matches(change.now, current))
|
|
75
|
+
problems.push(`${where}: 'now' doesn't match the data`);
|
|
76
|
+
}
|
|
77
|
+
if (change.cause === 'issuer') {
|
|
78
|
+
if (!change.source?.startsWith('https://'))
|
|
79
|
+
problems.push(`${where}: an issuer change needs an https source`);
|
|
80
|
+
if (change.field === 'welcome_offer')
|
|
81
|
+
for (const v of [change.was, change.now])
|
|
82
|
+
if (v !== null && !WelcomeOfferSchema.safeParse(v).success)
|
|
83
|
+
problems.push(`${where}: not a welcome offer: ${JSON.stringify(v)}`);
|
|
84
|
+
try {
|
|
85
|
+
changeHeadline(catalog, change);
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
problems.push(`${where}: ${e.message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// A value recorded as the old one must be what the previous change left.
|
|
93
|
+
for (const t of touches) {
|
|
94
|
+
if (t.change.was === undefined || t.change.field === undefined)
|
|
95
|
+
continue;
|
|
96
|
+
const before = touches.find((o) => o.kind === t.kind &&
|
|
97
|
+
o.id === t.id &&
|
|
98
|
+
o.at > t.at &&
|
|
99
|
+
o.change.field === t.change.field &&
|
|
100
|
+
o.change.now !== undefined);
|
|
101
|
+
if (before && !matches(t.change.was, before.change.now))
|
|
102
|
+
problems.push(`${releases[t.at]?.version ?? ''} ${t.id} ${t.change.field}: 'was' doesn't match the previous change's 'now'`);
|
|
103
|
+
}
|
|
104
|
+
return problems;
|
|
105
|
+
}
|
package/dist/data/file-store.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { bookPurchase } from "../engine/record.js";
|
|
2
|
-
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
|
|
2
|
+
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withSkippedIssuers, withValuations, } from "../engine/wallet-change.js";
|
|
3
3
|
import { loadWallet } from "./load.js";
|
|
4
4
|
import { emptyWallet, } from "./store.js";
|
|
5
|
-
import { addCardText, commitWalletText, EMPTY_WALLET_TEXT, readWalletText, removeCardText, setMembershipsText, setValuationsText, updateCardText, writeProgress, } from "./wallet-edit.js";
|
|
5
|
+
import { addCardText, commitWalletText, EMPTY_WALLET_TEXT, readWalletText, removeCardText, setMembershipsText, setTopListText, setValuationsText, updateCardText, writeProgress, } from "./wallet-edit.js";
|
|
6
6
|
const failed = (error) => ({ ok: false, error });
|
|
7
7
|
const errorText = (e) => (e instanceof Error ? e.message : String(e));
|
|
8
8
|
const queues = new Map();
|
|
@@ -75,6 +75,9 @@ export class FileWalletStore {
|
|
|
75
75
|
setMemberships(memberships) {
|
|
76
76
|
return this.change((w) => withMemberships(this.catalog, w ?? emptyWallet(), memberships), (text, change) => setMembershipsText(text ?? EMPTY_WALLET_TEXT, change.wallet.memberships));
|
|
77
77
|
}
|
|
78
|
+
setSkippedIssuers(issuers) {
|
|
79
|
+
return this.change((w) => withSkippedIssuers(this.catalog, w ?? emptyWallet(), issuers), (text, change) => setTopListText(text ?? EMPTY_WALLET_TEXT, 'skipped_issuers', change.wallet.skipped_issuers ?? []));
|
|
80
|
+
}
|
|
78
81
|
setValuations(changes) {
|
|
79
82
|
return this.change((w) => withValuations(this.catalog, w ?? emptyWallet(), changes), (text) => setValuationsText(text ?? EMPTY_WALLET_TEXT, changes));
|
|
80
83
|
}
|
package/dist/data/integrity.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
|
+
import { isAnniversary } from "../engine/periods.js";
|
|
2
3
|
import { WALLET_VERSION } from "../schema/wallet.js";
|
|
3
4
|
function duplicates(ids) {
|
|
4
5
|
const seen = new Set();
|
|
@@ -305,6 +306,10 @@ export function walletProblems(wallet, catalog) {
|
|
|
305
306
|
for (const id of duplicates(wallet.cards.map((c) => c.id))) {
|
|
306
307
|
problems.push(`wallet: card '${id}' is listed twice`);
|
|
307
308
|
}
|
|
309
|
+
const issuers = new Set(catalog.cards.map((c) => c.issuer));
|
|
310
|
+
for (const id of wallet.skipped_issuers ?? [])
|
|
311
|
+
if (!issuers.has(id))
|
|
312
|
+
problems.push(`wallet skipped_issuers: no card is from '${id}'`);
|
|
308
313
|
for (const [id, valuation] of Object.entries(wallet.valuations)) {
|
|
309
314
|
const currency = currencies.get(id);
|
|
310
315
|
if (!currency) {
|
|
@@ -319,6 +324,8 @@ export function walletProblems(wallet, catalog) {
|
|
|
319
324
|
}
|
|
320
325
|
for (const held of wallet.cards) {
|
|
321
326
|
const where = `wallet card ${held.id}`;
|
|
327
|
+
if (held.anniversary !== undefined && !isAnniversary(held.anniversary))
|
|
328
|
+
problems.push(`${where}: anniversary '${held.anniversary}' isn't a date`);
|
|
322
329
|
const card = cards.get(held.id);
|
|
323
330
|
if (!card) {
|
|
324
331
|
problems.push(`${where}: not in the card catalog`);
|
package/dist/data/load.js
CHANGED
|
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
import { CONFIG_DIR, WALLET_ENV_VAR } from "../brand.js";
|
|
8
8
|
import { CardSchema } from "../schema/card.js";
|
|
9
9
|
import { CategoriesFileSchema } from "../schema/category.js";
|
|
10
|
+
import { ChangesFileSchema } from "../schema/changes.js";
|
|
10
11
|
import { CurrenciesFileSchema } from "../schema/currency.js";
|
|
11
12
|
import { IssuersFileSchema } from "../schema/issuer.js";
|
|
12
13
|
import { MembershipsFileSchema } from "../schema/membership.js";
|
|
@@ -50,6 +51,10 @@ export async function loadCatalog(dir = DATA_DIR) {
|
|
|
50
51
|
merchants: { seed, overrides },
|
|
51
52
|
};
|
|
52
53
|
}
|
|
54
|
+
// The structured change record, newest release first.
|
|
55
|
+
export function loadChanges(dir = DATA_DIR) {
|
|
56
|
+
return readYaml(path.join(dir, 'changes.yaml'), ChangesFileSchema);
|
|
57
|
+
}
|
|
53
58
|
// MCP clients pass config values through without a shell, so "~" or
|
|
54
59
|
// "${HOME}" can arrive as literal text; expand them here.
|
|
55
60
|
export function walletPath(env = process.env) {
|
package/dist/data/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { bookPurchase } from "../engine/record.js";
|
|
2
|
-
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
|
|
2
|
+
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withSkippedIssuers, withValuations, } from "../engine/wallet-change.js";
|
|
3
3
|
import { withoutCardNumbers } from "../schema/card-number.js";
|
|
4
4
|
import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
|
|
5
5
|
const failed = (error) => ({ ok: false, error });
|
|
@@ -61,6 +61,9 @@ export class JsonWalletStore {
|
|
|
61
61
|
setMemberships(memberships) {
|
|
62
62
|
return this.change((w) => withMemberships(this.catalog, w ?? emptyWallet(), memberships));
|
|
63
63
|
}
|
|
64
|
+
setSkippedIssuers(issuers) {
|
|
65
|
+
return this.change((w) => withSkippedIssuers(this.catalog, w ?? emptyWallet(), issuers));
|
|
66
|
+
}
|
|
64
67
|
setValuations(changes) {
|
|
65
68
|
return this.change((w) => withValuations(this.catalog, w ?? emptyWallet(), changes));
|
|
66
69
|
}
|
package/dist/data/wallet-edit.js
CHANGED
|
@@ -212,18 +212,29 @@ export function updateCardText(text, cardId, fields) {
|
|
|
212
212
|
}
|
|
213
213
|
return out;
|
|
214
214
|
}
|
|
215
|
-
export
|
|
215
|
+
export const setMembershipsText = (text, memberships) => setTopListText(text, 'memberships', memberships);
|
|
216
|
+
// A top-level list, written as one flow line before `cards:`. An empty list
|
|
217
|
+
// other than memberships loses its line.
|
|
218
|
+
export function setTopListText(text, key, list) {
|
|
216
219
|
const root = rootMap(text);
|
|
217
|
-
const line =
|
|
218
|
-
|
|
220
|
+
const line = list.length === 0 && key !== 'memberships'
|
|
221
|
+
? ''
|
|
222
|
+
: `${key}: ${flowList(list)}\n`;
|
|
223
|
+
const pair = root.items.find((p) => keyOf(p) === key);
|
|
219
224
|
if (pair) {
|
|
220
225
|
const start = range(pair.key)[0];
|
|
221
226
|
const end = afterLine(text, (pair.value === null ? range(pair.key) : range(pair.value))[1] - 1);
|
|
222
|
-
|
|
227
|
+
// A comment at the end of the key's line stays with the new value.
|
|
228
|
+
const first = text
|
|
229
|
+
.slice(start, afterLine(text, start))
|
|
230
|
+
.replace(/\r?\n$/, '');
|
|
231
|
+
const comment = / #.*$/.exec(first)?.[0] ?? '';
|
|
232
|
+
const kept = line === '' ? '' : line.replace(/\n$/, `${comment}\n`);
|
|
233
|
+
return text.slice(0, start) + kept + text.slice(end);
|
|
223
234
|
}
|
|
224
235
|
const cards = root.items.find((p) => keyOf(p) === 'cards');
|
|
225
236
|
return cards
|
|
226
|
-
? insertLines(text,
|
|
237
|
+
? insertLines(text, aboveComments(text, range(cards.key)[0]), line)
|
|
227
238
|
: insertLines(text, text.length, line);
|
|
228
239
|
}
|
|
229
240
|
// Point values, one currency at a time so the rest of the file, comments
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { offerTerms } from "./offers.js";
|
|
2
|
+
import { creditName, ruleWhat } from "./rule-text.js";
|
|
3
|
+
import { creditBack, currencyName, earnText, money, pct, possessive, shortCardName, shortDate, } from "./text.js";
|
|
4
|
+
// Newest first.
|
|
5
|
+
export function datedChanges(releases) {
|
|
6
|
+
return releases
|
|
7
|
+
.flatMap((r) => r.changes.map((change) => ({
|
|
8
|
+
version: r.version,
|
|
9
|
+
date: change.date ?? r.date,
|
|
10
|
+
change,
|
|
11
|
+
})))
|
|
12
|
+
.sort((a, b) => (a.date === b.date ? 0 : a.date < b.date ? 1 : -1));
|
|
13
|
+
}
|
|
14
|
+
export const issuerChanges = (releases) => datedChanges(releases).filter((d) => d.change.cause === 'issuer');
|
|
15
|
+
export function subjectOf(catalog, kind, id) {
|
|
16
|
+
switch (kind) {
|
|
17
|
+
case 'cards':
|
|
18
|
+
return catalog.cards.find((c) => c.id === id);
|
|
19
|
+
case 'merchants':
|
|
20
|
+
return [...catalog.merchants.seed, ...catalog.merchants.overrides].find((m) => m.id === id);
|
|
21
|
+
case 'categories':
|
|
22
|
+
return catalog.categories.find((c) => c.id === id);
|
|
23
|
+
case 'currencies':
|
|
24
|
+
return catalog.currencies.find((c) => c.id === id);
|
|
25
|
+
case 'memberships':
|
|
26
|
+
return catalog.memberships.find((m) => m.id === id);
|
|
27
|
+
case 'issuers':
|
|
28
|
+
return catalog.issuers.find((i) => i.id === id);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
32
|
+
// `rules.amazon.rate` on a card: list items are found by their id.
|
|
33
|
+
export function fieldValue(subject, field) {
|
|
34
|
+
if (field === undefined)
|
|
35
|
+
return subject;
|
|
36
|
+
let value = subject;
|
|
37
|
+
for (const step of field.split('.')) {
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
value = value.find((item) => isRecord(item) && item.id === step);
|
|
40
|
+
else if (isRecord(value))
|
|
41
|
+
value = value[step];
|
|
42
|
+
else
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
const NO_SPEND = ' with no spending required';
|
|
48
|
+
// Mid-sentence: "up to 80,000 points".
|
|
49
|
+
const lower = (text) => text.replace(/^Up to /, 'up to ');
|
|
50
|
+
// Only the part that changed is said twice: "$150 → $200 with no spending
|
|
51
|
+
// required", "80,000 points after $3,000 … → after $2,000 …".
|
|
52
|
+
function offerChange(catalog, card, was, now) {
|
|
53
|
+
const units = currencyName(catalog, card.currency);
|
|
54
|
+
const terms = (o) => lower(offerTerms(o, units));
|
|
55
|
+
const amount = (o) => lower(offerTerms({ ...o, spend: 0, months: 1 }, units)).slice(0, -NO_SPEND.length);
|
|
56
|
+
const tail = (o) => terms(o).slice(amount(o).length);
|
|
57
|
+
if (tail(was) === tail(now))
|
|
58
|
+
return `${amount(was)} → ${terms(now)}`;
|
|
59
|
+
if (amount(was) === amount(now))
|
|
60
|
+
return `${terms(was)} → ${tail(now).trim()}`;
|
|
61
|
+
return `${terms(was)} → ${terms(now)}`;
|
|
62
|
+
}
|
|
63
|
+
const feeWords = (fee) => typeof fee === 'number' ? (fee === 0 ? 'none' : money(fee)) : 'not on file';
|
|
64
|
+
// "Prime Visa's welcome offer: $150 → $200 with no spending required": one
|
|
65
|
+
// plain line for an issuer change, from its old and new values.
|
|
66
|
+
export function changeHeadline(catalog, change) {
|
|
67
|
+
const id = change.cards?.[0] ?? '';
|
|
68
|
+
const card = catalog.cards.find((c) => c.id === id);
|
|
69
|
+
if (!card || change.field === undefined)
|
|
70
|
+
throw new Error(`no headline for a change to ${id}`);
|
|
71
|
+
const name = possessive(card.short_name ?? shortCardName(card.name));
|
|
72
|
+
const [kind, item] = change.field.split('.');
|
|
73
|
+
const { was, now } = change;
|
|
74
|
+
switch (kind) {
|
|
75
|
+
case 'welcome_offer':
|
|
76
|
+
if (was === null)
|
|
77
|
+
return `${name} new welcome offer: ${terms(now)}`;
|
|
78
|
+
if (now === null)
|
|
79
|
+
return `${name} welcome offer ended: ${terms(was)}`;
|
|
80
|
+
return `${name} welcome offer: ${offerChange(catalog, card, was, now)}`;
|
|
81
|
+
case 'annual_fee':
|
|
82
|
+
case 'first_year_fee':
|
|
83
|
+
return `${name} ${kind === 'annual_fee' ? 'annual fee' : 'first-year fee'}: ${feeWords(was)} → ${feeWords(now)}`;
|
|
84
|
+
case 'foreign_transaction_fee': {
|
|
85
|
+
const fee = (v) => typeof v === 'number' ? (v === 0 ? 'none' : pct(v)) : 'not on file';
|
|
86
|
+
return `${name} foreign transaction fee: ${fee(was)} → ${fee(now)}`;
|
|
87
|
+
}
|
|
88
|
+
case 'applications_closed':
|
|
89
|
+
return `${name} applications closed ${shortDate(String(now))}`;
|
|
90
|
+
case 'rules': {
|
|
91
|
+
const rule = (card.rules.find((r) => r.id === item) ?? now ?? was);
|
|
92
|
+
const rate = (v) => v === null
|
|
93
|
+
? 'none'
|
|
94
|
+
: earnText(catalog, typeof v === 'number' ? v : v.rate, card.currency);
|
|
95
|
+
return `${name} rate ${ruleWhat(catalog, rule, card)}: ${rate(was)} → ${rate(now)}`;
|
|
96
|
+
}
|
|
97
|
+
case 'credits': {
|
|
98
|
+
const credit = (card.credits.find((c) => c.id === item) ??
|
|
99
|
+
now ??
|
|
100
|
+
was);
|
|
101
|
+
const back = (v) => v === null ? 'none' : creditBack(v);
|
|
102
|
+
return `${name} ${creditName(catalog, credit)} credit: ${back(was)} → ${back(now)}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
throw new Error(`no headline for an issuer change to ${change.field}`);
|
|
106
|
+
function terms(o) {
|
|
107
|
+
return lower(offerTerms(o, currencyName(catalog, card?.currency ?? '')));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function freshness(catalog, releases) {
|
|
111
|
+
const verified = catalog.cards.map((c) => c.last_verified).sort();
|
|
112
|
+
const first = releases.at(-1);
|
|
113
|
+
const latest = releases[0];
|
|
114
|
+
if (!first || !latest)
|
|
115
|
+
throw new Error('no data releases');
|
|
116
|
+
return {
|
|
117
|
+
issuerChanges: issuerChanges(releases).length,
|
|
118
|
+
since: first.date,
|
|
119
|
+
latestRelease: latest.date,
|
|
120
|
+
latestVersion: latest.version,
|
|
121
|
+
reviewedFrom: verified[0] ?? '',
|
|
122
|
+
reviewedTo: verified.at(-1) ?? '',
|
|
123
|
+
};
|
|
124
|
+
}
|
package/dist/engine/checklist.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { addDays, currentPeriodEnd, daysBetween } from "./periods.js";
|
|
2
|
-
import { CAP_PERIODS, choiceName, askCreditText, enrollCreditText, introEndsText, listed, money, shortDate, useCreditText, wholeDollars, possessive, } from "./text.js";
|
|
2
|
+
import { CAP_PERIODS, choiceName, askCreditText, enrollCreditText, introEndsText, listed, money, shortDate, renewalText, useCreditText, wholeDollars, possessive, } from "./text.js";
|
|
3
3
|
import { milestonePays } from "./milestones.js";
|
|
4
|
+
import { renewalCheck, renewalDue } from "./renewal.js";
|
|
4
5
|
import { creditLabel, optionName } from "./rule-text.js";
|
|
5
6
|
import { activationsDue, walletSummary, } from "./wallet-summary.js";
|
|
7
|
+
// Node-free: earnrate.dev builds the same list from its browser wallet.
|
|
8
|
+
const issuerLabel = (catalog, id) => catalog.issuers.find((i) => i.id === id)?.name ?? id;
|
|
6
9
|
// "5% and 2% categories", "top category": a choice already named for its
|
|
7
10
|
// category isn't followed by "categories" again.
|
|
8
11
|
function pickWhat(names) {
|
|
@@ -17,7 +20,9 @@ function pickWhat(names) {
|
|
|
17
20
|
// hasn't posted yet.
|
|
18
21
|
export function checklist(catalog, wallet, today, summary = walletSummary(catalog, wallet, today),
|
|
19
22
|
// A shorter card name than the issuer's full one, where the caller has it.
|
|
20
|
-
shortName
|
|
23
|
+
shortName,
|
|
24
|
+
// For the renewal check; typical amounts when not given.
|
|
25
|
+
spending) {
|
|
21
26
|
const items = [];
|
|
22
27
|
for (const held of wallet.cards) {
|
|
23
28
|
const card = catalog.cards.find((c) => c.id === held.id);
|
|
@@ -74,7 +79,7 @@ shortName) {
|
|
|
74
79
|
items.push({
|
|
75
80
|
kind: 'pick',
|
|
76
81
|
cardId: card.id,
|
|
77
|
-
text: `${possessive(name)} ${listed(assumed.map((c) => choiceName(c.id)))} pick is assumed to be ${listed(assumed.flatMap((c) => (c.default ?? []).map((o) => optionName(catalog, card, c.id, o))))}; set it if you picked something else.`,
|
|
82
|
+
text: `${possessive(name)} ${listed(assumed.map((c) => choiceName(c.id)))} pick is assumed to be ${listed(assumed.flatMap((c) => (c.default ?? []).map((o) => optionName(catalog, card, c.id, o))))}; set it if ${assumed.every((c) => c.automatic) ? 'you expect another to have the most spending' : 'you picked something else'}.`,
|
|
78
83
|
due: null,
|
|
79
84
|
});
|
|
80
85
|
for (const cap of s.caps) {
|
|
@@ -158,6 +163,16 @@ shortName) {
|
|
|
158
163
|
text: introEndsText(name, s.intro.days, s.intro.until),
|
|
159
164
|
due: s.intro.until,
|
|
160
165
|
});
|
|
166
|
+
const due = (card.annual_fee ?? 0) > 0 ? renewalDue(held, today) : null;
|
|
167
|
+
if (due !== null) {
|
|
168
|
+
const r = renewalCheck(catalog, wallet, card, due, spending);
|
|
169
|
+
items.push({
|
|
170
|
+
kind: 'renewal',
|
|
171
|
+
cardId: card.id,
|
|
172
|
+
text: renewalText(name, issuerLabel(catalog, card.issuer), r),
|
|
173
|
+
due,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
161
176
|
if (s.rotationGap !== null) {
|
|
162
177
|
items.push({
|
|
163
178
|
kind: 'rotation',
|
package/dist/engine/explore.js
CHANGED
|
@@ -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 { amountUnknownText, capSentence, categoryPhrase, choiceName, codesAs, creditBack, creditThen, creditTerms, earnText, listed, paidText, PAYMENT_PHRASES, paymentUnknownText,
|
|
11
|
+
import { amountUnknownText, capSentence, categoryPhrase, choiceName, codesAs, creditBack, creditThen, creditTerms, earnText, listed, paidText, PAYMENT_PHRASES, paymentUnknownText, pickCondition, 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 = {
|
|
@@ -93,7 +93,12 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
|
|
|
93
93
|
const cap = best.cap?.cap;
|
|
94
94
|
return {
|
|
95
95
|
pick: choice && picked
|
|
96
|
-
? {
|
|
96
|
+
? {
|
|
97
|
+
choice: choice.id,
|
|
98
|
+
options: picked,
|
|
99
|
+
period: choice.period,
|
|
100
|
+
automatic: choice.automatic === true,
|
|
101
|
+
}
|
|
97
102
|
: null,
|
|
98
103
|
activation: rule.requires?.activation === true ? rule.id : null,
|
|
99
104
|
membership: rule.requires?.membership ?? null,
|
|
@@ -110,7 +115,7 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
|
|
|
110
115
|
function conditionsText(catalog, card, r) {
|
|
111
116
|
const conditions = [];
|
|
112
117
|
if (r.pick) {
|
|
113
|
-
conditions.push(
|
|
118
|
+
conditions.push(`${pickCondition({ pick: card.choices.find((c) => c.id === r.pick?.choice)?.pick ?? 1, ...r.pick }, r.pick.options.map((o) => optionName(catalog, card, r.pick?.choice ?? '', o)).join(' or '), choiceName(r.pick.choice))}.`);
|
|
114
119
|
}
|
|
115
120
|
if (r.activation !== null)
|
|
116
121
|
conditions.push('Needs its bonus activated with the issuer (free).');
|
|
@@ -247,8 +252,27 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
247
252
|
excluded,
|
|
248
253
|
assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid, ifLarger),
|
|
249
254
|
warnings: exploreWarnings(catalog, merchant),
|
|
255
|
+
ifPaying: paidAtTop(ranked, ifPaid).map(({ entry, paid }) => ({
|
|
256
|
+
cardId: entry.cardId,
|
|
257
|
+
cardName: entry.cardName,
|
|
258
|
+
payment: paid.payment,
|
|
259
|
+
earn: paid.earn,
|
|
260
|
+
})),
|
|
250
261
|
};
|
|
251
262
|
}
|
|
263
|
+
// Cards a way of paying would lift to the top (or that are already there).
|
|
264
|
+
function paidAtTop(ranked, ifPaid) {
|
|
265
|
+
const top = ranked[0];
|
|
266
|
+
if (!top)
|
|
267
|
+
return [];
|
|
268
|
+
return ranked.flatMap((entry) => {
|
|
269
|
+
const paid = ifPaid.get(entry.cardId);
|
|
270
|
+
return paid &&
|
|
271
|
+
(entry === top || paid.returnPct > top.effectiveReturnPct + EPSILON)
|
|
272
|
+
? [{ entry, paid }]
|
|
273
|
+
: [];
|
|
274
|
+
});
|
|
275
|
+
}
|
|
252
276
|
function creditPicks(catalog, merchant, ranked, date) {
|
|
253
277
|
const picks = ranked.flatMap((entry) => {
|
|
254
278
|
const card = catalog.cards.find((c) => c.id === entry.cardId);
|
|
@@ -304,12 +328,7 @@ function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid
|
|
|
304
328
|
if (purchase.payment)
|
|
305
329
|
assumptions.push(paidText(purchase.payment));
|
|
306
330
|
const top = ranked[0];
|
|
307
|
-
for (const entry of ranked) {
|
|
308
|
-
const paid = ifPaid.get(entry.cardId);
|
|
309
|
-
if (!paid || !top)
|
|
310
|
-
continue;
|
|
311
|
-
if (entry !== top && paid.returnPct <= top.effectiveReturnPct + EPSILON)
|
|
312
|
-
continue;
|
|
331
|
+
for (const { entry, paid } of paidAtTop(ranked, ifPaid)) {
|
|
313
332
|
assumptions.push(paymentUnknownText(entry.cardName, paid.payment, paid.earn, paid.standard));
|
|
314
333
|
}
|
|
315
334
|
for (const entry of ranked) {
|