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.
Files changed (81) hide show
  1. package/README.md +22 -10
  2. package/data/CHANGELOG.md +33 -1
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-business-gold.yaml +1 -0
  5. package/data/cards/amex-business-platinum.yaml +15 -1
  6. package/data/cards/amex-platinum.yaml +1 -1
  7. package/data/cards/barclays-jetblue-plus.yaml +19 -1
  8. package/data/cards/chase-freedom-flex.yaml +9 -12
  9. package/data/cards/chase-ihg-one-rewards-business.yaml +19 -3
  10. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +5 -1
  11. package/data/cards/chase-ihg-one-rewards-premier.yaml +7 -1
  12. package/data/cards/chase-ihg-one-rewards.yaml +13 -2
  13. package/data/cards/chase-ink-business-cash.yaml +5 -1
  14. package/data/cards/chase-ink-business-unlimited.yaml +5 -1
  15. package/data/cards/chase-prime-visa.yaml +3 -3
  16. package/data/cards/chase-sapphire-reserve.yaml +12 -3
  17. package/data/cards/chase-united-business.yaml +8 -1
  18. package/data/cards/chase-united-club-business.yaml +10 -1
  19. package/data/cards/chase-united-club.yaml +5 -1
  20. package/data/cards/chase-united-explorer.yaml +5 -1
  21. package/data/cards/chase-united-quest.yaml +5 -1
  22. package/data/cards/citi-aadvantage-business.yaml +5 -0
  23. package/data/cards/citi-aadvantage-executive.yaml +2 -0
  24. package/data/cards/citi-aadvantage-globe.yaml +2 -0
  25. package/data/cards/citi-aadvantage-mileup.yaml +4 -0
  26. package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
  27. package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
  28. package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
  29. package/data/cards/citi-custom-cash.yaml +8 -10
  30. package/data/cards/citi-double-cash.yaml +1 -0
  31. package/data/cards/citi-my-best-buy-visa.yaml +1 -0
  32. package/data/cards/citi-strata-elite.yaml +2 -0
  33. package/data/cards/citi-strata-premier.yaml +16 -1
  34. package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
  35. package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
  36. package/data/cards/wells-fargo-autograph-journey.yaml +16 -1
  37. package/data/changes.yaml +2840 -0
  38. package/data/merchants/overrides.yaml +3 -3
  39. package/dist/cli/args.js +13 -7
  40. package/dist/cli/offers.js +21 -5
  41. package/dist/cli/run.js +18 -3
  42. package/dist/cli/wallet.js +46 -11
  43. package/dist/data/change-integrity.js +105 -0
  44. package/dist/data/file-store.js +5 -2
  45. package/dist/data/integrity.js +20 -0
  46. package/dist/data/load.js +5 -0
  47. package/dist/data/store.js +4 -1
  48. package/dist/data/wallet-edit.js +21 -5
  49. package/dist/engine/changes.js +124 -0
  50. package/dist/engine/checklist.js +42 -7
  51. package/dist/engine/credits.js +56 -7
  52. package/dist/engine/evaluate.js +21 -1
  53. package/dist/engine/explore.js +52 -15
  54. package/dist/engine/memberships.js +21 -9
  55. package/dist/engine/milestones.js +11 -3
  56. package/dist/engine/offers.js +60 -6
  57. package/dist/engine/periods.js +7 -0
  58. package/dist/engine/recommend.js +21 -2
  59. package/dist/engine/renewal.js +101 -0
  60. package/dist/engine/rule-text.js +21 -13
  61. package/dist/engine/setup.js +12 -8
  62. package/dist/engine/simulate.js +57 -17
  63. package/dist/engine/text.js +110 -5
  64. package/dist/engine/wallet-change.js +34 -15
  65. package/dist/engine/wallet-summary.js +29 -10
  66. package/dist/format/card-text.js +10 -11
  67. package/dist/format/explore.js +12 -8
  68. package/dist/format/offers.js +5 -3
  69. package/dist/format/wallet-yaml.js +3 -0
  70. package/dist/format/wallet.js +8 -1
  71. package/dist/mcp/context.js +11 -3
  72. package/dist/mcp/inputs.js +12 -2
  73. package/dist/mcp/prompts.js +3 -3
  74. package/dist/mcp/schemas.js +47 -0
  75. package/dist/mcp/tools/account.js +1 -1
  76. package/dist/mcp/tools/answers.js +42 -24
  77. package/dist/mcp/tools/wallet.js +33 -9
  78. package/dist/schema/card.js +18 -0
  79. package/dist/schema/changes.js +73 -0
  80. package/dist/schema/wallet.js +11 -3
  81. package/package.json +1 -1
@@ -1005,7 +1005,7 @@
1005
1005
  category: other
1006
1006
  online:
1007
1007
  category: online_retail
1008
- note: A auto parts store, with no bonus category in store. Its website counts as online retail.
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: A auto parts store, with no bonus category in store. Its website counts as online retail.
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: A auto parts store, with no bonus category in store. Its website counts as online retail.
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: Object.fromEntries(Object.entries(enrolled).map(([id, list]) => [
170
- id,
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
+ ]));
@@ -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
- Ranks the welcome offers on cards you don't have by first-year value: the
13
- bonus at your point values minus the first year's annual fee and any
14
- membership the card needs that you don't have.
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(loaded, values.valuation)
83
- : loaded;
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 { ...wallet, valuations: saved.valuations };
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 { valuations, memberships };
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: [] };
@@ -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, comma separated, e.g. --enrolled lululemon.
24
- Replaces the current list.
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
- if (values.enrolled !== undefined) {
292
- input.enrolled = values.enrolled
293
- .split(',')
294
- .map((r) => r.trim())
295
- .filter((r) => r !== '');
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
+ }
@@ -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
  }
@@ -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) {
@@ -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
  }
@@ -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 function setMembershipsText(text, memberships) {
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 = `memberships: ${flowList(memberships)}\n`;
213
- const pair = root.items.find((p) => keyOf(p) === 'memberships');
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
- return text.slice(0, start) + line + text.slice(end);
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, lineStart(text, range(cards.key)[0]), line)
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