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.
Files changed (59) hide show
  1. package/README.md +9 -3
  2. package/data/CHANGELOG.md +19 -1
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-business-gold.yaml +1 -0
  5. package/data/cards/amex-platinum.yaml +1 -1
  6. package/data/cards/chase-freedom-flex.yaml +3 -6
  7. package/data/cards/chase-ihg-one-rewards-premier.yaml +2 -0
  8. package/data/cards/chase-united-business.yaml +3 -0
  9. package/data/cards/chase-united-club-business.yaml +5 -0
  10. package/data/cards/citi-aadvantage-business.yaml +5 -0
  11. package/data/cards/citi-aadvantage-executive.yaml +2 -0
  12. package/data/cards/citi-aadvantage-globe.yaml +2 -0
  13. package/data/cards/citi-aadvantage-mileup.yaml +4 -0
  14. package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
  15. package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
  16. package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
  17. package/data/cards/citi-custom-cash.yaml +8 -10
  18. package/data/cards/citi-double-cash.yaml +1 -0
  19. package/data/cards/citi-my-best-buy-visa.yaml +1 -0
  20. package/data/cards/citi-strata-elite.yaml +2 -0
  21. package/data/cards/citi-strata-premier.yaml +2 -0
  22. package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
  23. package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
  24. package/data/changes.yaml +2840 -0
  25. package/dist/cli/offers.js +21 -5
  26. package/dist/cli/run.js +14 -3
  27. package/dist/cli/wallet.js +28 -3
  28. package/dist/data/change-integrity.js +105 -0
  29. package/dist/data/file-store.js +5 -2
  30. package/dist/data/integrity.js +7 -0
  31. package/dist/data/load.js +5 -0
  32. package/dist/data/store.js +4 -1
  33. package/dist/data/wallet-edit.js +16 -5
  34. package/dist/engine/changes.js +124 -0
  35. package/dist/engine/checklist.js +18 -3
  36. package/dist/engine/explore.js +28 -9
  37. package/dist/engine/offers.js +52 -3
  38. package/dist/engine/periods.js +7 -0
  39. package/dist/engine/renewal.js +101 -0
  40. package/dist/engine/rule-text.js +2 -7
  41. package/dist/engine/setup.js +6 -3
  42. package/dist/engine/simulate.js +14 -7
  43. package/dist/engine/text.js +64 -0
  44. package/dist/engine/wallet-change.js +16 -8
  45. package/dist/engine/wallet-summary.js +4 -0
  46. package/dist/format/card-text.js +9 -2
  47. package/dist/format/offers.js +5 -3
  48. package/dist/format/wallet-yaml.js +3 -0
  49. package/dist/format/wallet.js +6 -1
  50. package/dist/mcp/context.js +11 -3
  51. package/dist/mcp/inputs.js +3 -1
  52. package/dist/mcp/prompts.js +3 -3
  53. package/dist/mcp/schemas.js +24 -0
  54. package/dist/mcp/tools/answers.js +42 -24
  55. package/dist/mcp/tools/wallet.js +28 -8
  56. package/dist/schema/card.js +7 -0
  57. package/dist/schema/changes.js +73 -0
  58. package/dist/schema/wallet.js +5 -1
  59. package/package.json +1 -1
@@ -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.
@@ -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 { ...wallet, valuations: saved.valuations };
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 { 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
+ };
155
166
  }
156
167
  catch {
157
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,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
+ }
@@ -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();
@@ -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) {
@@ -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
  }
@@ -212,18 +212,29 @@ export function updateCardText(text, cardId, fields) {
212
212
  }
213
213
  return out;
214
214
  }
215
- 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) {
216
219
  const root = rootMap(text);
217
- const line = `memberships: ${flowList(memberships)}\n`;
218
- 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);
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
- 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);
223
234
  }
224
235
  const cards = root.items.find((p) => keyOf(p) === 'cards');
225
236
  return cards
226
- ? insertLines(text, lineStart(text, range(cards.key)[0]), line)
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
+ }
@@ -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',
@@ -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, PICK_PERIODS, pointValuesText, } from "./text.js";
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
- ? { choice: choice.id, options: picked, period: choice.period }
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(`Needs ${r.pick.options.map((o) => optionName(catalog, card, r.pick?.choice ?? '', o)).join(' or ')} as its ${choiceName(r.pick.choice)} pick (${r.pick.period === 'until_changed' ? '' : 'picked '}${PICK_PERIODS[r.pick.period]}).`);
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) {