earnrate 0.2.5 → 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.
- package/README.md +14 -11
- package/data/CHANGELOG.md +40 -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 -4
- 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 +50 -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/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/offers.js +17 -7
- package/dist/cli/run.js +14 -17
- package/dist/cli/wallet.js +8 -5
- 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 +4 -5
- package/dist/engine/compare.js +2 -0
- package/dist/engine/evaluate.js +2 -4
- package/dist/engine/explore.js +4 -36
- 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 +14 -15
- 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 +64 -28
- 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 +1 -1
- package/dist/import/analyze.js +2 -1
- package/dist/mcp/context.js +30 -9
- package/dist/mcp/inputs.js +1 -1
- package/dist/mcp/schemas.js +1 -0
- package/dist/mcp/server.js +7 -1
- package/dist/mcp/tools/account.js +44 -16
- package/dist/mcp/tools/answers.js +58 -19
- package/dist/mcp/tools/wallet.js +58 -24
- package/dist/merchants/cache.js +0 -10
- 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
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { emptyWallet } from "../../data/store.js";
|
|
2
3
|
import { parseWalletYaml, walletYaml } from "../../format/wallet-yaml.js";
|
|
4
|
+
import { listed } from "../../engine/text.js";
|
|
5
|
+
import { savedSettingsText } from "../../engine/wallet-summary.js";
|
|
3
6
|
import { fail } from "../context.js";
|
|
4
7
|
import { WalletChangeSchema } from "../schemas.js";
|
|
5
8
|
// A saved wallet on the hosted server has no file to copy or delete, so
|
|
6
9
|
// these do it.
|
|
7
10
|
export function registerAccountTools(server, catalog, store, ctx) {
|
|
11
|
+
const cardsText = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
|
|
12
|
+
// Everything a saved wallet holds, in words; empty when there's nothing.
|
|
13
|
+
const contents = (w) => [
|
|
14
|
+
...(w.cards.length > 0
|
|
15
|
+
? [
|
|
16
|
+
`${cardsText(w.cards.length)} (${w.cards.map((c) => c.id).join(', ')})`,
|
|
17
|
+
]
|
|
18
|
+
: []),
|
|
19
|
+
...savedSettingsText(catalog, w),
|
|
20
|
+
];
|
|
8
21
|
server.registerTool('export_wallet', {
|
|
9
22
|
title: 'Export the wallet',
|
|
10
23
|
description: 'The saved wallet as a wallet.yaml file the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, point values, cap usage and bonus progress. Never card numbers.',
|
|
11
24
|
inputSchema: z.object({}),
|
|
12
25
|
outputSchema: z.object({ yaml: z.string() }),
|
|
13
|
-
annotations: {
|
|
26
|
+
annotations: {
|
|
27
|
+
title: 'Export the wallet',
|
|
28
|
+
readOnlyHint: true,
|
|
29
|
+
openWorldHint: false,
|
|
30
|
+
},
|
|
14
31
|
}, async () => {
|
|
15
32
|
const w = await ctx.wallet();
|
|
16
33
|
if (typeof w === 'string')
|
|
@@ -23,7 +40,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
23
40
|
});
|
|
24
41
|
server.registerTool('import_wallet', {
|
|
25
42
|
title: 'Import a wallet',
|
|
26
|
-
description: 'Replaces the saved wallet with a wallet.yaml from the earnrate command line or earnrate.dev, when the user pastes or attaches one. Wallet files only: never statements or card numbers. If
|
|
43
|
+
description: 'Replaces the saved wallet with a wallet.yaml from the earnrate command line or earnrate.dev, when the user pastes or attaches one. Wallet files only: never statements or card numbers. If anything is already saved (cards, memberships or point values), it refuses unless replace is true: confirm with the user first.',
|
|
27
44
|
inputSchema: z.object({
|
|
28
45
|
yaml: z
|
|
29
46
|
.string()
|
|
@@ -33,10 +50,11 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
33
50
|
replace: z
|
|
34
51
|
.boolean()
|
|
35
52
|
.optional()
|
|
36
|
-
.describe('true to replace
|
|
53
|
+
.describe('true to replace what the wallet already holds, after the user confirms.'),
|
|
37
54
|
}),
|
|
38
55
|
outputSchema: WalletChangeSchema,
|
|
39
56
|
annotations: {
|
|
57
|
+
title: 'Import a wallet',
|
|
40
58
|
readOnlyHint: false,
|
|
41
59
|
destructiveHint: true,
|
|
42
60
|
idempotentHint: true,
|
|
@@ -55,17 +73,16 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
55
73
|
return fail(`Couldn't read the saved wallet to check what it holds (${e instanceof Error ? e.message : String(e)}). Ask the user, then call again with replace: true to overwrite it.`);
|
|
56
74
|
current = null;
|
|
57
75
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
: `Imported a wallet with ${cards(parsed.cards.length)}.`, { notes: false });
|
|
76
|
+
const before = current === null ? [] : contents(current);
|
|
77
|
+
if (before.length > 0 && replace !== true)
|
|
78
|
+
return fail(`This would replace what's saved: ${listed(before)}. Ask the user, then call again with replace: true.`);
|
|
79
|
+
return ctx.changed(await store.replace(parsed), before.length > 0
|
|
80
|
+
? `Replaced what was saved (${listed(before)}) with the imported wallet: ${listed(contents(parsed))}.`
|
|
81
|
+
: `Imported a wallet with ${listed(contents(parsed)) || 'nothing in it'}.`, { notes: false });
|
|
65
82
|
});
|
|
66
83
|
server.registerTool('delete_wallet', {
|
|
67
84
|
title: 'Delete the wallet',
|
|
68
|
-
description: "Deletes the saved wallet and its recovery code now and for good, and signs out every Claude app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: show it with list_wallet, offer export_wallet, and pass confirm and
|
|
85
|
+
description: "Deletes the saved wallet and its recovery code now and for good, and signs out every Claude app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: show it with list_wallet, offer export_wallet, and pass confirm, cards and saved only after the user confirms. It refuses if the wallet no longer holds what list_wallet showed, so a retried call can't delete a different wallet.",
|
|
69
86
|
inputSchema: z.object({
|
|
70
87
|
confirm: z
|
|
71
88
|
.literal(true)
|
|
@@ -75,20 +92,30 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
75
92
|
.int()
|
|
76
93
|
.nonnegative()
|
|
77
94
|
.describe('How many cards the wallet held when the user confirmed, from list_wallet: 0 for an empty wallet.'),
|
|
95
|
+
saved: z
|
|
96
|
+
.number()
|
|
97
|
+
.int()
|
|
98
|
+
.nonnegative()
|
|
99
|
+
.optional()
|
|
100
|
+
.describe('How many memberships and point values list_wallet showed, counted together: 0 if none.'),
|
|
78
101
|
}),
|
|
79
102
|
outputSchema: z.object({ deleted: z.boolean() }),
|
|
80
103
|
annotations: {
|
|
104
|
+
title: 'Delete the wallet',
|
|
81
105
|
readOnlyHint: false,
|
|
82
106
|
destructiveHint: true,
|
|
83
107
|
idempotentHint: true,
|
|
84
108
|
openWorldHint: false,
|
|
85
109
|
},
|
|
86
|
-
}, async ({ cards }) => {
|
|
110
|
+
}, async ({ cards, saved }) => {
|
|
87
111
|
// An unreadable wallet can still be deleted: the user confirmed it.
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
const loaded = await store.load().catch(() => undefined);
|
|
113
|
+
if (loaded !== undefined) {
|
|
114
|
+
const current = loaded ?? emptyWallet();
|
|
115
|
+
const settings = savedSettingsText(catalog, current);
|
|
116
|
+
if (current.cards.length !== cards || settings.length !== (saved ?? 0))
|
|
117
|
+
return fail(`This saved wallet holds ${listed(contents(current)) || 'nothing'}, not what the user confirmed (${cardsText(cards)}${saved === undefined ? '' : ` and ${String(saved)} memberships and point values`}); it may have changed, or be a different wallet. Show it with list_wallet, confirm again, and pass cards: ${String(current.cards.length)} and saved: ${String(settings.length)}.`);
|
|
118
|
+
}
|
|
92
119
|
await store.erase();
|
|
93
120
|
return {
|
|
94
121
|
content: [
|
|
@@ -106,6 +133,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
106
133
|
inputSchema: z.object({}),
|
|
107
134
|
outputSchema: z.object({ disconnected: z.boolean() }),
|
|
108
135
|
annotations: {
|
|
136
|
+
title: 'Switch wallets',
|
|
109
137
|
readOnlyHint: false,
|
|
110
138
|
destructiveHint: false,
|
|
111
139
|
idempotentHint: true,
|
|
@@ -13,6 +13,7 @@ import { formatCheatSheet } from "../../format/cheat-sheet.js";
|
|
|
13
13
|
import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../../format/explore.js";
|
|
14
14
|
import { formatOffers } from "../../format/offers.js";
|
|
15
15
|
import { formatRecommendation } from "../../format/recommendation.js";
|
|
16
|
+
import { resolvePlace, withCallerCategory } from "../../merchants/resolve.js";
|
|
16
17
|
import { ALL_CARDS_NOTE, fail } from "../context.js";
|
|
17
18
|
import { ChannelInput, DateInput, Merchant, settings, } from "../inputs.js";
|
|
18
19
|
import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, OfferComparisonSchema, PurchaseAnswerSchema, SpendingSimulationSchema, } from "../schemas.js";
|
|
@@ -46,16 +47,21 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
46
47
|
category: CategoryInput,
|
|
47
48
|
cards: NamedCards,
|
|
48
49
|
memberships: NamedMemberships,
|
|
50
|
+
valuation: CatalogFilters.valuation,
|
|
49
51
|
}),
|
|
50
52
|
outputSchema: PurchaseAnswerSchema,
|
|
51
53
|
annotations: {
|
|
54
|
+
title: 'Best card for a purchase',
|
|
52
55
|
readOnlyHint: true,
|
|
53
56
|
openWorldHint: deps.llm !== null,
|
|
54
57
|
},
|
|
55
|
-
}, async ({ merchant, amount, channel, date, category, cards, memberships, }) => {
|
|
56
|
-
const
|
|
57
|
-
if (typeof
|
|
58
|
-
return fail(
|
|
58
|
+
}, async ({ merchant, amount, channel, date, category, cards, memberships, valuation, }) => {
|
|
59
|
+
const held = await ctx.cardsFor(cards, memberships);
|
|
60
|
+
if (typeof held === 'string')
|
|
61
|
+
return fail(held);
|
|
62
|
+
const wallet = held !== null && valuation
|
|
63
|
+
? withValuationProfile(held, valuation)
|
|
64
|
+
: held;
|
|
59
65
|
const purchase = {
|
|
60
66
|
merchant,
|
|
61
67
|
amount: amount ?? null,
|
|
@@ -74,7 +80,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
74
80
|
amount: purchase.amount,
|
|
75
81
|
channel: purchase.channel,
|
|
76
82
|
date: purchase.date,
|
|
77
|
-
}, settings({ memberships }, await ctx.saved()), resolved.merchant);
|
|
83
|
+
}, settings({ memberships, valuation }, await ctx.saved()), resolved.merchant);
|
|
78
84
|
const allCards = { ...explored, ranked: explored.ranked.slice(0, TOP) };
|
|
79
85
|
const empty = recommend(catalog, EMPTY_WALLET, purchase, resolved.merchant);
|
|
80
86
|
return {
|
|
@@ -101,7 +107,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
101
107
|
content: [
|
|
102
108
|
{
|
|
103
109
|
type: 'text',
|
|
104
|
-
text: `${retry}${formatRecommendation(output, purchase, { all: true })}`,
|
|
110
|
+
text: `${retry}${ctx.fromCards(cards)}${formatRecommendation(output, purchase, { all: true })}`,
|
|
105
111
|
},
|
|
106
112
|
],
|
|
107
113
|
structuredContent: output,
|
|
@@ -116,7 +122,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
116
122
|
category: CategoryInput,
|
|
117
123
|
}),
|
|
118
124
|
outputSchema: MerchantResolutionSchema,
|
|
119
|
-
annotations: {
|
|
125
|
+
annotations: {
|
|
126
|
+
title: 'Resolve a merchant',
|
|
127
|
+
readOnlyHint: true,
|
|
128
|
+
openWorldHint: deps.llm !== null,
|
|
129
|
+
},
|
|
120
130
|
}, async ({ merchant, channel, category }) => {
|
|
121
131
|
const resolved = await ctx.resolve(merchant, channel ?? null, category ?? null);
|
|
122
132
|
const m = resolved.merchant;
|
|
@@ -151,7 +161,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
151
161
|
cards: NamedCards,
|
|
152
162
|
}),
|
|
153
163
|
outputSchema: OfferComparisonSchema,
|
|
154
|
-
annotations: {
|
|
164
|
+
annotations: {
|
|
165
|
+
title: 'Compare welcome offers',
|
|
166
|
+
readOnlyHint: true,
|
|
167
|
+
openWorldHint: false,
|
|
168
|
+
},
|
|
155
169
|
}, async (args) => {
|
|
156
170
|
const wallet = await ctx.cardsFor(args.cards, undefined);
|
|
157
171
|
if (typeof wallet === 'string')
|
|
@@ -174,7 +188,8 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
174
188
|
content: [
|
|
175
189
|
{
|
|
176
190
|
type: 'text',
|
|
177
|
-
text:
|
|
191
|
+
text: (wallet === null ? '' : ctx.fromCards(args.cards)) +
|
|
192
|
+
formatOffers(trimmed, (id) => currencyName(catalog, id), true) +
|
|
178
193
|
(more > 0
|
|
179
194
|
? `\nThe top ${String(TOP)} of ${String(comparison.offers.length)} offers${trimmed.offers.length > TOP ? `, plus ${String(trimmed.offers.length - TOP)} likely not eligible` : ''}. Narrow them with kind, noAnnualFee or business.\n`
|
|
180
195
|
: ''),
|
|
@@ -185,7 +200,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
185
200
|
});
|
|
186
201
|
server.registerTool('best_cards_for', {
|
|
187
202
|
title: 'Best cards for a purchase',
|
|
188
|
-
description: 'Ranks every card earnrate knows, not only the user\'s, for one purchase or category, each set up at its best (the right category pick, activated, cap unused), with its annual fee and what earning the rate takes. Use it for questions like "what\'s the best card for groceries?" or "which card should I get for Amazon?". It needs no wallet. For which of the user\'s own cards to use, call best_card_for_purchase instead. Mention the annual fee and any conditions alongside each card.',
|
|
203
|
+
description: 'Ranks every card earnrate knows that takes new applicants, not only the user\'s, for one purchase or category, each set up at its best (the right category pick, activated, cap unused), with its annual fee and what earning the rate takes. Use it for questions like "what\'s the best card for groceries?" or "which card should I get for Amazon?". It needs no wallet. For which of the user\'s own cards to use, call best_card_for_purchase instead. Mention the annual fee and any conditions alongside each card.',
|
|
189
204
|
inputSchema: z.object({
|
|
190
205
|
merchant: Merchant.optional(),
|
|
191
206
|
category: CatalogCategory.optional(),
|
|
@@ -198,17 +213,24 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
198
213
|
...CatalogFilters,
|
|
199
214
|
}),
|
|
200
215
|
outputSchema: ExplorationSchema,
|
|
201
|
-
annotations: {
|
|
216
|
+
annotations: {
|
|
217
|
+
title: 'Best cards for a purchase',
|
|
218
|
+
readOnlyHint: true,
|
|
219
|
+
openWorldHint: false,
|
|
220
|
+
},
|
|
202
221
|
}, async (args) => {
|
|
203
222
|
if (args.merchant === undefined && args.category === undefined)
|
|
204
223
|
return fail('Give a merchant or a category.');
|
|
224
|
+
const channel = args.channel ?? null;
|
|
205
225
|
const result = exploreCards(catalog, {
|
|
206
226
|
merchant: args.merchant ?? null,
|
|
207
|
-
category: args.
|
|
227
|
+
category: args.category ?? null,
|
|
208
228
|
amount: args.amount ?? null,
|
|
209
|
-
channel
|
|
229
|
+
channel,
|
|
210
230
|
date: deps.today(),
|
|
211
|
-
}, settings(args, await ctx.saved())
|
|
231
|
+
}, settings(args, await ctx.saved()), args.merchant === undefined
|
|
232
|
+
? undefined
|
|
233
|
+
: withCallerCategory(resolvePlace(catalog, args.merchant, channel), args.category ?? null));
|
|
212
234
|
const trimmed = { ...result, ranked: result.ranked.slice(0, TOP) };
|
|
213
235
|
return {
|
|
214
236
|
content: [
|
|
@@ -237,7 +259,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
237
259
|
...CatalogFilters,
|
|
238
260
|
}),
|
|
239
261
|
outputSchema: SpendingSimulationSchema,
|
|
240
|
-
annotations: {
|
|
262
|
+
annotations: {
|
|
263
|
+
title: 'Simulate a year of spending',
|
|
264
|
+
readOnlyHint: true,
|
|
265
|
+
openWorldHint: false,
|
|
266
|
+
},
|
|
241
267
|
}, async (args) => {
|
|
242
268
|
const given = args.spending ?? [];
|
|
243
269
|
if (given.some((l) => l.merchant === undefined && l.category === undefined))
|
|
@@ -269,7 +295,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
269
295
|
? withValuationProfile(wallet, args.valuation)
|
|
270
296
|
: wallet;
|
|
271
297
|
const result = simulateAdditions(catalog, valued, lines, options);
|
|
272
|
-
const text = formatAdditions(result, catalog, false)
|
|
298
|
+
const text = `${ctx.fromCards(args.cards)}${formatAdditions(result, catalog, false)}`;
|
|
273
299
|
return {
|
|
274
300
|
content: [{ type: 'text', text: [...notes, text].join('\n\n') }],
|
|
275
301
|
structuredContent: {
|
|
@@ -316,7 +342,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
316
342
|
valuation: CatalogFilters.valuation,
|
|
317
343
|
}),
|
|
318
344
|
outputSchema: CheatSheetSchema,
|
|
319
|
-
annotations: {
|
|
345
|
+
annotations: {
|
|
346
|
+
title: 'Cheat sheet',
|
|
347
|
+
readOnlyHint: true,
|
|
348
|
+
openWorldHint: false,
|
|
349
|
+
},
|
|
320
350
|
}, async (args) => {
|
|
321
351
|
const wallet = args.everyCard === true
|
|
322
352
|
? null
|
|
@@ -335,7 +365,12 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
335
365
|
? withValuationProfile(wallet, args.valuation)
|
|
336
366
|
: wallet, date);
|
|
337
367
|
return {
|
|
338
|
-
content: [
|
|
368
|
+
content: [
|
|
369
|
+
{
|
|
370
|
+
type: 'text',
|
|
371
|
+
text: `${wallet === null ? '' : ctx.fromCards(args.cards)}${formatCheatSheet(sheet)}`,
|
|
372
|
+
},
|
|
373
|
+
],
|
|
339
374
|
structuredContent: sheet,
|
|
340
375
|
};
|
|
341
376
|
});
|
|
@@ -349,7 +384,11 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
349
384
|
.describe('Card name or issuer, as the user said it.'),
|
|
350
385
|
}),
|
|
351
386
|
outputSchema: CardSearchSchema,
|
|
352
|
-
annotations: {
|
|
387
|
+
annotations: {
|
|
388
|
+
title: 'Search cards',
|
|
389
|
+
readOnlyHint: true,
|
|
390
|
+
openWorldHint: false,
|
|
391
|
+
},
|
|
353
392
|
}, async ({ query }) => {
|
|
354
393
|
const held = await deps.store.load().catch(() => null);
|
|
355
394
|
const result = searchCards(catalog, query ?? '', held, deps.today());
|
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { membershipsFor, needsAnniversary } from "../../engine/setup.js";
|
|
3
|
-
import { choiceName, currencyLabel, money, shortDate, words, } from "../../engine/text.js";
|
|
3
|
+
import { choiceName, currencyLabel, listed, money, shortDate, words, } from "../../engine/text.js";
|
|
4
|
+
import { membershipName } from "../../engine/memberships.js";
|
|
4
5
|
import { profileText, VALUATION_PROFILES } from "../../engine/profiles.js";
|
|
5
|
-
import { walletSummary } from "../../engine/wallet-summary.js";
|
|
6
|
+
import { optionName, walletSummary } from "../../engine/wallet-summary.js";
|
|
6
7
|
import { formatBooking, formatWalletSummary } from "../../format/wallet.js";
|
|
7
8
|
import { fail } from "../context.js";
|
|
8
9
|
import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
|
|
@@ -20,13 +21,14 @@ function startedText(started, now) {
|
|
|
20
21
|
: minutes < 48 * 60
|
|
21
22
|
? `${String(Math.floor(minutes / 60))} hour${minutes < 120 ? '' : 's'} ago`
|
|
22
23
|
: `on ${shortDate(started.slice(0, 10))}`;
|
|
23
|
-
return `
|
|
24
|
+
return `It was started ${ago}.`;
|
|
24
25
|
}
|
|
25
26
|
// What update_card changed, so the reply confirms the values it saved.
|
|
26
|
-
function changes(args) {
|
|
27
|
+
function changes(catalog, args) {
|
|
28
|
+
const card = catalog.cards.find((c) => c.id === args.cardId);
|
|
27
29
|
const out = [];
|
|
28
30
|
for (const [choice, options] of Object.entries(args.picks ?? {}))
|
|
29
|
-
out.push(`${choiceName(choice)} picks: ${options.map(words).join('
|
|
31
|
+
out.push(`${choiceName(choice)} picks: ${options.map((o) => (card ? optionName(catalog, card, choice, o) : words(o))).join('; ')}`);
|
|
30
32
|
if (args.checkoutLabel === null)
|
|
31
33
|
out.push('checkout name cleared');
|
|
32
34
|
else if (args.checkoutLabel !== undefined)
|
|
@@ -50,9 +52,13 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
50
52
|
const { catalog, store } = deps;
|
|
51
53
|
server.registerTool('list_wallet', {
|
|
52
54
|
title: 'List wallet',
|
|
53
|
-
description:
|
|
55
|
+
description: `Lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, which stores a store card works at, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
|
|
54
56
|
outputSchema: WalletSummarySchema,
|
|
55
|
-
annotations: {
|
|
57
|
+
annotations: {
|
|
58
|
+
title: 'List wallet',
|
|
59
|
+
readOnlyHint: true,
|
|
60
|
+
openWorldHint: false,
|
|
61
|
+
},
|
|
56
62
|
}, async () => {
|
|
57
63
|
const read = await ctx.readableWallet();
|
|
58
64
|
if (typeof read === 'string')
|
|
@@ -62,12 +68,14 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
62
68
|
...walletSummary(catalog, read.wallet, deps.today()),
|
|
63
69
|
started,
|
|
64
70
|
};
|
|
65
|
-
const when = started === null
|
|
71
|
+
const when = started === null
|
|
72
|
+
? `This is ${ctx.walletName}.\n`
|
|
73
|
+
: `This is ${ctx.walletName}. ${startedText(started, Date.now())}\n`;
|
|
66
74
|
return {
|
|
67
75
|
content: [
|
|
68
76
|
{
|
|
69
77
|
type: 'text',
|
|
70
|
-
text: `${when}${read.start
|
|
78
|
+
text: `${when}${read.start === null ? '' : `${read.start}\n`}${formatWalletSummary(summary)}`,
|
|
71
79
|
},
|
|
72
80
|
],
|
|
73
81
|
structuredContent: summary,
|
|
@@ -75,7 +83,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
75
83
|
});
|
|
76
84
|
server.registerTool('record_purchase', {
|
|
77
85
|
title: 'Record a purchase',
|
|
78
|
-
description:
|
|
86
|
+
description: `Records a purchase the user has already made, so spending caps and welcome-bonus progress stay accurate for future recommendations. It updates the user's saved wallet. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap and no open welcome bonus are acknowledged and nothing is written. For a merchant earnrate doesn't recognize it asks for category first, so caps aren't missed. ${ctx.walletUse}`,
|
|
79
87
|
inputSchema: z.object({
|
|
80
88
|
cardId: z
|
|
81
89
|
.string()
|
|
@@ -89,6 +97,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
89
97
|
}),
|
|
90
98
|
outputSchema: RecordResultSchema,
|
|
91
99
|
annotations: {
|
|
100
|
+
title: 'Record a purchase',
|
|
92
101
|
...WRITE,
|
|
93
102
|
destructiveHint: false,
|
|
94
103
|
idempotentHint: false,
|
|
@@ -119,7 +128,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
119
128
|
recorded: true,
|
|
120
129
|
booking,
|
|
121
130
|
bonus,
|
|
122
|
-
message: formatBooking(amount, cardId, booking, bonus)
|
|
131
|
+
message: `${formatBooking(amount, cardId, booking, bonus)} Saved in ${ctx.walletName}.`,
|
|
123
132
|
}
|
|
124
133
|
: {
|
|
125
134
|
recorded: false,
|
|
@@ -139,10 +148,15 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
139
148
|
});
|
|
140
149
|
server.registerTool('add_card', {
|
|
141
150
|
title: 'Add a card',
|
|
142
|
-
description:
|
|
151
|
+
description: `Adds a card the user holds to their wallet by its id from search_cards, creating the wallet if there isn't one. The reply says what the card still needs (category picks, memberships, activations); ask the user about those. Stores only the card's product id and settings, never card numbers. ${ctx.walletUse}`,
|
|
143
152
|
inputSchema: CardChange,
|
|
144
153
|
outputSchema: WalletChangeSchema,
|
|
145
|
-
annotations: {
|
|
154
|
+
annotations: {
|
|
155
|
+
title: 'Add a card',
|
|
156
|
+
...WRITE,
|
|
157
|
+
destructiveHint: false,
|
|
158
|
+
idempotentHint: false,
|
|
159
|
+
},
|
|
146
160
|
}, async (args) => {
|
|
147
161
|
const result = await store.addCard(cardInput(args), deps.today());
|
|
148
162
|
const started = !result.ok || !result.created
|
|
@@ -154,12 +168,17 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
154
168
|
});
|
|
155
169
|
server.registerTool('update_card', {
|
|
156
170
|
title: 'Update a card',
|
|
157
|
-
description:
|
|
171
|
+
description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, activated categories, cap spending so far (capSpent) or a welcome bonus to track. Picks replace the saved ones in each choice group you set (other groups are kept) for the current period. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+. ${ctx.walletUse}`,
|
|
158
172
|
inputSchema: CardChange,
|
|
159
173
|
outputSchema: WalletChangeSchema,
|
|
160
|
-
annotations: {
|
|
174
|
+
annotations: {
|
|
175
|
+
title: 'Update a card',
|
|
176
|
+
...WRITE,
|
|
177
|
+
destructiveHint: true,
|
|
178
|
+
idempotentHint: true,
|
|
179
|
+
},
|
|
161
180
|
}, async (args) => {
|
|
162
|
-
const what = changes(args);
|
|
181
|
+
const what = changes(catalog, args);
|
|
163
182
|
const card = catalog.cards.find((c) => c.id === args.cardId);
|
|
164
183
|
const unused = args.anniversary !== undefined && card && !needsAnniversary(card)
|
|
165
184
|
? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
|
|
@@ -168,17 +187,27 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
168
187
|
});
|
|
169
188
|
server.registerTool('remove_card', {
|
|
170
189
|
title: 'Remove a card',
|
|
171
|
-
description:
|
|
190
|
+
description: `Removes a card from the wallet, with its tracked cap usage and bonus progress. Memberships and point values stay, even when the last card goes. Only when the user says they no longer have it or don't want it considered. ${ctx.walletUse}`,
|
|
172
191
|
inputSchema: z.object({ cardId: CardId }),
|
|
173
192
|
outputSchema: WalletChangeSchema,
|
|
174
|
-
annotations: {
|
|
193
|
+
annotations: {
|
|
194
|
+
title: 'Remove a card',
|
|
195
|
+
...WRITE,
|
|
196
|
+
destructiveHint: true,
|
|
197
|
+
idempotentHint: true,
|
|
198
|
+
},
|
|
175
199
|
}, async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }));
|
|
176
200
|
server.registerTool('set_memberships', {
|
|
177
201
|
title: 'Set memberships',
|
|
178
|
-
description:
|
|
202
|
+
description: `Sets the memberships that unlock some cards' rates, such as amazon-prime for Prime Visa's 5% or a Bank of America Preferred Rewards tier, and costco for a Costco membership (so spending answers don't count its cost against Costco Anywhere). Replaces the whole list, so include the ones to keep. It saves them even with no cards saved, and every answer then uses them. ${ctx.walletUse}`,
|
|
179
203
|
inputSchema: z.object({ memberships: inputs.AllMemberships }),
|
|
180
204
|
outputSchema: WalletChangeSchema,
|
|
181
|
-
annotations: {
|
|
205
|
+
annotations: {
|
|
206
|
+
title: 'Set memberships',
|
|
207
|
+
...WRITE,
|
|
208
|
+
destructiveHint: true,
|
|
209
|
+
idempotentHint: true,
|
|
210
|
+
},
|
|
182
211
|
}, async ({ memberships }) => {
|
|
183
212
|
const result = await store.setMemberships(memberships);
|
|
184
213
|
const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
|
|
@@ -187,12 +216,12 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
187
216
|
}));
|
|
188
217
|
const unused = memberships.filter((m) => !used.has(m));
|
|
189
218
|
return ctx.changed(result, `${memberships.length > 0
|
|
190
|
-
? `Memberships set to ${memberships.join(', ')}.`
|
|
191
|
-
: 'Memberships cleared.'}${unused.length > 0 ? ` ${unused.
|
|
219
|
+
? `Memberships set to ${memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')}.`
|
|
220
|
+
: 'Memberships cleared.'}${unused.length > 0 ? ` ${listed(unused.map((m) => membershipName(catalog, m)))} ${unused.length === 1 ? "doesn't" : "don't"} change any saved card's rates yet; kept for cards that use ${unused.length === 1 ? 'it' : 'them'}.` : ''}`, { notes: false });
|
|
192
221
|
});
|
|
193
222
|
server.registerTool('set_point_values', {
|
|
194
223
|
title: 'Set point values',
|
|
195
|
-
description: `Saves what the user's points are worth to them, in cents per point, in their saved wallet; every answer then compares cards at those values unless a valuation profile is asked for.
|
|
224
|
+
description: `Saves what the user's points are worth to them, in cents per point, in their saved wallet; every answer then compares cards at those values unless a valuation profile is asked for. No saved cards are needed. Use it when the user says what their points are worth ("I value Chase points at 1.5¢") or that they transfer points to airlines and hotels (profile points-heavy). Values change only the currencies given; null goes back to earnrate's default. Currencies: ${catalog.currencies.map((c) => c.id).join(', ')}. ${ctx.walletUse}`,
|
|
196
225
|
inputSchema: z.object({
|
|
197
226
|
values: z
|
|
198
227
|
.record(z.string(), z.number().nullable())
|
|
@@ -204,7 +233,12 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
204
233
|
.describe(`A preset: flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}). Values given too win over it.`),
|
|
205
234
|
}),
|
|
206
235
|
outputSchema: WalletChangeSchema,
|
|
207
|
-
annotations: {
|
|
236
|
+
annotations: {
|
|
237
|
+
title: 'Set point values',
|
|
238
|
+
...WRITE,
|
|
239
|
+
destructiveHint: true,
|
|
240
|
+
idempotentHint: true,
|
|
241
|
+
},
|
|
208
242
|
}, async ({ values, profile }) => {
|
|
209
243
|
const changes = {
|
|
210
244
|
...(profile ? VALUATION_PROFILES[profile] : {}),
|
package/dist/merchants/cache.js
CHANGED
|
@@ -38,16 +38,6 @@ export function merchantCachePath(env = process.env) {
|
|
|
38
38
|
return (env[MERCHANT_CACHE_ENV_VAR] ??
|
|
39
39
|
path.join(homedir(), CONFIG_DIR, 'merchant-cache.json'));
|
|
40
40
|
}
|
|
41
|
-
export function memoryCache() {
|
|
42
|
-
const entries = new Map();
|
|
43
|
-
return {
|
|
44
|
-
get: (key) => entries.get(key),
|
|
45
|
-
set: (key, value) => {
|
|
46
|
-
entries.set(key, value);
|
|
47
|
-
return Promise.resolve();
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
41
|
// A cache file that can't be read is left alone rather than overwritten, so a
|
|
52
42
|
// hand edit gone wrong doesn't lose every saved classification.
|
|
53
43
|
export async function openMerchantCache(file) {
|
|
@@ -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);
|