earnrate 0.2.5 → 0.2.7

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 (112) hide show
  1. package/README.md +25 -18
  2. package/data/CHANGELOG.md +56 -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 +19 -3
  8. package/data/cards/amex-platinum.yaml +19 -3
  9. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +10 -2
  10. package/data/cards/bank-of-america-atmos-rewards-business.yaml +14 -3
  11. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +4 -1
  12. package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -5
  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 +49 -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 +15 -2
  25. package/data/cards/chase-ihg-one-rewards.yaml +8 -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 +16 -3
  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 +5 -2
  47. package/data/cards/citi-costco-anywhere-visa.yaml +6 -3
  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 +12 -3
  55. package/data/cards/td-bank-nordstrom-visa.yaml +19 -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/issuers.yaml +32 -0
  61. package/data/merchants/seed.yaml +7 -0
  62. package/dist/brand.js +1 -0
  63. package/dist/cli/bin.js +10 -1
  64. package/dist/cli/cheatsheet.js +10 -5
  65. package/dist/cli/explore.js +6 -13
  66. package/dist/cli/import.js +2 -2
  67. package/dist/cli/missed.js +65 -0
  68. package/dist/cli/offers.js +17 -7
  69. package/dist/cli/run.js +29 -17
  70. package/dist/cli/todo.js +62 -0
  71. package/dist/cli/wallet.js +9 -6
  72. package/dist/data/file-store.js +6 -7
  73. package/dist/data/integrity.js +4 -0
  74. package/dist/data/load.js +4 -1
  75. package/dist/data/store.js +6 -22
  76. package/dist/data/wallet-edit.js +38 -13
  77. package/dist/engine/cheat-sheet.js +14 -6
  78. package/dist/engine/checklist.js +152 -0
  79. package/dist/engine/compare.js +2 -0
  80. package/dist/engine/evaluate.js +16 -11
  81. package/dist/engine/explore.js +6 -38
  82. package/dist/engine/memberships.js +1 -1
  83. package/dist/engine/offers.js +2 -1
  84. package/dist/engine/periods.js +18 -0
  85. package/dist/engine/recommend.js +21 -20
  86. package/dist/engine/rule-text.js +47 -0
  87. package/dist/engine/setup.js +14 -24
  88. package/dist/engine/simulate.js +7 -6
  89. package/dist/engine/text.js +13 -1
  90. package/dist/engine/wallet-change.js +10 -12
  91. package/dist/engine/wallet-summary.js +80 -65
  92. package/dist/format/card-text.js +7 -20
  93. package/dist/format/explore.js +2 -2
  94. package/dist/format/offers.js +2 -2
  95. package/dist/format/recommendation.js +2 -2
  96. package/dist/format/wallet.js +7 -1
  97. package/dist/import/analyze.js +2 -1
  98. package/dist/mcp/context.js +38 -13
  99. package/dist/mcp/inputs.js +1 -1
  100. package/dist/mcp/schemas.js +9 -0
  101. package/dist/mcp/serve.js +4 -0
  102. package/dist/mcp/server.js +8 -2
  103. package/dist/mcp/tools/account.js +44 -16
  104. package/dist/mcp/tools/answers.js +58 -19
  105. package/dist/mcp/tools/wallet.js +64 -25
  106. package/dist/merchants/cache.js +0 -10
  107. package/dist/merchants/missed.js +78 -0
  108. package/dist/merchants/resolve.js +81 -4
  109. package/dist/schema/card.js +4 -1
  110. package/dist/schema/issuer.js +8 -0
  111. package/dist/schema/wallet.js +1 -1
  112. package/package.json +1 -1
package/dist/cli/run.js CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { CLI_NAME, CONFIG_DIR, LLM_ENV_VAR, LLM_MAX_CALLS_ENV_VAR, LLM_MODEL_ENV_VAR, MERCHANT_CACHE_ENV_VAR, WALLET_ENV_VAR, } from "../brand.js";
6
6
  import { walletProblems } from "../data/integrity.js";
7
7
  import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
8
- import { ALL_PERSONAL_CARDS, categoryResolution, exploreCards, findCategory, } from "../engine/explore.js";
8
+ import { ALL_PERSONAL_CARDS, exploreCards } from "../engine/explore.js";
9
9
  import { profileText, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
10
10
  import { recommend } from "../engine/recommend.js";
11
11
  import { walletFromCards } from "../engine/transient.js";
@@ -14,10 +14,13 @@ import { formatRecommendation, plain, } from "../format/recommendation.js";
14
14
  import { DEFAULT_LLM_MODEL } from "../merchants/anthropic.js";
15
15
  import { DEFAULT_MAX_CALLS } from "../merchants/fallback.js";
16
16
  import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
17
- import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
17
+ import { missedPath, missLogAt } from "../merchants/missed.js";
18
+ import { resolvePlace, withCallerCategory } from "../merchants/resolve.js";
18
19
  import { packageVersion } from "../version.js";
19
20
  import { membershipProblem, parseCliArgs } from "./args.js";
20
21
  import { runCheatSheet } from "./cheatsheet.js";
22
+ import { runMissed } from "./missed.js";
23
+ import { runTodo } from "./todo.js";
21
24
  import { runBest, runSimulate } from "./explore.js";
22
25
  import { runImport } from "./import.js";
23
26
  import { runOffers } from "./offers.js";
@@ -47,6 +50,8 @@ Save your cards (optional):
47
50
 
48
51
  Other commands:
49
52
  ${CLI_NAME} cheatsheet The best card for each category this quarter.
53
+ ${CLI_NAME} todo [--ics file] What your cards need this quarter, with dates.
54
+ ${CLI_NAME} missed Stores earnrate didn't recognize, kept on this computer.
50
55
  ${CLI_NAME} import <statement.csv> --card <id> Replay a statement against your wallet.
51
56
  ${CLI_NAME} offers [--cash | --points] Rank welcome offers on cards you don't have.
52
57
  ${CLI_NAME} mcp Run the MCP server over stdio, for AI assistants.
@@ -63,7 +68,7 @@ Options:
63
68
  --country <code> Where you're paying. Default: US.
64
69
  --cards <ids> Answer for these cards instead of your wallet, comma
65
70
  separated: --cards amex-gold,chase-prime-visa.
66
- --member <name> A membership you have, with --cards or no wallet.
71
+ --member <name> A membership you have, in place of the saved ones.
67
72
  --category <id> For a merchant earnrate doesn't know: its category,
68
73
  e.g. grocery.supermarket. Ignored for known merchants.
69
74
  --llm, --no-llm Ask Claude to classify merchants earnrate doesn't know.
@@ -87,7 +92,9 @@ export function displayPath(file) {
87
92
  return file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file;
88
93
  }
89
94
  // The cards to answer for: those named with --cards, else the wallet file.
90
- // 'none' means no wallet and no --cards, which answers from every card.
95
+ // 'none' means no cards saved or named, which answers from every card.
96
+ // Named cards use the saved memberships (unless --member names some) and the
97
+ // saved point values, as the MCP tools do.
91
98
  export async function cardsToUse(catalog, file, io, named) {
92
99
  const problem = membershipProblem(catalog, named.members);
93
100
  if (problem !== null) {
@@ -95,15 +102,16 @@ export async function cardsToUse(catalog, file, io, named) {
95
102
  return null;
96
103
  }
97
104
  if (named.cards !== null) {
105
+ const saved = await savedSettings(io);
98
106
  const wallet = walletFromCards(catalog, {
99
107
  cards: named.cards.map((id) => ({ id })),
100
- memberships: named.members,
108
+ memberships: named.members.length > 0 ? named.members : saved.memberships,
101
109
  }, io.today);
102
110
  if (typeof wallet === 'string') {
103
111
  io.stderr(`${wallet}\n`);
104
112
  return null;
105
113
  }
106
- return wallet;
114
+ return { ...wallet, valuations: saved.valuations };
107
115
  }
108
116
  try {
109
117
  await access(file);
@@ -119,7 +127,7 @@ export async function cardsToUse(catalog, file, io, named) {
119
127
  io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
120
128
  return null;
121
129
  }
122
- return loaded;
130
+ return loaded.cards.length === 0 ? 'none' : loaded;
123
131
  }
124
132
  export const NO_WALLET_HINT = `These are every card earnrate knows. For the best of your own cards, name them with --cards amex-gold,chase-prime-visa, or save them: ${CLI_NAME} add <card-id> (${CLI_NAME} cards finds ids).`;
125
133
  // The point values and memberships saved in the wallet, which every answer
@@ -157,6 +165,10 @@ export async function run(argv, io) {
157
165
  return runBest(argv.slice(1), io);
158
166
  if (argv[0] === 'cheatsheet')
159
167
  return runCheatSheet(argv.slice(1), io);
168
+ if (argv[0] === 'todo')
169
+ return runTodo(argv.slice(1), io);
170
+ if (argv[0] === 'missed')
171
+ return runMissed(argv.slice(1), io);
160
172
  if (argv[0] === 'simulate')
161
173
  return runSimulate(argv.slice(1), io);
162
174
  const command = argv[0];
@@ -194,14 +206,7 @@ export async function run(argv, io) {
194
206
  io.stderr(`Unknown category "${options.category}". Use one of: ${catalog.categories.map((c) => c.id).join(', ')}.\n`);
195
207
  return USAGE_ERROR;
196
208
  }
197
- // Plain category words ("restaurants", "groceries") work like stores.
198
- const resolvedName = resolveMerchant(catalog, purchase.merchant, purchase.channel);
199
- const named = resolvedName.source === 'none' && options.category === null
200
- ? findCategory(catalog, purchase.merchant)
201
- : null;
202
- let merchant = named === null
203
- ? withCallerCategory(resolvedName, options.category)
204
- : categoryResolution(catalog, named, purchase.channel);
209
+ let merchant = withCallerCategory(resolvePlace(catalog, purchase.merchant, purchase.channel), options.category);
205
210
  const notices = [];
206
211
  if (llmOn && merchant.source === 'none') {
207
212
  const opened = await openLlmSession({
@@ -218,7 +223,14 @@ export async function run(argv, io) {
218
223
  merchant = resolved.merchant;
219
224
  notices.push(...resolved.notices);
220
225
  }
226
+ const missedFile = missedPath(io.env);
227
+ if (missedFile !== null &&
228
+ (merchant.source === 'none' ||
229
+ merchant.source === 'caller' ||
230
+ merchant.source === 'llm'))
231
+ await missLogAt(missedFile).note(purchase.merchant, merchant.source === 'none' ? null : merchant.category, io.today);
221
232
  if (loaded === 'none') {
233
+ const saved = await savedSettings(io);
222
234
  const explored = exploreCards(catalog, {
223
235
  merchant: purchase.merchant,
224
236
  category: null,
@@ -227,10 +239,10 @@ export async function run(argv, io) {
227
239
  date: purchase.date,
228
240
  }, {
229
241
  filters: ALL_PERSONAL_CARDS,
230
- memberships: options.members,
242
+ memberships: options.members.length > 0 ? options.members : saved.memberships,
231
243
  valuations: options.valuation
232
244
  ? VALUATION_PROFILES[options.valuation]
233
- : {},
245
+ : saved.valuations,
234
246
  }, merchant);
235
247
  if (options.json) {
236
248
  io.stdout(`${JSON.stringify(explored, null, 2)}\n`);
@@ -0,0 +1,62 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { parseArgs } from 'node:util';
3
+ import { CLI_NAME } from "../brand.js";
4
+ import { loadCatalog, walletPath } from "../data/load.js";
5
+ import { checklist, checklistCalendar } from "../engine/checklist.js";
6
+ import { cardText } from "../format/card-text.js";
7
+ import { dateProblem } from "./args.js";
8
+ import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
9
+ const TODO_HELP = `Usage: ${CLI_NAME} todo [--ics <file>] [--date <YYYY-MM-DD>]
10
+
11
+ What your saved cards need this period, soonest first: bonuses to activate,
12
+ categories to pick, spending caps running low, welcome bonuses to finish, and
13
+ rotating categories the issuer hasn't posted yet.
14
+
15
+ Options:
16
+ --ics <file> Also write the dated items as a calendar file to import
17
+ into your calendar app, with reminders two days before.
18
+ --date <YYYY-MM-DD> The day to check. Default: today.
19
+ -h, --help Show this help.
20
+ `;
21
+ export async function runTodo(argv, io) {
22
+ let parsed;
23
+ try {
24
+ parsed = parseArgs({
25
+ args: argv,
26
+ options: {
27
+ ics: { type: 'string' },
28
+ date: { type: 'string' },
29
+ help: { type: 'boolean', short: 'h' },
30
+ },
31
+ });
32
+ }
33
+ catch (e) {
34
+ io.stderr(`${e instanceof Error ? e.message : String(e)}\n${TODO_HELP}`);
35
+ return USAGE_ERROR;
36
+ }
37
+ const { values } = parsed;
38
+ if (values.help) {
39
+ io.stdout(TODO_HELP);
40
+ return 0;
41
+ }
42
+ const problem = dateProblem(values.date);
43
+ if (problem !== null) {
44
+ io.stderr(`${problem}\n`);
45
+ return USAGE_ERROR;
46
+ }
47
+ const catalog = await loadCatalog();
48
+ const wallet = await readWallet(walletPath(io.env), io);
49
+ if (wallet === null)
50
+ return RUNTIME_ERROR;
51
+ const today = values.date ?? io.today;
52
+ const items = checklist(catalog, wallet, today, undefined, cardText(catalog).shortName);
53
+ io.stdout(items.length === 0
54
+ ? 'Nothing to do: every bonus is activated, every pick is made, and no cap or welcome bonus needs attention.\n'
55
+ : `${items.map((i) => `- ${i.text}`).join('\n')}\n`);
56
+ if (values.ics !== undefined) {
57
+ const stamp = `${new Date().toISOString().replace(/[-:]/g, '').slice(0, 15)}Z`;
58
+ await writeFile(values.ics, checklistCalendar(items, stamp));
59
+ io.stdout(`Wrote ${displayPath(values.ics)}; open it to add the dates to your calendar.\n`);
60
+ }
61
+ return 0;
62
+ }
@@ -3,6 +3,7 @@ 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
5
  import { isValuationProfile, profileText, VALUATION_PROFILES, } from "../engine/profiles.js";
6
+ import { membershipName } from "../engine/memberships.js";
6
7
  import { knownMemberships, searchCards } from "../engine/setup.js";
7
8
  import { formatCardInfo, formatNotes } from "../format/cards.js";
8
9
  import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
@@ -41,19 +42,21 @@ ${CARD_OPTIONS}
41
42
  `,
42
43
  update: `Usage: ${CLI_NAME} update <card-id> [options]
43
44
 
44
- Changes a card in your wallet: picks for this period, label, anniversary or
45
- 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.
46
48
 
47
49
  ${CARD_OPTIONS}
48
50
  `,
49
51
  remove: `Usage: ${CLI_NAME} remove <card-id>
50
52
 
51
- 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.
52
55
  `,
53
56
  memberships: `Usage: ${CLI_NAME} memberships [<membership>...] [--none]
54
57
 
55
58
  With no arguments, shows your memberships and the ones earnrate knows. With
56
- names, replaces the list. --none clears it.
59
+ names, replaces the list. --none clears it. No cards need to be saved first.
57
60
  `,
58
61
  values: `Usage: ${CLI_NAME} values [<currency>=<cents>...] [--profile <name>]
59
62
 
@@ -205,7 +208,7 @@ export async function runWalletCommand(command, argv, io) {
205
208
  if (command === 'memberships') {
206
209
  if (positionals.length === 0 && !values.none) {
207
210
  const held = await heldWallet(io);
208
- io.stdout(`Yours: ${held && held.memberships.length > 0 ? held.memberships.join(', ') : 'none'}\nKnown: ${knownMemberships(catalog).join(', ')}\n`);
211
+ io.stdout(`Yours: ${held && held.memberships.length > 0 ? held.memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ') : 'none'}\nKnown: ${knownMemberships(catalog).join(', ')}\n`);
209
212
  return 0;
210
213
  }
211
214
  if (positionals.length > 0 && values.none) {
@@ -213,7 +216,7 @@ export async function runWalletCommand(command, argv, io) {
213
216
  }
214
217
  const list = values.none ? [] : positionals;
215
218
  return report(await store.setMemberships(list), list.length > 0
216
- ? `Memberships set to ${list.join(', ')} in ${where}.`
219
+ ? `Memberships set to ${list.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')} in ${where}.`
217
220
  : `Memberships cleared in ${where}.`, null, io);
218
221
  }
219
222
  if (positionals.length !== 1) {
@@ -1,7 +1,8 @@
1
1
  import { bookPurchase } from "../engine/record.js";
2
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, setValuationsText, 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,15 +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));
78
77
  }
79
78
  setValuations(changes) {
80
- return this.change(this.existing((w) => withValuations(this.catalog, w, changes)), (text) => setValuationsText(text ?? '', changes));
79
+ return this.change((w) => withValuations(this.catalog, w ?? emptyWallet(), changes), (text) => setValuationsText(text ?? EMPTY_WALLET_TEXT, changes));
81
80
  }
82
81
  record(purchase, merchant) {
83
82
  return withWalletLock(this.where, async () => {
@@ -179,6 +179,7 @@ export function catalogProblems(catalog) {
179
179
  ['currency', catalog.currencies.map((c) => c.id)],
180
180
  ['card', catalog.cards.map((c) => c.id)],
181
181
  ['membership', catalog.memberships.map((m) => m.id)],
182
+ ['issuer', catalog.issuers.map((i) => i.id)],
182
183
  ]) {
183
184
  for (const id of duplicates(ids))
184
185
  problems.push(`duplicate ${kind} id '${id}'`);
@@ -203,8 +204,11 @@ export function catalogProblems(catalog) {
203
204
  for (const id of m.members_shop_at)
204
205
  if (!merchantIds.has(id))
205
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));
206
208
  for (const card of catalog.cards) {
207
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)`);
208
212
  }
209
213
  return problems;
210
214
  }
package/dist/data/load.js CHANGED
@@ -8,6 +8,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
10
  import { CurrenciesFileSchema } from "../schema/currency.js";
11
+ import { IssuersFileSchema } from "../schema/issuer.js";
11
12
  import { MembershipsFileSchema } from "../schema/membership.js";
12
13
  import { MerchantsFileSchema } from "../schema/merchant.js";
13
14
  import { WalletSchema } from "../schema/wallet.js";
@@ -31,9 +32,10 @@ async function loadCards(dir) {
31
32
  }));
32
33
  }
33
34
  export async function loadCatalog(dir = DATA_DIR) {
34
- const [categories, currencies, memberships, cards, seed, overrides] = await Promise.all([
35
+ const [categories, currencies, issuers, memberships, cards, seed, overrides] = await Promise.all([
35
36
  readYaml(path.join(dir, 'categories.yaml'), CategoriesFileSchema),
36
37
  readYaml(path.join(dir, 'currencies.yaml'), CurrenciesFileSchema),
38
+ readYaml(path.join(dir, 'issuers.yaml'), IssuersFileSchema),
37
39
  readYaml(path.join(dir, 'memberships.yaml'), MembershipsFileSchema),
38
40
  loadCards(path.join(dir, 'cards')),
39
41
  readYaml(path.join(dir, 'merchants', 'seed.yaml'), MerchantsFileSchema),
@@ -42,6 +44,7 @@ export async function loadCatalog(dir = DATA_DIR) {
42
44
  return {
43
45
  categories,
44
46
  currencies,
47
+ issuers,
45
48
  memberships,
46
49
  cards,
47
50
  merchants: { seed, overrides },
@@ -1,7 +1,9 @@
1
1
  import { bookPurchase } from "../engine/record.js";
2
2
  import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
3
- import { WalletSchema } from "../schema/wallet.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,10 +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));
62
60
  }
63
61
  setValuations(changes) {
64
- return this.change(this.needsWallet((w) => withValuations(this.catalog, w, changes)));
62
+ return this.change((w) => withValuations(this.catalog, w ?? emptyWallet(), changes));
65
63
  }
66
64
  replace(wallet) {
67
65
  return this.change(() => ({ ok: true, wallet, fields: {}, notes: [] }));
@@ -91,17 +89,3 @@ export class JsonWalletStore {
91
89
  });
92
90
  }
93
91
  }
94
- export function memoryBackend(initial = null) {
95
- let text = initial;
96
- return {
97
- read: () => Promise.resolve(text),
98
- write: (next) => {
99
- text = next;
100
- return Promise.resolve();
101
- },
102
- remove: () => {
103
- text = null;
104
- return Promise.resolve();
105
- },
106
- };
107
- }
@@ -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,9 @@ 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);
172
198
  }
173
199
  // Point values, one currency at a time so the rest of the file, comments
174
200
  // included, stays as it was: a value is replaced in place, a new one goes
@@ -192,10 +218,7 @@ function aboveComments(text, at) {
192
218
  return start;
193
219
  }
194
220
  function setValuationText(text, id, cents) {
195
- const { doc } = cardsSeq(text);
196
- const root = doc.contents;
197
- if (!isMap(root))
198
- throw new Error(`the wallet isn't a map; ${BY_HAND}`);
221
+ const root = rootMap(text);
199
222
  const pair = root.items.find((p) => keyOf(p) === 'valuations');
200
223
  const map = pair?.value;
201
224
  const line = cents === null ? '' : `${id}: ${String(cents)}`;
@@ -209,7 +232,9 @@ function setValuationText(text, id, cents) {
209
232
  return text.slice(0, start) + block + text.slice(end);
210
233
  }
211
234
  const before = root.items.find((p) => keyOf(p) === 'memberships' || keyOf(p) === 'cards');
212
- return insertLines(text, aboveComments(text, range(before?.key)[0]), block);
235
+ return before
236
+ ? insertLines(text, aboveComments(text, range(before.key)[0]), block)
237
+ : insertLines(text, text.length, block);
213
238
  }
214
239
  if (!isMap(map))
215
240
  throw new Error(`"valuations" isn't a map; ${BY_HAND}`);
@@ -251,7 +276,7 @@ function flowUsage(booking) {
251
276
  : booking.period;
252
277
  return `{ period: ${period}, spent: ${String(booking.after)} }`;
253
278
  }
254
- export function capUsageText(text, booking) {
279
+ function capUsageText(text, booking) {
255
280
  const card = findCard(text, booking.cardId);
256
281
  const value = flowUsage(booking);
257
282
  const usage = card.get('cap_usage', true);
@@ -271,7 +296,7 @@ export function capUsageText(text, booking) {
271
296
  const at = afterLine(text, range(card)[1] - 1);
272
297
  return insertLines(text, at, `${indent}cap_usage:\n${indent} ${booking.capKey}: ${value}\n`);
273
298
  }
274
- export function bonusSpentText(text, cardId, spent) {
299
+ function bonusSpentText(text, cardId, spent) {
275
300
  const bonus = findCard(text, cardId).get('bonus', true);
276
301
  if (!isMap(bonus))
277
302
  throw new Error(`card '${cardId}' has no bonus`);
@@ -1,11 +1,12 @@
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
4
  import { EPSILON, isClose } from "./compare.js";
5
5
  import { periodKey } from "./periods.js";
6
6
  import { recommend } from "./recommend.js";
7
+ import { checklist } from "./checklist.js";
7
8
  import { setupNotes } from "./setup.js";
8
- import { feeText, listed, money, shortDate } from "./text.js";
9
+ import { capitalize, feeText, listed, money, shortDate } from "./text.js";
9
10
  // The everyday kinds of purchase: the cheat sheet's rows and the site's
10
11
  // quick picks.
11
12
  export const EVERYDAY_PURCHASES = [
@@ -82,10 +83,18 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
82
83
  : 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
83
84
  ];
84
85
  });
86
+ // Picks and activations in plain words, as the to-do list says them; the
87
+ // setup notes name ids for an agent to set.
88
+ const todo = checklist(catalog, wallet, date).filter((i) => i.kind === 'pick' || i.kind === 'activate');
85
89
  const notes = [
86
90
  ...new Set([
87
91
  ...bonuses,
88
- ...wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date).map((n) => n.text)),
92
+ ...todo.map((i) => i.text),
93
+ ...wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date)
94
+ .filter((n) => n.kind !== 'pick' && n.kind !== 'activation')
95
+ .map((n) => n.kind === 'membership'
96
+ ? n.text.replace(/ \([a-z0-9-]+\)/g, '')
97
+ : n.text)),
89
98
  ]),
90
99
  ];
91
100
  const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
@@ -100,7 +109,6 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
100
109
  // The best card earnrate knows for each everyday kind of purchase, for
101
110
  // someone who hasn't named any cards. Each row's note says what the card
102
111
  // costs or needs.
103
- const capitalized = (t) => t.charAt(0).toUpperCase() + t.slice(1);
104
112
  export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCHASES) {
105
113
  const rows = items.map((item) => {
106
114
  const top = exploreCards(catalog, 'merchant' in item
@@ -119,7 +127,7 @@ export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCH
119
127
  channel: null,
120
128
  date,
121
129
  }, options).ranked[0];
122
- const fee = top ? `${capitalized(feeText(top))}.` : null;
130
+ const fee = top ? `${capitalize(feeText(top))}.` : null;
123
131
  return {
124
132
  label: item.label,
125
133
  cardId: top?.cardId ?? null,