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.
- package/README.md +25 -18
- package/data/CHANGELOG.md +56 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +14 -6
- package/data/cards/amex-blue-cash-everyday.yaml +12 -3
- package/data/cards/amex-blue-cash-preferred.yaml +17 -3
- package/data/cards/amex-gold.yaml +19 -3
- package/data/cards/amex-platinum.yaml +19 -3
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +10 -2
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +14 -3
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +4 -1
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -5
- package/data/cards/bilt-obsidian.yaml +4 -3
- package/data/cards/capital-one-kohls-card.yaml +11 -2
- package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
- package/data/cards/capital-one-savor.yaml +32 -10
- package/data/cards/capital-one-venture-x.yaml +19 -4
- package/data/cards/capital-one-venture.yaml +19 -5
- package/data/cards/chase-aeroplan.yaml +15 -3
- package/data/cards/chase-freedom-flex.yaml +49 -10
- package/data/cards/chase-freedom-unlimited.yaml +6 -2
- package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
- package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -2
- package/data/cards/chase-ihg-one-rewards.yaml +8 -2
- package/data/cards/chase-ink-business-cash.yaml +7 -3
- package/data/cards/chase-ink-business-preferred.yaml +9 -2
- package/data/cards/chase-prime-visa.yaml +24 -5
- package/data/cards/chase-sapphire-preferred.yaml +59 -6
- package/data/cards/chase-sapphire-reserve.yaml +12 -2
- package/data/cards/chase-southwest-performance-business.yaml +22 -4
- package/data/cards/chase-southwest-plus.yaml +13 -3
- package/data/cards/chase-southwest-premier-business.yaml +22 -5
- package/data/cards/chase-southwest-premier.yaml +13 -3
- package/data/cards/chase-southwest-priority.yaml +18 -2
- package/data/cards/chase-united-business.yaml +5 -2
- package/data/cards/chase-united-club-business.yaml +2 -2
- package/data/cards/chase-united-club.yaml +16 -3
- package/data/cards/chase-united-explorer.yaml +5 -2
- package/data/cards/chase-united-gateway.yaml +2 -2
- package/data/cards/chase-united-quest.yaml +12 -3
- package/data/cards/citi-aadvantage-business.yaml +10 -4
- package/data/cards/citi-aadvantage-executive.yaml +8 -8
- package/data/cards/citi-aadvantage-mileup.yaml +7 -4
- package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
- package/data/cards/citi-costco-anywhere-visa-business.yaml +5 -2
- package/data/cards/citi-costco-anywhere-visa.yaml +6 -3
- package/data/cards/citi-custom-cash.yaml +43 -12
- package/data/cards/citi-double-cash.yaml +9 -3
- package/data/cards/citi-my-best-buy-visa.yaml +6 -3
- package/data/cards/citi-strata-elite.yaml +12 -2
- package/data/cards/citi-strata-premier.yaml +24 -2
- package/data/cards/sofi-unlimited-2.yaml +17 -3
- package/data/cards/td-bank-nordstrom-credit-card.yaml +12 -3
- package/data/cards/td-bank-nordstrom-visa.yaml +19 -3
- package/data/cards/td-bank-target-circle-card.yaml +13 -4
- package/data/cards/usbank-cash-plus.yaml +29 -7
- package/data/cards/wells-fargo-active-cash.yaml +9 -2
- package/data/cards/wells-fargo-autograph.yaml +31 -7
- package/data/issuers.yaml +32 -0
- package/data/merchants/seed.yaml +7 -0
- package/dist/brand.js +1 -0
- package/dist/cli/bin.js +10 -1
- package/dist/cli/cheatsheet.js +10 -5
- package/dist/cli/explore.js +6 -13
- package/dist/cli/import.js +2 -2
- package/dist/cli/missed.js +65 -0
- package/dist/cli/offers.js +17 -7
- package/dist/cli/run.js +29 -17
- package/dist/cli/todo.js +62 -0
- package/dist/cli/wallet.js +9 -6
- package/dist/data/file-store.js +6 -7
- package/dist/data/integrity.js +4 -0
- package/dist/data/load.js +4 -1
- package/dist/data/store.js +6 -22
- package/dist/data/wallet-edit.js +38 -13
- package/dist/engine/cheat-sheet.js +14 -6
- package/dist/engine/checklist.js +152 -0
- package/dist/engine/compare.js +2 -0
- package/dist/engine/evaluate.js +16 -11
- package/dist/engine/explore.js +6 -38
- package/dist/engine/memberships.js +1 -1
- package/dist/engine/offers.js +2 -1
- package/dist/engine/periods.js +18 -0
- package/dist/engine/recommend.js +21 -20
- package/dist/engine/rule-text.js +47 -0
- package/dist/engine/setup.js +14 -24
- package/dist/engine/simulate.js +7 -6
- package/dist/engine/text.js +13 -1
- package/dist/engine/wallet-change.js +10 -12
- package/dist/engine/wallet-summary.js +80 -65
- package/dist/format/card-text.js +7 -20
- package/dist/format/explore.js +2 -2
- package/dist/format/offers.js +2 -2
- package/dist/format/recommendation.js +2 -2
- package/dist/format/wallet.js +7 -1
- package/dist/import/analyze.js +2 -1
- package/dist/mcp/context.js +38 -13
- package/dist/mcp/inputs.js +1 -1
- package/dist/mcp/schemas.js +9 -0
- package/dist/mcp/serve.js +4 -0
- package/dist/mcp/server.js +8 -2
- package/dist/mcp/tools/account.js +44 -16
- package/dist/mcp/tools/answers.js +58 -19
- package/dist/mcp/tools/wallet.js +64 -25
- package/dist/merchants/cache.js +0 -10
- package/dist/merchants/missed.js +78 -0
- package/dist/merchants/resolve.js +81 -4
- package/dist/schema/card.js +4 -1
- package/dist/schema/issuer.js +8 -0
- package/dist/schema/wallet.js +1 -1
- 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 =
|
|
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
|
-
//
|
|
127
|
-
// category
|
|
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 (
|
|
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
|
+
}
|
package/dist/schema/card.js
CHANGED
|
@@ -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);
|
package/dist/schema/wallet.js
CHANGED
|
@@ -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).
|
|
52
|
+
cards: z.array(WalletCardSchema).default([]),
|
|
53
53
|
});
|