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
@@ -0,0 +1,78 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { z } from 'zod';
5
+ import { CONFIG_DIR, MISSED_ENV_VAR } from "../brand.js";
6
+ import { normalizeMerchantName } from "./resolve.js";
7
+ const MissedFileSchema = z.object({
8
+ version: z.literal(1),
9
+ merchants: z.record(z.string(), z.object({
10
+ name: z.string(),
11
+ count: z.number().int().positive(),
12
+ last_seen: z.string(),
13
+ categories: z.array(z.string()).default([]),
14
+ })),
15
+ });
16
+ const MAX = 500;
17
+ // $EARNRATE_MISSED_MERCHANTS names another file, or "off" keeps no log.
18
+ export function missedPath(env = process.env) {
19
+ const given = env[MISSED_ENV_VAR]?.trim();
20
+ if (given === 'off')
21
+ return null;
22
+ return given || path.join(homedir(), CONFIG_DIR, 'missed-merchants.json');
23
+ }
24
+ export async function readMissed(file) {
25
+ try {
26
+ const parsed = MissedFileSchema.safeParse(JSON.parse(await readFile(file, 'utf8')));
27
+ return parsed.success ? Object.values(parsed.data.merchants) : [];
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ // Writes are queued, so concurrent tool calls never lose a count. A file that
34
+ // can't be read as a log is left alone.
35
+ export function missLogAt(file) {
36
+ let queue = Promise.resolve();
37
+ return {
38
+ note(name, category, today) {
39
+ const key = normalizeMerchantName(name);
40
+ if (key === '')
41
+ return Promise.resolve();
42
+ const run = queue.then(async () => {
43
+ let merchants = {};
44
+ try {
45
+ const parsed = MissedFileSchema.safeParse(JSON.parse(await readFile(file, 'utf8')));
46
+ if (!parsed.success)
47
+ return;
48
+ merchants = parsed.data.merchants;
49
+ }
50
+ catch (e) {
51
+ if (!(e instanceof Error && 'code' in e && e.code === 'ENOENT'))
52
+ return;
53
+ }
54
+ const seen = merchants[key];
55
+ merchants[key] = {
56
+ name: name.trim(),
57
+ count: (seen?.count ?? 0) + 1,
58
+ last_seen: today,
59
+ categories: [
60
+ ...new Set([
61
+ ...(seen?.categories ?? []),
62
+ ...(category === null ? [] : [category]),
63
+ ]),
64
+ ],
65
+ };
66
+ const kept = Object.entries(merchants)
67
+ .sort(([, a], [, b]) => b.last_seen.localeCompare(a.last_seen))
68
+ .slice(0, MAX);
69
+ await mkdir(path.dirname(file), { recursive: true });
70
+ const tmp = `${file}.${String(process.pid)}.tmp`;
71
+ await writeFile(tmp, `${JSON.stringify({ version: 1, merchants: Object.fromEntries(kept) }, null, 2)}\n`);
72
+ await rename(tmp, file);
73
+ });
74
+ queue = run.catch(() => undefined);
75
+ return run.catch(() => undefined);
76
+ },
77
+ };
78
+ }
@@ -116,17 +116,94 @@ function fromMerchant(found, source, input, channel, typedDomain) {
116
116
  inference: null,
117
117
  };
118
118
  }
119
+ // A merchant's record by id; an override replaces the seed entry.
120
+ export function findMerchant(catalog, id) {
121
+ return (catalog.merchants.overrides.find((m) => m.id === id) ??
122
+ catalog.merchants.seed.find((m) => m.id === id));
123
+ }
119
124
  export function resolveMerchantById(catalog, id, channel) {
120
125
  const override = catalog.merchants.overrides.find((m) => m.id === id);
121
- const found = override ?? catalog.merchants.seed.find((m) => m.id === id);
126
+ const found = findMerchant(catalog, id);
122
127
  if (!found)
123
128
  return null;
124
129
  return fromMerchant(found, override ? 'override' : 'seed', found.name, channel, false);
125
130
  }
126
- // Used only when the merchant is unknown: curated data always wins over a
127
- // category supplied with the request.
131
+ // Curated merchant data always wins over a category supplied with the
132
+ // request; the caller's category wins over an unknown name or a different
133
+ // category word.
128
134
  export function withCallerCategory(merchant, category) {
129
- if (merchant.source !== 'none' || category === null)
135
+ if (category === null)
136
+ return merchant;
137
+ const otherWord = merchant.source === 'category' && merchant.category !== category;
138
+ if (merchant.source !== 'none' && !otherWord)
130
139
  return merchant;
131
140
  return { ...merchant, category, confidence: 'medium', source: 'caller' };
132
141
  }
142
+ const plainWords = (s) => s
143
+ .toLowerCase()
144
+ .replace(/[^a-z0-9]+/g, ' ')
145
+ .trim();
146
+ // A category from everyday words: an alias ("groceries") first, then the
147
+ // name, then the id, so "grocery" means supermarkets rather than the parent.
148
+ export function findCategory(catalog, text) {
149
+ const wanted = plainWords(text);
150
+ if (wanted === '')
151
+ return null;
152
+ // "grocery store" finds "grocery stores", and "drugstore" "drugstores".
153
+ const forms = [
154
+ wanted,
155
+ wanted.endsWith('s') ? wanted.slice(0, -1) : `${wanted}s`,
156
+ ];
157
+ for (const form of forms) {
158
+ const by = (key) => catalog.categories.find((c) => key(c).map(plainWords).includes(form))?.id;
159
+ const found = by((c) => c.aliases) ?? by((c) => [c.name]) ?? by((c) => [c.id]);
160
+ if (found !== undefined)
161
+ return found;
162
+ }
163
+ return null;
164
+ }
165
+ export function categoryResolution(catalog, category, channel) {
166
+ const found = catalog.categories.find((c) => c.id === category);
167
+ if (!found)
168
+ throw new Error(`unknown category '${category}'`);
169
+ return {
170
+ input: found.name,
171
+ merchantId: null,
172
+ resolvedName: found.name,
173
+ category,
174
+ likelyMcc: null,
175
+ accepts: null,
176
+ ambiguity: null,
177
+ channel,
178
+ confidence: 'high',
179
+ source: 'category',
180
+ bookedVia: [],
181
+ inference: null,
182
+ };
183
+ }
184
+ // Words around a place that don't name it: "a restaurant", "the grocery
185
+ // store", "a gas station near me".
186
+ const LEADING = /^(?:(?:a|an|the|my|any|some|our|your|this|that|local|nearby|nearest)\s+)+/i;
187
+ const TRAILING = /\s+(?:near me|nearby|around here|close by)$/i;
188
+ // What someone typed: a store earnrate knows, else a category word
189
+ // ("restaurants", "gas"), else unrecognized. Names are tried as given first,
190
+ // so a store like The Home Depot keeps its "The".
191
+ export function resolvePlace(catalog, text, channel) {
192
+ const attempt = (t) => {
193
+ const known = resolveMerchant(catalog, t, channel);
194
+ if (known.source !== 'none')
195
+ return known;
196
+ const category = findCategory(catalog, t);
197
+ return category === null
198
+ ? known
199
+ : categoryResolution(catalog, category, channel);
200
+ };
201
+ const asGiven = attempt(text);
202
+ if (asGiven.source !== 'none')
203
+ return asGiven;
204
+ const bare = text.trim().replace(TRAILING, '').replace(LEADING, '');
205
+ if (bare === '' || bare === text.trim())
206
+ return asGiven;
207
+ const found = attempt(bare);
208
+ return found.source === 'none' ? asGiven : found;
209
+ }
@@ -58,12 +58,15 @@ export const RuleSchema = z
58
58
  .optional(),
59
59
  valid_from: IsoDate.optional(),
60
60
  valid_to: IsoDate.optional(),
61
+ // The issuer's last day to activate a bonus that needs it.
62
+ activate_by: IsoDate.optional(),
61
63
  // Set when a rule isn't confirmed against the issuer's terms. The engine
62
64
  // warns when an unverified rule decides the winner.
63
65
  unverified: z.string().optional(),
64
66
  note: z.string().optional(),
65
67
  })
66
- .refine((r) => !r.valid_from || !r.valid_to || r.valid_from <= r.valid_to, 'valid_from must not be after valid_to');
68
+ .refine((r) => !r.valid_from || !r.valid_to || r.valid_from <= r.valid_to, 'valid_from must not be after valid_to')
69
+ .refine((r) => r.activate_by === undefined || r.requires?.activation === true, 'activate_by needs requires.activation');
67
70
  // The public sign-up bonus on the issuer's page. Offers vary by person and
68
71
  // channel, so a wallet can override it.
69
72
  export const WelcomeOfferSchema = z
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ import { KebabId } from "./common.js";
3
+ export const IssuerSchema = z.strictObject({
4
+ id: KebabId,
5
+ name: z.string().min(1), // as shown: "American Express"
6
+ search: z.array(z.string().min(1)).default([]), // other words people use
7
+ });
8
+ export const IssuersFileSchema = z.array(IssuerSchema).min(1);
@@ -49,5 +49,5 @@ export const WalletSchema = z.strictObject({
49
49
  version: z.number().int().positive().optional(),
50
50
  valuations: z.record(CurrencyId, Valuation).default({}),
51
51
  memberships: z.array(KebabId).default([]),
52
- cards: z.array(WalletCardSchema).min(1),
52
+ cards: z.array(WalletCardSchema).default([]),
53
53
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Which credit card earns the most at a merchant, from your cards or every card, and why",
5
5
  "keywords": [
6
6
  "credit cards",