earnrate 0.2.4 → 0.2.6

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 (116) hide show
  1. package/README.md +18 -15
  2. package/data/CHANGELOG.md +79 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-business-plus.yaml +14 -6
  5. package/data/cards/amex-blue-cash-everyday.yaml +12 -3
  6. package/data/cards/amex-blue-cash-preferred.yaml +17 -3
  7. package/data/cards/amex-gold.yaml +23 -3
  8. package/data/cards/amex-platinum.yaml +19 -3
  9. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +15 -3
  10. package/data/cards/bank-of-america-atmos-rewards-business.yaml +19 -4
  11. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +9 -2
  12. package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -4
  13. package/data/cards/bilt-obsidian.yaml +4 -3
  14. package/data/cards/capital-one-kohls-card.yaml +11 -2
  15. package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
  16. package/data/cards/capital-one-savor.yaml +32 -10
  17. package/data/cards/capital-one-venture-x.yaml +19 -4
  18. package/data/cards/capital-one-venture.yaml +19 -5
  19. package/data/cards/chase-aeroplan.yaml +15 -3
  20. package/data/cards/chase-freedom-flex.yaml +50 -10
  21. package/data/cards/chase-freedom-unlimited.yaml +6 -2
  22. package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
  23. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
  24. package/data/cards/chase-ihg-one-rewards-premier.yaml +19 -2
  25. package/data/cards/chase-ihg-one-rewards.yaml +15 -2
  26. package/data/cards/chase-ink-business-cash.yaml +7 -3
  27. package/data/cards/chase-ink-business-preferred.yaml +9 -2
  28. package/data/cards/chase-prime-visa.yaml +24 -5
  29. package/data/cards/chase-sapphire-preferred.yaml +59 -6
  30. package/data/cards/chase-sapphire-reserve.yaml +12 -2
  31. package/data/cards/chase-southwest-performance-business.yaml +22 -4
  32. package/data/cards/chase-southwest-plus.yaml +13 -3
  33. package/data/cards/chase-southwest-premier-business.yaml +22 -5
  34. package/data/cards/chase-southwest-premier.yaml +13 -3
  35. package/data/cards/chase-southwest-priority.yaml +18 -2
  36. package/data/cards/chase-united-business.yaml +5 -2
  37. package/data/cards/chase-united-club-business.yaml +2 -2
  38. package/data/cards/chase-united-club.yaml +17 -4
  39. package/data/cards/chase-united-explorer.yaml +5 -2
  40. package/data/cards/chase-united-gateway.yaml +2 -2
  41. package/data/cards/chase-united-quest.yaml +12 -3
  42. package/data/cards/citi-aadvantage-business.yaml +10 -4
  43. package/data/cards/citi-aadvantage-executive.yaml +8 -8
  44. package/data/cards/citi-aadvantage-mileup.yaml +7 -4
  45. package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
  46. package/data/cards/citi-costco-anywhere-visa-business.yaml +6 -9
  47. package/data/cards/citi-costco-anywhere-visa.yaml +7 -10
  48. package/data/cards/citi-custom-cash.yaml +43 -12
  49. package/data/cards/citi-double-cash.yaml +9 -3
  50. package/data/cards/citi-my-best-buy-visa.yaml +6 -3
  51. package/data/cards/citi-strata-elite.yaml +12 -2
  52. package/data/cards/citi-strata-premier.yaml +24 -2
  53. package/data/cards/sofi-unlimited-2.yaml +17 -3
  54. package/data/cards/td-bank-nordstrom-credit-card.yaml +16 -3
  55. package/data/cards/td-bank-nordstrom-visa.yaml +23 -3
  56. package/data/cards/td-bank-target-circle-card.yaml +13 -4
  57. package/data/cards/usbank-cash-plus.yaml +29 -7
  58. package/data/cards/wells-fargo-active-cash.yaml +9 -2
  59. package/data/cards/wells-fargo-autograph.yaml +31 -7
  60. package/data/categories.yaml +12 -0
  61. package/data/currencies.yaml +2 -0
  62. package/data/issuers.yaml +32 -0
  63. package/data/memberships.yaml +33 -0
  64. package/data/merchants/overrides.yaml +25 -0
  65. package/data/merchants/seed.yaml +7 -0
  66. package/dist/cli/bin.js +10 -1
  67. package/dist/cli/cheatsheet.js +11 -6
  68. package/dist/cli/explore.js +17 -23
  69. package/dist/cli/import.js +10 -10
  70. package/dist/cli/offers.js +19 -8
  71. package/dist/cli/run.js +31 -20
  72. package/dist/cli/wallet.js +50 -5
  73. package/dist/data/file-store.js +9 -7
  74. package/dist/data/integrity.js +22 -2
  75. package/dist/data/load.js +13 -2
  76. package/dist/data/store.js +13 -22
  77. package/dist/data/wallet-edit.js +108 -8
  78. package/dist/engine/cheat-sheet.js +15 -19
  79. package/dist/engine/compare.js +10 -0
  80. package/dist/engine/evaluate.js +2 -4
  81. package/dist/engine/explore.js +13 -56
  82. package/dist/engine/memberships.js +41 -15
  83. package/dist/engine/offers.js +18 -5
  84. package/dist/engine/periods.js +18 -0
  85. package/dist/engine/profiles.js +8 -0
  86. package/dist/engine/recommend.js +23 -37
  87. package/dist/engine/setup.js +28 -40
  88. package/dist/engine/simulate.js +11 -10
  89. package/dist/engine/text.js +49 -23
  90. package/dist/engine/valuation.js +1 -1
  91. package/dist/engine/wallet-change.js +26 -11
  92. package/dist/engine/wallet-summary.js +85 -31
  93. package/dist/format/card-text.js +19 -36
  94. package/dist/format/cards.js +3 -3
  95. package/dist/format/explore.js +15 -16
  96. package/dist/format/offers.js +10 -15
  97. package/dist/format/recommendation.js +6 -6
  98. package/dist/format/wallet.js +5 -2
  99. package/dist/import/analyze.js +2 -1
  100. package/dist/mcp/context.js +61 -13
  101. package/dist/mcp/inputs.js +16 -7
  102. package/dist/mcp/schemas.js +9 -0
  103. package/dist/mcp/server.js +8 -1
  104. package/dist/mcp/tools/account.js +45 -17
  105. package/dist/mcp/tools/answers.js +84 -29
  106. package/dist/mcp/tools/wallet.js +109 -22
  107. package/dist/merchants/cache.js +0 -10
  108. package/dist/merchants/resolve.js +81 -4
  109. package/dist/schema/card.js +8 -12
  110. package/dist/schema/category.js +4 -0
  111. package/dist/schema/currency.js +2 -0
  112. package/dist/schema/issuer.js +8 -0
  113. package/dist/schema/membership.js +15 -0
  114. package/dist/schema/merchant.js +4 -0
  115. package/dist/schema/wallet.js +1 -1
  116. package/package.json +1 -1
@@ -2,6 +2,8 @@ import { parseArgs } from 'node:util';
2
2
  import { CLI_NAME } from "../brand.js";
3
3
  import { FileWalletStore } from "../data/file-store.js";
4
4
  import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
5
+ import { isValuationProfile, profileText, VALUATION_PROFILES, } from "../engine/profiles.js";
6
+ import { membershipName } from "../engine/memberships.js";
5
7
  import { knownMemberships, searchCards } from "../engine/setup.js";
6
8
  import { formatCardInfo, formatNotes } from "../format/cards.js";
7
9
  import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
@@ -40,19 +42,35 @@ ${CARD_OPTIONS}
40
42
  `,
41
43
  update: `Usage: ${CLI_NAME} update <card-id> [options]
42
44
 
43
- Changes a card in your wallet: picks for this period, label, anniversary or
44
- activations. Picks for the current period are merged with ones already set.
45
+ Changes a card in your wallet: picks for this period, label, anniversary,
46
+ activations or welcome bonus. Picks for the current period are merged with
47
+ ones already set.
45
48
 
46
49
  ${CARD_OPTIONS}
47
50
  `,
48
51
  remove: `Usage: ${CLI_NAME} remove <card-id>
49
52
 
50
- Removes a card from your wallet, with its tracked cap usage.
53
+ Removes a card from your wallet, with its tracked cap usage and bonus
54
+ progress. Your memberships and point values stay.
51
55
  `,
52
56
  memberships: `Usage: ${CLI_NAME} memberships [<membership>...] [--none]
53
57
 
54
58
  With no arguments, shows your memberships and the ones earnrate knows. With
55
- names, replaces the list. --none clears it.
59
+ names, replaces the list. --none clears it. No cards need to be saved first.
60
+ `,
61
+ values: `Usage: ${CLI_NAME} values [<currency>=<cents>...] [--profile <name>]
62
+
63
+ What your points are worth to you, in cents per point, saved in your wallet
64
+ file; every answer then uses them unless you pass --valuation. With no
65
+ arguments, shows them.
66
+
67
+ Examples:
68
+ ${CLI_NAME} values UR=1.5 MR=1.6
69
+ ${CLI_NAME} values UR=default back to earnrate's value
70
+ ${CLI_NAME} values --profile points-heavy
71
+
72
+ Options:
73
+ --profile <name> flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}).
56
74
  `,
57
75
  };
58
76
  const HINTS = {
@@ -118,6 +136,7 @@ export async function runWalletCommand(command, argv, io) {
118
136
  'bonus-by': { type: 'string' },
119
137
  'no-bonus': { type: 'boolean' },
120
138
  none: { type: 'boolean' },
139
+ profile: { type: 'string' },
121
140
  help: { type: 'boolean', short: 'h' },
122
141
  },
123
142
  });
@@ -160,6 +179,32 @@ export async function runWalletCommand(command, argv, io) {
160
179
  io.stdout(`${header}${result.cards.map(formatCardInfo).join('\n')}\n`);
161
180
  return 0;
162
181
  }
182
+ if (command === 'values') {
183
+ const changes = {};
184
+ if (values.profile !== undefined) {
185
+ if (!isValuationProfile(values.profile))
186
+ return usage(`Unknown profile "${values.profile}". Use flat or points-heavy.`);
187
+ Object.assign(changes, VALUATION_PROFILES[values.profile]);
188
+ }
189
+ for (const arg of positionals) {
190
+ const [id, value] = arg.split('=');
191
+ const cents = value === 'default' ? null : Number(value);
192
+ if (!id ||
193
+ value === undefined ||
194
+ (cents !== null && !Number.isFinite(cents)))
195
+ return usage(`"${arg}" isn't <currency>=<cents>, like UR=1.5.`);
196
+ changes[id] = cents;
197
+ }
198
+ if (Object.keys(changes).length === 0) {
199
+ const held = await heldWallet(io);
200
+ const set = Object.entries(held?.valuations ?? {});
201
+ io.stdout(`${set.length > 0 ? `Yours: ${set.map(([id, v]) => `${id}=${typeof v === 'number' ? String(v) : JSON.stringify(v)}`).join(' ')}` : "Yours: earnrate's defaults"}\nCurrencies: ${catalog.currencies.map((c) => c.id).join(', ')}\n`);
202
+ return 0;
203
+ }
204
+ return report(await store.setValuations(changes), `Point values updated in ${where}: ${Object.entries(changes)
205
+ .map(([id, c]) => `${id} ${c === null ? 'back to the default' : `${String(c)}¢`}`)
206
+ .join(', ')}.`, null, io);
207
+ }
163
208
  if (command === 'memberships') {
164
209
  if (positionals.length === 0 && !values.none) {
165
210
  const held = await heldWallet(io);
@@ -171,7 +216,7 @@ export async function runWalletCommand(command, argv, io) {
171
216
  }
172
217
  const list = values.none ? [] : positionals;
173
218
  return report(await store.setMemberships(list), list.length > 0
174
- ? `Memberships set to ${list.join(', ')} in ${where}.`
219
+ ? `Memberships set to ${list.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')} in ${where}.`
175
220
  : `Memberships cleared in ${where}.`, null, io);
176
221
  }
177
222
  if (positionals.length !== 1) {
@@ -1,7 +1,8 @@
1
1
  import { bookPurchase } from "../engine/record.js";
2
- import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
2
+ import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
3
3
  import { loadWallet } from "./load.js";
4
- import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, updateCardText, writeProgress, } from "./wallet-edit.js";
4
+ import { emptyWallet, } from "./store.js";
5
+ import { addCardText, commitWalletText, EMPTY_WALLET_TEXT, readWalletText, removeCardText, setMembershipsText, setValuationsText, updateCardText, writeProgress, } from "./wallet-edit.js";
5
6
  const failed = (error) => ({ ok: false, error });
6
7
  const errorText = (e) => (e instanceof Error ? e.message : String(e));
7
8
  const queues = new Map();
@@ -56,7 +57,7 @@ export class FileWalletStore {
56
57
  }
57
58
  });
58
59
  }
59
- // Changes other than adding a card need a wallet file to change.
60
+ // Changing or removing a card needs a wallet file to change.
60
61
  existing(apply) {
61
62
  return (wallet) => wallet === null
62
63
  ? `No wallet at ${this.where} yet. Add a card first to create it.`
@@ -69,12 +70,13 @@ export class FileWalletStore {
69
70
  return this.change(this.existing((w) => updateInWallet(this.catalog, w, input, today)), (text, change) => updateCardText(text ?? '', input.id, change.fields));
70
71
  }
71
72
  removeCard(cardId) {
72
- return this.change(this.existing((w) => w.cards.length === 1 && w.cards[0]?.id === cardId
73
- ? failed(`${cardId} is the last card in ${this.where}. To start over, delete that file; to swap cards, add the new one first.`)
74
- : removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
73
+ return this.change(this.existing((w) => removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
75
74
  }
76
75
  setMemberships(memberships) {
77
- return this.change(this.existing((w) => withMemberships(this.catalog, w, memberships)), (text, change) => setMembershipsText(text ?? '', change.wallet.memberships));
76
+ return this.change((w) => withMemberships(this.catalog, w ?? emptyWallet(), memberships), (text, change) => setMembershipsText(text ?? EMPTY_WALLET_TEXT, change.wallet.memberships));
77
+ }
78
+ setValuations(changes) {
79
+ return this.change((w) => withValuations(this.catalog, w ?? emptyWallet(), changes), (text) => setValuationsText(text ?? EMPTY_WALLET_TEXT, changes));
78
80
  }
79
81
  record(purchase, merchant) {
80
82
  return withWalletLock(this.where, async () => {
@@ -60,8 +60,19 @@ function cardProblems(card, catalog, merchantIds) {
60
60
  for (const id of card.welcome_offer?.not_with ?? [])
61
61
  if (!cardIds.has(id))
62
62
  problems.push(`${where} welcome_offer.not_with: unknown card '${id}'`);
63
- for (const m of card.requires_membership?.members_shop_at ?? [])
64
- checkMerchant(m, 'requires_membership');
63
+ const memberships = new Set(catalog.memberships.map((m) => m.id));
64
+ for (const id of [
65
+ ...(card.requires_membership === undefined
66
+ ? []
67
+ : [card.requires_membership]),
68
+ ...card.rules.flatMap((r) => r.requires?.membership ?? []),
69
+ ])
70
+ if (!memberships.has(id))
71
+ problems.push(`${where}: unknown membership '${id}' (data/memberships.yaml)`);
72
+ if (card.requires_membership !== undefined &&
73
+ catalog.memberships.find((m) => m.id === card.requires_membership)
74
+ ?.annual_fee === undefined)
75
+ problems.push(`${where}: requires_membership '${card.requires_membership}' has no annual_fee`);
65
76
  if (card.pools_into) {
66
77
  if (!currencies.has(card.pools_into.currency)) {
67
78
  problems.push(`${where} pools_into: unknown currency '${card.pools_into.currency}'`);
@@ -167,6 +178,8 @@ export function catalogProblems(catalog) {
167
178
  ['category', catalog.categories.map((c) => c.id)],
168
179
  ['currency', catalog.currencies.map((c) => c.id)],
169
180
  ['card', catalog.cards.map((c) => c.id)],
181
+ ['membership', catalog.memberships.map((m) => m.id)],
182
+ ['issuer', catalog.issuers.map((i) => i.id)],
170
183
  ]) {
171
184
  for (const id of duplicates(ids))
172
185
  problems.push(`duplicate ${kind} id '${id}'`);
@@ -187,8 +200,15 @@ export function catalogProblems(catalog) {
187
200
  problems.push(`merchant ${m.id}: booked_via '${via}' is not a merchant`);
188
201
  }
189
202
  }
203
+ for (const m of catalog.memberships)
204
+ for (const id of m.members_shop_at)
205
+ if (!merchantIds.has(id))
206
+ problems.push(`membership ${m.id}: members_shop_at '${id}' is not a merchant`);
207
+ const issuers = new Set(catalog.issuers.map((i) => i.id));
190
208
  for (const card of catalog.cards) {
191
209
  problems.push(...cardProblems(card, catalog, merchantIds));
210
+ if (!issuers.has(card.issuer))
211
+ problems.push(`${card.id}: unknown issuer '${card.issuer}' (data/issuers.yaml)`);
192
212
  }
193
213
  return problems;
194
214
  }
package/dist/data/load.js CHANGED
@@ -8,6 +8,8 @@ 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
10
  import { CurrenciesFileSchema } from "../schema/currency.js";
11
+ import { IssuersFileSchema } from "../schema/issuer.js";
12
+ import { MembershipsFileSchema } from "../schema/membership.js";
11
13
  import { MerchantsFileSchema } from "../schema/merchant.js";
12
14
  import { WalletSchema } from "../schema/wallet.js";
13
15
  export const DATA_DIR = fileURLToPath(new URL('../../data/', import.meta.url));
@@ -30,14 +32,23 @@ async function loadCards(dir) {
30
32
  }));
31
33
  }
32
34
  export async function loadCatalog(dir = DATA_DIR) {
33
- const [categories, currencies, cards, seed, overrides] = await Promise.all([
35
+ const [categories, currencies, issuers, memberships, cards, seed, overrides] = await Promise.all([
34
36
  readYaml(path.join(dir, 'categories.yaml'), CategoriesFileSchema),
35
37
  readYaml(path.join(dir, 'currencies.yaml'), CurrenciesFileSchema),
38
+ readYaml(path.join(dir, 'issuers.yaml'), IssuersFileSchema),
39
+ readYaml(path.join(dir, 'memberships.yaml'), MembershipsFileSchema),
36
40
  loadCards(path.join(dir, 'cards')),
37
41
  readYaml(path.join(dir, 'merchants', 'seed.yaml'), MerchantsFileSchema),
38
42
  readYaml(path.join(dir, 'merchants', 'overrides.yaml'), MerchantsFileSchema),
39
43
  ]);
40
- return { categories, currencies, cards, merchants: { seed, overrides } };
44
+ return {
45
+ categories,
46
+ currencies,
47
+ issuers,
48
+ memberships,
49
+ cards,
50
+ merchants: { seed, overrides },
51
+ };
41
52
  }
42
53
  // MCP clients pass config values through without a shell, so "~" or
43
54
  // "${HOME}" can arrive as literal text; expand them here.
@@ -1,7 +1,9 @@
1
1
  import { bookPurchase } from "../engine/record.js";
2
- import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
3
- import { WalletSchema } from "../schema/wallet.js";
2
+ import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
3
+ import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
4
4
  const failed = (error) => ({ ok: false, error });
5
+ // Memberships and point values can be saved before any card.
6
+ export const emptyWallet = () => WalletSchema.parse({ version: WALLET_VERSION });
5
7
  // A wallet stored as JSON text. Writes are serialized within this instance;
6
8
  // a backend shared across processes must serialize them itself (one Durable
7
9
  // Object per wallet does).
@@ -30,11 +32,7 @@ export class JsonWalletStore {
30
32
  const change = apply(before);
31
33
  if (!change.ok)
32
34
  return failed(change.error);
33
- // An empty wallet is stored as none: the schema needs a card.
34
- if (change.wallet.cards.length === 0)
35
- await this.backend.remove();
36
- else
37
- await this.backend.write(JSON.stringify(change.wallet));
35
+ await this.backend.write(JSON.stringify(change.wallet));
38
36
  return {
39
37
  ok: true,
40
38
  wallet: change.wallet,
@@ -58,7 +56,10 @@ export class JsonWalletStore {
58
56
  return this.change(this.needsWallet((w) => removeFromWallet(w, cardId)));
59
57
  }
60
58
  setMemberships(memberships) {
61
- return this.change(this.needsWallet((w) => withMemberships(this.catalog, w, memberships)));
59
+ return this.change((w) => withMemberships(this.catalog, w ?? emptyWallet(), memberships));
60
+ }
61
+ setValuations(changes) {
62
+ return this.change((w) => withValuations(this.catalog, w ?? emptyWallet(), changes));
62
63
  }
63
64
  replace(wallet) {
64
65
  return this.change(() => ({ ok: true, wallet, fields: {}, notes: [] }));
@@ -70,6 +71,10 @@ export class JsonWalletStore {
70
71
  disconnect() {
71
72
  return Promise.resolve();
72
73
  }
74
+ // The backend doesn't record when it began.
75
+ started() {
76
+ return Promise.resolve(null);
77
+ }
73
78
  record(purchase, merchant) {
74
79
  return this.serialized(async () => {
75
80
  const wallet = await this.load();
@@ -84,17 +89,3 @@ export class JsonWalletStore {
84
89
  });
85
90
  }
86
91
  }
87
- export function memoryBackend(initial = null) {
88
- let text = initial;
89
- return {
90
- read: () => Promise.resolve(text),
91
- write: (next) => {
92
- text = next;
93
- return Promise.resolve();
94
- },
95
- remove: () => {
96
- text = null;
97
- return Promise.resolve();
98
- },
99
- };
100
- }
@@ -93,6 +93,17 @@ function cardsSeq(text) {
93
93
  }
94
94
  return { doc, cards };
95
95
  }
96
+ function rootMap(text) {
97
+ const doc = parseDocument(text);
98
+ if (doc.errors.length > 0)
99
+ throw new Error(doc.errors[0]?.message);
100
+ const root = doc.contents;
101
+ if (!isMap(root))
102
+ throw new Error(`the wallet isn't a map; ${BY_HAND}`);
103
+ return root;
104
+ }
105
+ // A wallet with no cards yet, for memberships or point values saved first.
106
+ export const EMPTY_WALLET_TEXT = `${NEW_WALLET}version: ${String(WALLET_VERSION)}\ncards: []\n`;
96
107
  function findCard(text, cardId) {
97
108
  const { cards } = cardsSeq(text);
98
109
  const card = cards.items.find((item) => isMap(item) && item.get('id') === cardId);
@@ -108,6 +119,17 @@ export function addCardText(text, entry) {
108
119
  if (text === null || text.trim() === '') {
109
120
  return `${NEW_WALLET}version: ${String(WALLET_VERSION)}\ncards:\n${cardLines(entry, ' ')}`;
110
121
  }
122
+ const pair = rootMap(text).items.find((p) => keyOf(p) === 'cards');
123
+ const value = pair?.value;
124
+ const none = value === null || (isSeq(value) && value.items.length === 0);
125
+ if (!pair || none) {
126
+ const block = `cards:\n${cardLines(entry, ' ')}`;
127
+ if (!pair)
128
+ return insertLines(text, text.length, block);
129
+ const start = lineStart(text, range(pair.key)[0]);
130
+ const end = afterLine(text, range(value ?? pair.key)[1] - 1);
131
+ return text.slice(0, start) + block + text.slice(end);
132
+ }
111
133
  const { cards } = cardsSeq(text);
112
134
  const first = cards.items[0];
113
135
  if (!isMap(first))
@@ -125,8 +147,13 @@ function cardLines(entry, dashIndent) {
125
147
  }
126
148
  export function removeCardText(text, cardId) {
127
149
  const card = findCard(text, cardId);
128
- const start = lineStart(text, range(card)[0]);
129
150
  const end = afterLine(text, range(card)[1] - 1);
151
+ if (cardsSeq(text).cards.items.length === 1) {
152
+ const pair = rootMap(text).items.find((p) => keyOf(p) === 'cards');
153
+ const start = lineStart(text, range(pair?.key)[0]);
154
+ return `${text.slice(0, start)}cards: []\n${text.slice(end)}`;
155
+ }
156
+ const start = lineStart(text, range(card)[0]);
130
157
  return text.slice(0, start) + text.slice(end);
131
158
  }
132
159
  // Replaces each given field in place, or adds it after the card's last line.
@@ -156,10 +183,7 @@ export function updateCardText(text, cardId, fields) {
156
183
  return out;
157
184
  }
158
185
  export function setMembershipsText(text, memberships) {
159
- const { doc } = cardsSeq(text);
160
- const root = doc.contents;
161
- if (!isMap(root))
162
- throw new Error(`the wallet isn't a map; ${BY_HAND}`);
186
+ const root = rootMap(text);
163
187
  const line = `memberships: ${flowList(memberships)}\n`;
164
188
  const pair = root.items.find((p) => keyOf(p) === 'memberships');
165
189
  if (pair) {
@@ -168,7 +192,83 @@ export function setMembershipsText(text, memberships) {
168
192
  return text.slice(0, start) + line + text.slice(end);
169
193
  }
170
194
  const cards = root.items.find((p) => keyOf(p) === 'cards');
171
- return insertLines(text, lineStart(text, range(cards?.key)[0]), line);
195
+ return cards
196
+ ? insertLines(text, lineStart(text, range(cards.key)[0]), line)
197
+ : insertLines(text, text.length, line);
198
+ }
199
+ // Point values, one currency at a time so the rest of the file, comments
200
+ // included, stays as it was: a value is replaced in place, a new one goes
201
+ // after the last, a removed one loses its line. An inline map is rewritten.
202
+ export function setValuationsText(text, changes) {
203
+ let out = text;
204
+ for (const [id, cents] of Object.entries(changes))
205
+ out = setValuationText(out, id, cents);
206
+ return out;
207
+ }
208
+ // The start of the line at `at`, moved up past the comments right above it,
209
+ // so a comment stays with the line it describes.
210
+ function aboveComments(text, at) {
211
+ let start = lineStart(text, at);
212
+ while (start > 0) {
213
+ const previous = lineStart(text, start - 1);
214
+ if (!text.slice(previous, start).trimStart().startsWith('#'))
215
+ break;
216
+ start = previous;
217
+ }
218
+ return start;
219
+ }
220
+ function setValuationText(text, id, cents) {
221
+ const root = rootMap(text);
222
+ const pair = root.items.find((p) => keyOf(p) === 'valuations');
223
+ const map = pair?.value;
224
+ const line = cents === null ? '' : `${id}: ${String(cents)}`;
225
+ if (!pair || map === null || (isMap(map) && map.items.length === 0)) {
226
+ if (cents === null)
227
+ return text;
228
+ const block = `valuations:\n ${line}\n`;
229
+ if (pair) {
230
+ const start = range(pair.key)[0];
231
+ const end = afterLine(text, range(map ?? pair.key)[1] - 1);
232
+ return text.slice(0, start) + block + text.slice(end);
233
+ }
234
+ const before = root.items.find((p) => keyOf(p) === 'memberships' || keyOf(p) === 'cards');
235
+ return before
236
+ ? insertLines(text, aboveComments(text, range(before.key)[0]), block)
237
+ : insertLines(text, text.length, block);
238
+ }
239
+ if (!isMap(map))
240
+ throw new Error(`"valuations" isn't a map; ${BY_HAND}`);
241
+ if (map.flow) {
242
+ const values = Object.entries(map.toJSON()).filter(([k]) => k !== id);
243
+ if (cents !== null)
244
+ values.push([id, cents]);
245
+ const entries = values.map(([k, v]) => `${k}: ${stringify(v, { flow: true }).trimEnd()}`);
246
+ const start = range(pair.key)[0];
247
+ const end = afterLine(text, range(map)[1] - 1);
248
+ return (text.slice(0, start) +
249
+ (entries.length === 0 ? '' : `valuations: { ${entries.join(', ')} }\n`) +
250
+ text.slice(end));
251
+ }
252
+ const own = map.items.find((p) => keyOf(p) === id);
253
+ if (own) {
254
+ if (cents === null) {
255
+ if (map.items.length === 1) {
256
+ const start = lineStart(text, range(pair.key)[0]);
257
+ return (text.slice(0, start) + text.slice(afterLine(text, range(map)[1] - 1)));
258
+ }
259
+ const start = lineStart(text, range(own.key)[0]);
260
+ return (text.slice(0, start) +
261
+ text.slice(afterLine(text, range(own.value)[1] - 1)));
262
+ }
263
+ const [start, end] = range(own.value);
264
+ return text.slice(0, start) + String(cents) + text.slice(end);
265
+ }
266
+ if (cents === null)
267
+ return text;
268
+ const first = map.items[0];
269
+ const last = map.items.at(-1);
270
+ const indent = ' '.repeat(column(text, range(first?.key)[0]));
271
+ return insertLines(text, afterLine(text, range(last?.value ?? last?.key)[1] - 1), `${indent}${line}\n`);
172
272
  }
173
273
  function flowUsage(booking) {
174
274
  const period = /^\d+$/.test(booking.period)
@@ -176,7 +276,7 @@ function flowUsage(booking) {
176
276
  : booking.period;
177
277
  return `{ period: ${period}, spent: ${String(booking.after)} }`;
178
278
  }
179
- export function capUsageText(text, booking) {
279
+ function capUsageText(text, booking) {
180
280
  const card = findCard(text, booking.cardId);
181
281
  const value = flowUsage(booking);
182
282
  const usage = card.get('cap_usage', true);
@@ -196,7 +296,7 @@ export function capUsageText(text, booking) {
196
296
  const at = afterLine(text, range(card)[1] - 1);
197
297
  return insertLines(text, at, `${indent}cap_usage:\n${indent} ${booking.capKey}: ${value}\n`);
198
298
  }
199
- export function bonusSpentText(text, cardId, spent) {
299
+ function bonusSpentText(text, cardId, spent) {
200
300
  const bonus = findCard(text, cardId).get('bonus', true);
201
301
  if (!isMap(bonus))
202
302
  throw new Error(`card '${cardId}' has no bonus`);
@@ -1,11 +1,14 @@
1
- import { resolveMerchantById } from "../merchants/resolve.js";
2
- import { categoryResolution, exploreCards, } from "./explore.js";
1
+ import { categoryResolution, resolveMerchantById, } from "../merchants/resolve.js";
2
+ import { exploreCards } from "./explore.js";
3
3
  import { bonusStatus } from "./bonus.js";
4
+ import { EPSILON, isClose } from "./compare.js";
4
5
  import { periodKey } from "./periods.js";
5
6
  import { recommend } from "./recommend.js";
6
7
  import { setupNotes } from "./setup.js";
7
- import { listed, money, shortDate } from "./text.js";
8
- const CHEAT_SHEET_ITEMS = [
8
+ import { capitalize, feeText, listed, money, shortDate } from "./text.js";
9
+ // The everyday kinds of purchase: the cheat sheet's rows and the site's
10
+ // quick picks.
11
+ export const EVERYDAY_PURCHASES = [
9
12
  { label: 'Groceries', category: 'grocery.supermarket' },
10
13
  { label: 'Dining', category: 'dining' },
11
14
  { label: 'Gas', category: 'gas' },
@@ -18,7 +21,7 @@ const CHEAT_SHEET_ITEMS = [
18
21
  ];
19
22
  // The best card in the wallet for each everyday kind of purchase on `date`,
20
23
  // with caps, picks and activations as the wallet has them.
21
- export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
24
+ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
22
25
  const ranked = [];
23
26
  const rows = items.map((item) => {
24
27
  const merchant = 'merchant' in item
@@ -53,8 +56,8 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
53
56
  const name = held.checkout_label ??
54
57
  catalog.cards.find((c) => c.id === held.id)?.name ??
55
58
  held.id;
56
- // Rows where the bonus card matches the row's card, or comes within a
57
- // point and three quarters of its rate (as single answers mention it).
59
+ // Rows where the bonus card matches the row's card, or is close to it
60
+ // (as single answers mention it).
58
61
  const matches = [];
59
62
  const near = [];
60
63
  for (const { label, cards } of ranked) {
@@ -62,9 +65,9 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
62
65
  const own = cards.find((c) => c.cardId === held.id)?.effectiveReturnPct;
63
66
  if (best === undefined || own === undefined)
64
67
  continue;
65
- if (best - own < 1e-9)
68
+ if (best - own < EPSILON)
66
69
  matches.push(label.toLowerCase());
67
- else if (best - own <= 1 && own >= best * 0.75)
70
+ else if (isClose(own, best))
68
71
  near.push(label.toLowerCase());
69
72
  }
70
73
  const where = [
@@ -97,14 +100,11 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
97
100
  // The best card earnrate knows for each everyday kind of purchase, for
98
101
  // someone who hasn't named any cards. Each row's note says what the card
99
102
  // costs or needs.
100
- export function catalogCheatSheet(catalog, date, options, items = CHEAT_SHEET_ITEMS) {
103
+ export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCHASES) {
101
104
  const rows = items.map((item) => {
102
105
  const top = exploreCards(catalog, 'merchant' in item
103
106
  ? {
104
- merchant: catalog.merchants.seed.find((m) => m.id === item.merchant)
105
- ?.name ??
106
- catalog.merchants.overrides.find((m) => m.id === item.merchant)
107
- ?.name ??
107
+ merchant: resolveMerchantById(catalog, item.merchant, null)?.resolvedName ??
108
108
  item.label,
109
109
  category: null,
110
110
  amount: null,
@@ -118,11 +118,7 @@ export function catalogCheatSheet(catalog, date, options, items = CHEAT_SHEET_IT
118
118
  channel: null,
119
119
  date,
120
120
  }, options).ranked[0];
121
- const fee = top?.annualFee === null || top?.annualFee === undefined
122
- ? null
123
- : top.annualFee === 0
124
- ? 'No annual fee.'
125
- : `$${String(top.annualFee)} annual fee.`;
121
+ const fee = top ? `${capitalize(feeText(top))}.` : null;
126
122
  return {
127
123
  label: item.label,
128
124
  cardId: top?.cardId ?? null,
@@ -0,0 +1,10 @@
1
+ // Returns closer than this are equal: floating-point noise, not a difference.
2
+ export const EPSILON = 1e-9;
3
+ // Dollar amounts closer than half a cent print the same.
4
+ export const HALF_CENT = 0.005;
5
+ // A card close to the best one: within a percentage point, and at least
6
+ // three quarters of its rate. Answers and cheat sheets mention a welcome
7
+ // bonus card when it's this close.
8
+ export function isClose(own, best) {
9
+ return best - own <= 1 && own >= best * 0.75;
10
+ }
@@ -1,4 +1,4 @@
1
- import { periodKey } from "./periods.js";
1
+ import { inEffect, periodKey } from "./periods.js";
2
2
  export function purchaseContext(catalog, wallet, merchant, category, purchase) {
3
3
  return {
4
4
  category,
@@ -110,9 +110,7 @@ function applicability(card, rule, ctx, held, picked) {
110
110
  return 'no';
111
111
  if (rule.geography === 'us_only' && ctx.country !== 'US')
112
112
  return 'no';
113
- if (rule.valid_from !== undefined && ctx.date < rule.valid_from)
114
- return 'no';
115
- if (rule.valid_to !== undefined && ctx.date > rule.valid_to)
113
+ if (!inEffect(rule, ctx.date))
116
114
  return 'no';
117
115
  const membership = rule.requires?.membership;
118
116
  if (membership !== undefined && !ctx.memberships.has(membership))