earnrate 0.2.16 → 0.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -10
- package/data/CHANGELOG.md +33 -1
- package/data/VERSION +1 -1
- package/data/cards/amex-business-gold.yaml +1 -0
- package/data/cards/amex-business-platinum.yaml +15 -1
- package/data/cards/amex-platinum.yaml +1 -1
- package/data/cards/barclays-jetblue-plus.yaml +19 -1
- package/data/cards/chase-freedom-flex.yaml +9 -12
- package/data/cards/chase-ihg-one-rewards-business.yaml +19 -3
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +5 -1
- package/data/cards/chase-ihg-one-rewards-premier.yaml +7 -1
- package/data/cards/chase-ihg-one-rewards.yaml +13 -2
- package/data/cards/chase-ink-business-cash.yaml +5 -1
- package/data/cards/chase-ink-business-unlimited.yaml +5 -1
- package/data/cards/chase-prime-visa.yaml +3 -3
- package/data/cards/chase-sapphire-reserve.yaml +12 -3
- package/data/cards/chase-united-business.yaml +8 -1
- package/data/cards/chase-united-club-business.yaml +10 -1
- package/data/cards/chase-united-club.yaml +5 -1
- package/data/cards/chase-united-explorer.yaml +5 -1
- package/data/cards/chase-united-quest.yaml +5 -1
- package/data/cards/citi-aadvantage-business.yaml +5 -0
- package/data/cards/citi-aadvantage-executive.yaml +2 -0
- package/data/cards/citi-aadvantage-globe.yaml +2 -0
- package/data/cards/citi-aadvantage-mileup.yaml +4 -0
- package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
- package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
- package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
- package/data/cards/citi-custom-cash.yaml +8 -10
- package/data/cards/citi-double-cash.yaml +1 -0
- package/data/cards/citi-my-best-buy-visa.yaml +1 -0
- package/data/cards/citi-strata-elite.yaml +2 -0
- package/data/cards/citi-strata-premier.yaml +16 -1
- package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
- package/data/cards/wells-fargo-autograph-journey.yaml +16 -1
- package/data/changes.yaml +2840 -0
- package/data/merchants/overrides.yaml +3 -3
- package/dist/cli/args.js +13 -7
- package/dist/cli/offers.js +21 -5
- package/dist/cli/run.js +18 -3
- package/dist/cli/wallet.js +46 -11
- package/dist/data/change-integrity.js +105 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +20 -0
- package/dist/data/load.js +5 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +21 -5
- package/dist/engine/changes.js +124 -0
- package/dist/engine/checklist.js +42 -7
- package/dist/engine/credits.js +56 -7
- package/dist/engine/evaluate.js +21 -1
- package/dist/engine/explore.js +52 -15
- package/dist/engine/memberships.js +21 -9
- package/dist/engine/milestones.js +11 -3
- package/dist/engine/offers.js +60 -6
- package/dist/engine/periods.js +7 -0
- package/dist/engine/recommend.js +21 -2
- package/dist/engine/renewal.js +101 -0
- package/dist/engine/rule-text.js +21 -13
- package/dist/engine/setup.js +12 -8
- package/dist/engine/simulate.js +57 -17
- package/dist/engine/text.js +110 -5
- package/dist/engine/wallet-change.js +34 -15
- package/dist/engine/wallet-summary.js +29 -10
- package/dist/format/card-text.js +10 -11
- package/dist/format/explore.js +12 -8
- package/dist/format/offers.js +5 -3
- package/dist/format/wallet-yaml.js +3 -0
- package/dist/format/wallet.js +8 -1
- package/dist/mcp/context.js +11 -3
- package/dist/mcp/inputs.js +12 -2
- package/dist/mcp/prompts.js +3 -3
- package/dist/mcp/schemas.js +47 -0
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +42 -24
- package/dist/mcp/tools/wallet.js +33 -9
- package/dist/schema/card.js +18 -0
- package/dist/schema/changes.js +73 -0
- package/dist/schema/wallet.js +11 -3
- package/package.json +1 -1
|
@@ -1005,7 +1005,7 @@
|
|
|
1005
1005
|
category: other
|
|
1006
1006
|
online:
|
|
1007
1007
|
category: online_retail
|
|
1008
|
-
note:
|
|
1008
|
+
note: An auto parts store, with no bonus category in store. Its website counts as online retail.
|
|
1009
1009
|
- id: advance-auto-parts
|
|
1010
1010
|
name: Advance Auto Parts
|
|
1011
1011
|
aliases: [advance auto]
|
|
@@ -1013,7 +1013,7 @@
|
|
|
1013
1013
|
category: other
|
|
1014
1014
|
online:
|
|
1015
1015
|
category: online_retail
|
|
1016
|
-
note:
|
|
1016
|
+
note: An auto parts store, with no bonus category in store. Its website counts as online retail.
|
|
1017
1017
|
- id: napa-auto-parts
|
|
1018
1018
|
name: NAPA Auto Parts
|
|
1019
1019
|
aliases: [napa auto]
|
|
@@ -1021,7 +1021,7 @@
|
|
|
1021
1021
|
category: other
|
|
1022
1022
|
online:
|
|
1023
1023
|
category: online_retail
|
|
1024
|
-
note:
|
|
1024
|
+
note: An auto parts store, with no bonus category in store. Its website counts as online retail.
|
|
1025
1025
|
- id: discount-tire
|
|
1026
1026
|
name: Discount Tire
|
|
1027
1027
|
aliases: [americas tire]
|
package/dist/cli/args.js
CHANGED
|
@@ -96,6 +96,7 @@ export function parseCliArgs(argv) {
|
|
|
96
96
|
cards: { type: 'string' },
|
|
97
97
|
member: { type: 'string', multiple: true },
|
|
98
98
|
enrolled: { type: 'string', multiple: true },
|
|
99
|
+
'skipped-credits': { type: 'string', multiple: true },
|
|
99
100
|
opened: { type: 'string', multiple: true },
|
|
100
101
|
help: { type: 'boolean', short: 'h' },
|
|
101
102
|
},
|
|
@@ -147,6 +148,9 @@ export function parseCliArgs(argv) {
|
|
|
147
148
|
const enrolled = perCard('enrolled', 'amex-platinum=lululemon');
|
|
148
149
|
if (typeof enrolled === 'string')
|
|
149
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 };
|
|
150
154
|
const opened = perCard('opened', 'discover-it-cash-back=2026-03-04');
|
|
151
155
|
if (typeof opened === 'string')
|
|
152
156
|
return { kind: 'error', message: opened };
|
|
@@ -166,14 +170,16 @@ export function parseCliArgs(argv) {
|
|
|
166
170
|
llm: values.llm ?? null,
|
|
167
171
|
cards,
|
|
168
172
|
members: values.member ?? [],
|
|
169
|
-
enrolled:
|
|
170
|
-
|
|
171
|
-
list
|
|
172
|
-
.split(',')
|
|
173
|
-
.map((c) => c.trim())
|
|
174
|
-
.filter(Boolean),
|
|
175
|
-
])),
|
|
173
|
+
enrolled: creditLists(enrolled),
|
|
174
|
+
skipped: creditLists(skipped),
|
|
176
175
|
opened,
|
|
177
176
|
},
|
|
178
177
|
};
|
|
179
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/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.
|
|
@@ -73,6 +74,9 @@ Options:
|
|
|
73
74
|
--enrolled <card>=<credits>
|
|
74
75
|
For a named card, statement credits you've enrolled
|
|
75
76
|
in: --enrolled amex-platinum=lululemon. Repeat per card.
|
|
77
|
+
--skipped-credits <card>=<credits>
|
|
78
|
+
For a named card, statement credits you don't use:
|
|
79
|
+
--skipped-credits amex-gold=dunkin. Repeat per card.
|
|
76
80
|
--opened <card>=<date> For a named card, the day it opened (YYYY-MM-DD), for
|
|
77
81
|
a first-year rate. Repeat per card.
|
|
78
82
|
--member <name> A membership you have, in place of the saved ones.
|
|
@@ -114,6 +118,7 @@ export async function cardsToUse(catalog, file, io, named) {
|
|
|
114
118
|
cards: named.cards.map((id) => ({
|
|
115
119
|
id,
|
|
116
120
|
...(named.enrolled?.[id] ? { enrolled: named.enrolled[id] } : {}),
|
|
121
|
+
...(named.skipped?.[id] ? { skippedCredits: named.skipped[id] } : {}),
|
|
117
122
|
...(named.opened?.[id] ? { opened: named.opened[id] } : {}),
|
|
118
123
|
})),
|
|
119
124
|
memberships: named.members.length > 0 ? named.members : saved.memberships,
|
|
@@ -122,7 +127,13 @@ export async function cardsToUse(catalog, file, io, named) {
|
|
|
122
127
|
io.stderr(`${wallet}\n`);
|
|
123
128
|
return null;
|
|
124
129
|
}
|
|
125
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
...wallet,
|
|
132
|
+
valuations: saved.valuations,
|
|
133
|
+
...(saved.skipped_issuers
|
|
134
|
+
? { skipped_issuers: saved.skipped_issuers }
|
|
135
|
+
: {}),
|
|
136
|
+
};
|
|
126
137
|
}
|
|
127
138
|
try {
|
|
128
139
|
await access(file);
|
|
@@ -146,8 +157,12 @@ export const NO_WALLET_HINT = `These are every card earnrate knows. For the best
|
|
|
146
157
|
// can't be read, has none.
|
|
147
158
|
export async function savedSettings(io) {
|
|
148
159
|
try {
|
|
149
|
-
const { valuations, memberships } = await loadWallet(walletPath(io.env));
|
|
150
|
-
return {
|
|
160
|
+
const { valuations, memberships, skipped_issuers } = await loadWallet(walletPath(io.env));
|
|
161
|
+
return {
|
|
162
|
+
valuations,
|
|
163
|
+
memberships,
|
|
164
|
+
...(skipped_issuers ? { skipped_issuers } : {}),
|
|
165
|
+
};
|
|
151
166
|
}
|
|
152
167
|
catch {
|
|
153
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,18 +14,25 @@ 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.
|
|
20
22
|
--activated <rules> Rules you've activated with the issuer, comma
|
|
21
23
|
separated. Replaces the current list.
|
|
22
24
|
--enrolled <credits> Statement credits you've enrolled in with the
|
|
23
|
-
issuer,
|
|
24
|
-
|
|
25
|
+
issuer, or use if they need no enrolling, comma
|
|
26
|
+
separated, e.g. --enrolled lululemon. They count
|
|
27
|
+
against the annual fee. Replaces the current list.
|
|
25
28
|
--credit-used <credit>=<dollars>
|
|
26
29
|
How much of a credit you've received this period,
|
|
27
30
|
e.g. --credit-used lululemon=40. Repeat for each.
|
|
31
|
+
--skipped-credits <credits>
|
|
32
|
+
Statement credits you don't use, comma separated,
|
|
33
|
+
e.g. --skipped-credits dunkin. They're left out of
|
|
34
|
+
picks, the to-do list and annual-fee math.
|
|
35
|
+
Replaces the current list.
|
|
28
36
|
--cap-spent <cap>=<dollars>
|
|
29
37
|
Spending toward a cap so far this period, e.g.
|
|
30
38
|
--cap-spent us-supermarkets=1200. Repeat for each.
|
|
@@ -75,6 +83,13 @@ and point values stay.
|
|
|
75
83
|
|
|
76
84
|
With no arguments, shows your memberships and the ones earnrate knows. With
|
|
77
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.
|
|
78
93
|
`,
|
|
79
94
|
values: `Usage: ${CLI_NAME} values [<currency>=<cents>...] [--profile <name>]
|
|
80
95
|
|
|
@@ -150,6 +165,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
150
165
|
opened: { type: 'string' },
|
|
151
166
|
activated: { type: 'string' },
|
|
152
167
|
enrolled: { type: 'string' },
|
|
168
|
+
'skipped-credits': { type: 'string' },
|
|
153
169
|
'credit-used': { type: 'string', multiple: true },
|
|
154
170
|
'cap-spent': { type: 'string', multiple: true },
|
|
155
171
|
'milestone-spent': { type: 'string', multiple: true },
|
|
@@ -183,6 +199,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
183
199
|
values.opened !== undefined ||
|
|
184
200
|
values.activated !== undefined ||
|
|
185
201
|
values.enrolled !== undefined ||
|
|
202
|
+
values['skipped-credits'] !== undefined ||
|
|
186
203
|
values['credit-used'] !== undefined ||
|
|
187
204
|
values['cap-spent'] !== undefined ||
|
|
188
205
|
values['milestone-spent'] !== undefined ||
|
|
@@ -194,8 +211,8 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
194
211
|
if (command !== 'add' && command !== 'update' && cardOptionsGiven) {
|
|
195
212
|
return usage(`${CLI_NAME} ${command} doesn't take card options.`);
|
|
196
213
|
}
|
|
197
|
-
if (command !== 'memberships' && values.none) {
|
|
198
|
-
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.');
|
|
199
216
|
}
|
|
200
217
|
const catalog = await loadCatalog();
|
|
201
218
|
const file = walletPath(io.env);
|
|
@@ -241,6 +258,22 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
241
258
|
.map(([id, c]) => `${id} ${c === null ? 'back to the default' : `${String(c)}¢`}`)
|
|
242
259
|
.join(', ')}.`, null, io);
|
|
243
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
|
+
}
|
|
244
277
|
if (command === 'memberships') {
|
|
245
278
|
if (positionals.length === 0 && !values.none) {
|
|
246
279
|
const held = await heldWallet(io);
|
|
@@ -288,12 +321,14 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
288
321
|
const n = Number(text.replace(/[$,]/g, ''));
|
|
289
322
|
return Number.isFinite(n) ? n : `--${flag} takes dollars, like 1200.`;
|
|
290
323
|
};
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
324
|
+
const idList = (text) => text
|
|
325
|
+
.split(',')
|
|
326
|
+
.map((r) => r.trim())
|
|
327
|
+
.filter((r) => r !== '');
|
|
328
|
+
if (values.enrolled !== undefined)
|
|
329
|
+
input.enrolled = idList(values.enrolled);
|
|
330
|
+
if (values['skipped-credits'] !== undefined)
|
|
331
|
+
input.skippedCredits = idList(values['skipped-credits']);
|
|
297
332
|
// --credit-used, --cap-spent and --milestone-spent: <id>=<dollars>, repeated.
|
|
298
333
|
const dollarPairs = (flag, example) => {
|
|
299
334
|
const pairs = values[flag];
|
|
@@ -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();
|
|
@@ -160,6 +161,19 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
160
161
|
...card.intro_rates.map((i) => i.id),
|
|
161
162
|
...card.credits.map((c) => c.id),
|
|
162
163
|
]);
|
|
164
|
+
// An end or start date has to come from the issuer, so a quote names its
|
|
165
|
+
// year ("through 12/31/2027", "Starting in 2027").
|
|
166
|
+
for (const [id, date] of [
|
|
167
|
+
...card.credits.map((c) => [c.id, c.valid_to]),
|
|
168
|
+
...card.milestones.map((m) => [m.id, m.valid_from]),
|
|
169
|
+
]) {
|
|
170
|
+
if (date === undefined)
|
|
171
|
+
continue;
|
|
172
|
+
const year = date.slice(0, 4);
|
|
173
|
+
const named = new RegExp(`(^|\\D)(${year}|\\d{1,2}/\\d{1,2}/${year.slice(2)})(\\D|$)`);
|
|
174
|
+
if (!card.evidence.some((e) => e.rule === id && named.test(e.quote)))
|
|
175
|
+
problems.push(`${where} ${id}: no evidence quote names its date's year`);
|
|
176
|
+
}
|
|
163
177
|
const sourcePages = new Set(card.source_urls.map((u) => u.split('#')[0]));
|
|
164
178
|
const backed = new Set(card.evidence.map((e) => e.rule));
|
|
165
179
|
for (const e of card.evidence) {
|
|
@@ -292,6 +306,10 @@ export function walletProblems(wallet, catalog) {
|
|
|
292
306
|
for (const id of duplicates(wallet.cards.map((c) => c.id))) {
|
|
293
307
|
problems.push(`wallet: card '${id}' is listed twice`);
|
|
294
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}'`);
|
|
295
313
|
for (const [id, valuation] of Object.entries(wallet.valuations)) {
|
|
296
314
|
const currency = currencies.get(id);
|
|
297
315
|
if (!currency) {
|
|
@@ -306,6 +324,8 @@ export function walletProblems(wallet, catalog) {
|
|
|
306
324
|
}
|
|
307
325
|
for (const held of wallet.cards) {
|
|
308
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`);
|
|
309
329
|
const card = cards.get(held.id);
|
|
310
330
|
if (!card) {
|
|
311
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
|
@@ -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) {
|
|
@@ -207,18 +212,29 @@ export function updateCardText(text, cardId, fields) {
|
|
|
207
212
|
}
|
|
208
213
|
return out;
|
|
209
214
|
}
|
|
210
|
-
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) {
|
|
211
219
|
const root = rootMap(text);
|
|
212
|
-
const line =
|
|
213
|
-
|
|
220
|
+
const line = list.length === 0 && key !== 'memberships'
|
|
221
|
+
? ''
|
|
222
|
+
: `${key}: ${flowList(list)}\n`;
|
|
223
|
+
const pair = root.items.find((p) => keyOf(p) === key);
|
|
214
224
|
if (pair) {
|
|
215
225
|
const start = range(pair.key)[0];
|
|
216
226
|
const end = afterLine(text, (pair.value === null ? range(pair.key) : range(pair.value))[1] - 1);
|
|
217
|
-
|
|
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);
|
|
218
234
|
}
|
|
219
235
|
const cards = root.items.find((p) => keyOf(p) === 'cards');
|
|
220
236
|
return cards
|
|
221
|
-
? insertLines(text,
|
|
237
|
+
? insertLines(text, aboveComments(text, range(cards.key)[0]), line)
|
|
222
238
|
: insertLines(text, text.length, line);
|
|
223
239
|
}
|
|
224
240
|
// Point values, one currency at a time so the rest of the file, comments
|