earnrate 0.2.11 → 0.2.13
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 +37 -12
- package/data/CHANGELOG.md +291 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +1 -0
- package/data/cards/amex-blue-cash-everyday.yaml +29 -2
- package/data/cards/amex-blue-cash-preferred.yaml +26 -0
- package/data/cards/amex-business-gold.yaml +169 -0
- package/data/cards/amex-business-platinum.yaml +184 -0
- package/data/cards/amex-delta-skymiles-blue.yaml +91 -0
- package/data/cards/amex-delta-skymiles-gold.yaml +29 -1
- package/data/cards/amex-delta-skymiles-platinum.yaml +35 -1
- package/data/cards/amex-delta-skymiles-reserve.yaml +35 -1
- package/data/cards/amex-gold.yaml +53 -0
- package/data/cards/amex-hilton-honors-aspire.yaml +25 -1
- package/data/cards/amex-hilton-honors-business.yaml +106 -0
- package/data/cards/amex-hilton-honors-surpass.yaml +16 -1
- package/data/cards/amex-hilton-honors.yaml +2 -0
- package/data/cards/amex-marriott-bonvoy-bevy.yaml +2 -0
- package/data/cards/amex-marriott-bonvoy-brilliant.yaml +17 -1
- package/data/cards/amex-marriott-bonvoy-business.yaml +116 -0
- package/data/cards/amex-platinum.yaml +111 -0
- package/data/cards/apple-card.yaml +76 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +1 -0
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +2 -0
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +1 -0
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +34 -1
- package/data/cards/bank-of-america-premium-rewards.yaml +134 -0
- package/data/cards/bank-of-america-travel-rewards.yaml +123 -0
- package/data/cards/bank-of-america-unlimited-cash-rewards.yaml +83 -0
- package/data/cards/barclays-jetblue-plus.yaml +86 -0
- package/data/cards/bilt-blue.yaml +60 -0
- package/data/cards/bilt-obsidian.yaml +13 -2
- package/data/cards/bilt-palladium.yaml +86 -0
- package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
- package/data/cards/capital-one-kohls-card.yaml +1 -0
- package/data/cards/capital-one-kohls-rewards-visa.yaml +1 -0
- package/data/cards/capital-one-quicksilver.yaml +2 -0
- package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -0
- package/data/cards/capital-one-savor.yaml +2 -0
- package/data/cards/capital-one-spark-cash-plus.yaml +80 -0
- package/data/cards/capital-one-venture-x-business.yaml +124 -0
- package/data/cards/capital-one-venture-x.yaml +32 -2
- package/data/cards/capital-one-venture.yaml +2 -0
- package/data/cards/chase-aeroplan.yaml +16 -0
- package/data/cards/chase-freedom-flex.yaml +1 -0
- package/data/cards/chase-freedom-rise.yaml +29 -0
- package/data/cards/chase-freedom-unlimited.yaml +1 -0
- package/data/cards/chase-ihg-one-rewards-business.yaml +28 -1
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +18 -0
- package/data/cards/chase-ihg-one-rewards-premier.yaml +16 -0
- package/data/cards/chase-ihg-one-rewards.yaml +1 -0
- package/data/cards/chase-ink-business-cash.yaml +14 -0
- package/data/cards/chase-ink-business-preferred.yaml +1 -0
- package/data/cards/chase-ink-business-unlimited.yaml +80 -0
- package/data/cards/chase-marriott-bonvoy-bold.yaml +1 -0
- package/data/cards/chase-marriott-bonvoy-boundless.yaml +2 -0
- package/data/cards/chase-prime-visa.yaml +1 -0
- package/data/cards/chase-sapphire-preferred.yaml +12 -0
- package/data/cards/chase-sapphire-reserve.yaml +45 -4
- package/data/cards/chase-southwest-performance-business.yaml +2 -0
- package/data/cards/chase-southwest-plus.yaml +2 -0
- package/data/cards/chase-southwest-premier-business.yaml +2 -0
- package/data/cards/chase-southwest-premier.yaml +2 -0
- package/data/cards/chase-southwest-priority.yaml +2 -0
- package/data/cards/chase-united-business.yaml +33 -0
- package/data/cards/chase-united-club-business.yaml +41 -0
- package/data/cards/chase-united-club.yaml +41 -0
- package/data/cards/chase-united-explorer.yaml +33 -0
- package/data/cards/chase-united-gateway.yaml +1 -0
- package/data/cards/chase-united-quest.yaml +41 -0
- package/data/cards/citi-aadvantage-business.yaml +2 -0
- package/data/cards/citi-aadvantage-executive.yaml +28 -1
- package/data/cards/citi-aadvantage-globe.yaml +2 -0
- package/data/cards/citi-aadvantage-mileup.yaml +2 -0
- package/data/cards/citi-aadvantage-platinum-select.yaml +2 -0
- package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
- package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
- package/data/cards/citi-custom-cash.yaml +1 -0
- package/data/cards/citi-double-cash.yaml +1 -0
- package/data/cards/citi-my-best-buy-visa.yaml +1 -0
- package/data/cards/citi-strata-elite.yaml +21 -1
- package/data/cards/citi-strata-premier.yaml +1 -0
- package/data/cards/citi-strata.yaml +164 -0
- package/data/cards/discover-it-cash-back.yaml +22 -3
- package/data/cards/fidelity-rewards-visa-signature.yaml +60 -0
- package/data/cards/navy-federal-cash-rewards-plus.yaml +61 -0
- package/data/cards/navy-federal-cash-rewards.yaml +60 -0
- package/data/cards/navy-federal-more-rewards-amex.yaml +92 -0
- package/data/cards/paypal-cashback-mastercard.yaml +52 -0
- package/data/cards/penfed-power-cash-rewards.yaml +47 -0
- package/data/cards/sams-club-mastercard.yaml +68 -0
- package/data/cards/sofi-unlimited-2.yaml +1 -0
- package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
- package/data/cards/td-bank-target-circle-card.yaml +1 -0
- package/data/cards/usbank-altitude-connect.yaml +156 -0
- package/data/cards/usbank-altitude-go.yaml +124 -0
- package/data/cards/usbank-cash-plus.yaml +1 -0
- package/data/cards/usbank-smartly.yaml +107 -0
- package/data/cards/venmo-credit-card.yaml +151 -0
- package/data/cards/verizon-visa-card.yaml +122 -0
- package/data/cards/wells-fargo-active-cash.yaml +1 -0
- package/data/cards/wells-fargo-autograph-journey.yaml +73 -0
- package/data/cards/wells-fargo-autograph.yaml +1 -0
- package/data/categories.yaml +3 -1
- package/data/currencies.yaml +48 -0
- package/data/issuers.yaml +44 -1
- package/data/memberships.yaml +45 -0
- package/data/merchants/overrides.yaml +947 -1
- package/data/merchants/seed.yaml +1916 -62
- package/dist/cli/args.js +18 -0
- package/dist/cli/explore.js +10 -1
- package/dist/cli/import.js +2 -2
- package/dist/cli/run.js +4 -1
- package/dist/cli/wallet.js +37 -3
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +59 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +28 -7
- package/dist/engine/cheat-sheet.js +17 -5
- package/dist/engine/checklist.js +38 -6
- package/dist/engine/credits.js +65 -0
- package/dist/engine/evaluate.js +57 -18
- package/dist/engine/explore.js +70 -7
- package/dist/engine/intro.js +36 -0
- package/dist/engine/offers.js +2 -1
- package/dist/engine/periods.js +10 -3
- package/dist/engine/recommend.js +101 -20
- package/dist/engine/record.js +43 -1
- package/dist/engine/rotation.js +3 -0
- package/dist/engine/rule-text.js +28 -3
- package/dist/engine/setup.js +20 -3
- package/dist/engine/simulate.js +10 -1
- package/dist/engine/text.js +107 -0
- package/dist/engine/wallet-change.js +57 -4
- package/dist/engine/wallet-summary.js +54 -6
- package/dist/format/card-art.js +21 -0
- package/dist/format/card-text.js +4 -3
- package/dist/format/cards.js +1 -0
- package/dist/format/cheat-sheet.js +2 -0
- package/dist/format/explore.js +20 -4
- package/dist/format/recommendation.js +6 -2
- package/dist/format/wallet-yaml.js +1 -0
- package/dist/format/wallet.js +15 -5
- package/dist/import/analyze.js +8 -2
- package/dist/mcp/inputs.js +13 -0
- package/dist/mcp/prompts.js +9 -1
- package/dist/mcp/schemas.js +87 -1
- package/dist/mcp/tools/account.js +3 -1
- package/dist/mcp/tools/answers.js +9 -4
- package/dist/mcp/tools/wallet.js +20 -8
- package/dist/merchants/resolve.js +8 -0
- package/dist/schema/card.js +60 -3
- package/dist/schema/common.js +1 -1
- package/dist/schema/issuer.js +4 -0
- package/dist/schema/merchant.js +3 -0
- package/dist/schema/wallet.js +11 -0
- package/package.json +12 -3
|
@@ -4,7 +4,7 @@ import { addMonths } from "./bonus.js";
|
|
|
4
4
|
import { capByKey } from "./evaluate.js";
|
|
5
5
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
6
6
|
import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
|
|
7
|
-
import { idList } from "./text.js";
|
|
7
|
+
import { CREDIT_PERIODS, idList, money } from "./text.js";
|
|
8
8
|
const failed = (error) => ({ ok: false, error });
|
|
9
9
|
function findCatalogCard(catalog, id, today) {
|
|
10
10
|
const card = catalog.cards.find((c) => c.id === id);
|
|
@@ -22,6 +22,8 @@ function isAnniversary(text) {
|
|
|
22
22
|
const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
|
|
23
23
|
return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
|
|
24
24
|
}
|
|
25
|
+
const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d) &&
|
|
26
|
+
new Date(`${d}T00:00:00Z`).toISOString().startsWith(d);
|
|
25
27
|
function pickKey(card, today) {
|
|
26
28
|
const period = choicePeriod(card);
|
|
27
29
|
return period === null ? null : periodKey(period, today, null);
|
|
@@ -44,6 +46,15 @@ export function cardFields(card, held, input, today) {
|
|
|
44
46
|
}
|
|
45
47
|
fields.anniversary = input.anniversary;
|
|
46
48
|
}
|
|
49
|
+
if (input.opened === null)
|
|
50
|
+
fields.opened = null;
|
|
51
|
+
else if (input.opened !== undefined) {
|
|
52
|
+
if (!isDate(input.opened))
|
|
53
|
+
problems.push(`"${input.opened}" isn't a date. Use YYYY-MM-DD.`);
|
|
54
|
+
else if (input.opened > today)
|
|
55
|
+
problems.push("The account's opening date is in the future.");
|
|
56
|
+
fields.opened = input.opened;
|
|
57
|
+
}
|
|
47
58
|
if (input.activated !== undefined) {
|
|
48
59
|
const needing = card.rules
|
|
49
60
|
.filter((r) => r.requires?.activation)
|
|
@@ -138,12 +149,44 @@ export function cardFields(card, held, input, today) {
|
|
|
138
149
|
}
|
|
139
150
|
fields.milestone_usage = usage;
|
|
140
151
|
}
|
|
152
|
+
if (input.enrolled !== undefined) {
|
|
153
|
+
const needing = card.credits
|
|
154
|
+
.filter((c) => c.enroll === true)
|
|
155
|
+
.map((c) => c.id);
|
|
156
|
+
for (const id of input.enrolled) {
|
|
157
|
+
if (!needing.includes(id))
|
|
158
|
+
problems.push(needing.length > 0
|
|
159
|
+
? `'${id}' isn't a ${card.id} credit that needs enrolling. Those are: ${idList(needing)}.`
|
|
160
|
+
: `${card.id} has no credits that need enrolling.`);
|
|
161
|
+
}
|
|
162
|
+
fields.enrolled = [...new Set(input.enrolled)];
|
|
163
|
+
}
|
|
164
|
+
if (input.creditUsed !== undefined) {
|
|
165
|
+
const usage = { ...held?.credit_usage };
|
|
166
|
+
const anniversary = fields.anniversary ?? held?.anniversary ?? null;
|
|
167
|
+
for (const [id, used] of Object.entries(input.creditUsed)) {
|
|
168
|
+
const credit = card.credits.find((c) => c.id === id);
|
|
169
|
+
if (!credit) {
|
|
170
|
+
problems.push(card.credits.length > 0
|
|
171
|
+
? `${card.id} has no credit '${id}'. Its credits are: ${idList(card.credits.map((c) => c.id))}.`
|
|
172
|
+
: `${card.id} has no merchant credits.`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!(used >= 0 && used <= credit.amount))
|
|
176
|
+
problems.push(`The '${id}' credit is ${money(credit.amount)} ${CREDIT_PERIODS[credit.period]}, so what's used must be $0 to ${money(credit.amount)}.`);
|
|
177
|
+
const period = periodKey(credit.period, today, anniversary);
|
|
178
|
+
if (period === null) {
|
|
179
|
+
problems.push(`The '${id}' credit resets on the card's anniversary; give the anniversary (MM-DD) too.`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
usage[id] = { period, used };
|
|
183
|
+
}
|
|
184
|
+
fields.credit_usage = usage;
|
|
185
|
+
}
|
|
141
186
|
if (input.bonus === null)
|
|
142
187
|
fields.bonus = null;
|
|
143
188
|
else if (input.bonus !== undefined) {
|
|
144
189
|
const b = input.bonus;
|
|
145
|
-
const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d) &&
|
|
146
|
-
new Date(`${d}T00:00:00Z`).toISOString().startsWith(d);
|
|
147
190
|
if (!isDate(b.opened))
|
|
148
191
|
problems.push(`"${b.opened}" isn't a date. Use YYYY-MM-DD.`);
|
|
149
192
|
else if (b.opened > today)
|
|
@@ -177,6 +220,12 @@ export function cardFields(card, held, input, today) {
|
|
|
177
220
|
...(dollars === undefined ? {} : { dollars }),
|
|
178
221
|
};
|
|
179
222
|
}
|
|
223
|
+
const opened = fields.opened === undefined ? (held?.opened ?? null) : fields.opened;
|
|
224
|
+
const bonusOpened = fields.bonus === undefined
|
|
225
|
+
? (held?.bonus?.opened ?? null)
|
|
226
|
+
: fields.bonus?.opened;
|
|
227
|
+
if (opened !== null && bonusOpened != null && opened !== bonusOpened)
|
|
228
|
+
problems.push(`The card's opening date (${opened}) and its welcome bonus's (${bonusOpened}) differ; give the same date for both.`);
|
|
180
229
|
return problems.length > 0 ? problems.join('\n') : fields;
|
|
181
230
|
}
|
|
182
231
|
const newHolding = (id) => ({
|
|
@@ -184,8 +233,10 @@ const newHolding = (id) => ({
|
|
|
184
233
|
cap_usage: {},
|
|
185
234
|
activated: [],
|
|
186
235
|
});
|
|
187
|
-
const withFields = ({ bonus, checkout_label, ...held }, f) => ({
|
|
236
|
+
const withFields = ({ bonus, checkout_label, opened, ...held }, f) => ({
|
|
188
237
|
...held,
|
|
238
|
+
...(opened !== undefined && f.opened === undefined ? { opened } : {}),
|
|
239
|
+
...(f.opened ? { opened: f.opened } : {}),
|
|
189
240
|
...(bonus && f.bonus === undefined ? { bonus } : {}),
|
|
190
241
|
...(checkout_label !== undefined && f.checkout_label === undefined
|
|
191
242
|
? { checkout_label }
|
|
@@ -198,6 +249,8 @@ const withFields = ({ bonus, checkout_label, ...held }, f) => ({
|
|
|
198
249
|
...(f.milestone_usage === undefined
|
|
199
250
|
? {}
|
|
200
251
|
: { milestone_usage: f.milestone_usage }),
|
|
252
|
+
...(f.enrolled === undefined ? {} : { enrolled: f.enrolled }),
|
|
253
|
+
...(f.credit_usage === undefined ? {} : { credit_usage: f.credit_usage }),
|
|
201
254
|
...(f.bonus ? { bonus: f.bonus } : {}),
|
|
202
255
|
});
|
|
203
256
|
// `wallet` is null when there isn't one yet; adding a card starts it.
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { bonusStatus } from "./bonus.js";
|
|
2
2
|
import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
|
|
3
|
+
import { introUntil, introWindows, openedOn } from "./intro.js";
|
|
3
4
|
import { membershipName } from "./memberships.js";
|
|
4
5
|
import { milestoneSummaries } from "./milestones.js";
|
|
5
|
-
import {
|
|
6
|
+
import { creditStates } from "./credits.js";
|
|
7
|
+
import { creditWhere, optionName, ruleParts } from "./rule-text.js";
|
|
6
8
|
import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
|
|
7
9
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
8
10
|
import { rotationGap } from "./rotation.js";
|
|
9
11
|
import { activationRules } from "./setup.js";
|
|
10
|
-
import { CAP_PERIODS, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
|
|
12
|
+
import { CAP_PERIODS, categoryReadsOn, creditBack, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, earnText, listed, PAYMENT_PHRASES, shortDate, wholeDollars, words, } from "./text.js";
|
|
11
13
|
import { cardValuation, currencyValuation } from "./valuation.js";
|
|
12
14
|
const matchKeys = (rule) => rule.match.choice !== undefined
|
|
13
15
|
? []
|
|
@@ -61,10 +63,12 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
61
63
|
if (replaced)
|
|
62
64
|
continue;
|
|
63
65
|
const pending = r.requires?.activation && !isActivated(card, held, r.id);
|
|
66
|
+
const payment = r.requires?.payment;
|
|
64
67
|
const key = [
|
|
65
68
|
`${earnText(catalog, r.rate, currency)} on`,
|
|
66
69
|
capLimit(card, shown, r),
|
|
67
70
|
pending ? ', once activated' : '',
|
|
71
|
+
payment === undefined ? '' : ` ${PAYMENT_PHRASES[payment]}`,
|
|
68
72
|
].join('|');
|
|
69
73
|
const whats = lines.get(key) ?? [];
|
|
70
74
|
for (const part of ruleParts(catalog, r))
|
|
@@ -74,12 +78,13 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
74
78
|
}
|
|
75
79
|
return [
|
|
76
80
|
...[...lines].map(([key, whats]) => {
|
|
77
|
-
const [head = '', limit = '', activate = ''] = key.split('|');
|
|
81
|
+
const [head = '', limit = '', activate = '', paid = ''] = key.split('|');
|
|
78
82
|
const after = `${limit}${activate}`;
|
|
79
|
-
return `${head} ${listed(whats)}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
|
|
83
|
+
return `${head} ${listed(whats)}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
|
|
80
84
|
}),
|
|
81
85
|
...(shown.some((r) => r.match.any &&
|
|
82
86
|
r.geography !== 'foreign_only' &&
|
|
87
|
+
r.requires?.payment === undefined &&
|
|
83
88
|
r.rate > card.base_rate)
|
|
84
89
|
? []
|
|
85
90
|
: [`${earnText(catalog, card.base_rate, currency)} on everything else`]),
|
|
@@ -98,8 +103,13 @@ export function activationsDue(catalog, card, held, today) {
|
|
|
98
103
|
seen.add(g);
|
|
99
104
|
const rules = card.rules.filter((r) => group.includes(r.id));
|
|
100
105
|
const first = rules[0];
|
|
106
|
+
const categories = rules.flatMap((r) => r.match.categories ?? []);
|
|
107
|
+
const servicesOnly = categories.length > 0 &&
|
|
108
|
+
rules.every((r) => (r.match.merchants ?? []).length === 0) &&
|
|
109
|
+
categories.every((c) => categoryReadsOn(catalog, c));
|
|
101
110
|
out.push({
|
|
102
|
-
what: rules.
|
|
111
|
+
what: listed([...new Set(rules.flatMap((r) => ruleParts(catalog, r)))]),
|
|
112
|
+
preposition: servicesOnly ? 'on' : 'at',
|
|
103
113
|
from: first?.valid_from ?? null,
|
|
104
114
|
to: first?.valid_to ?? null,
|
|
105
115
|
activateBy: first?.activate_by ?? null,
|
|
@@ -144,7 +154,7 @@ function capSummaries(catalog, card, held, today) {
|
|
|
144
154
|
const shared = card.caps.find((c) => c.id === key);
|
|
145
155
|
const label = shared?.rotating
|
|
146
156
|
? 'the rotating bonus categories'
|
|
147
|
-
: [...new Set(covered.
|
|
157
|
+
: listed([...new Set(covered.flatMap((r) => ruleParts(catalog, r)))]) ||
|
|
148
158
|
words(key);
|
|
149
159
|
return {
|
|
150
160
|
key,
|
|
@@ -194,6 +204,7 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
194
204
|
rotationGap: rotationGap(card, today),
|
|
195
205
|
caps: capSummaries(catalog, card, held, today),
|
|
196
206
|
milestones: milestoneSummaries(card, held, today),
|
|
207
|
+
credits: creditSummaries(catalog, card, held, today),
|
|
197
208
|
chosen: held.chosen
|
|
198
209
|
? {
|
|
199
210
|
period: held.chosen.period,
|
|
@@ -211,6 +222,8 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
211
222
|
activated: held.activated,
|
|
212
223
|
toActivate: toActivate(catalog, card, held, today),
|
|
213
224
|
anniversary: held.anniversary ?? null,
|
|
225
|
+
opened: openedOn(held),
|
|
226
|
+
intro: introSummary(card, held, today),
|
|
214
227
|
});
|
|
215
228
|
}
|
|
216
229
|
return {
|
|
@@ -221,6 +234,41 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
221
234
|
cards,
|
|
222
235
|
};
|
|
223
236
|
}
|
|
237
|
+
function creditSummaries(catalog, card, held, today) {
|
|
238
|
+
return creditStates(catalog, card, held, today).map((s) => {
|
|
239
|
+
const status = !s.enrolled
|
|
240
|
+
? `${ENROLL_FIRST}, then mark it enrolled`
|
|
241
|
+
: s.left === null
|
|
242
|
+
? "give the card's anniversary to track it"
|
|
243
|
+
: `${creditLeft(s.left, s.period)}${s.until === null ? '' : `, through ${shortDate(s.until)}`}`;
|
|
244
|
+
return {
|
|
245
|
+
id: s.credit.id,
|
|
246
|
+
name: s.name,
|
|
247
|
+
amount: s.amount,
|
|
248
|
+
period: s.period,
|
|
249
|
+
enroll: s.credit.enroll === true,
|
|
250
|
+
enrolled: s.enrolled,
|
|
251
|
+
currentPeriod: s.key,
|
|
252
|
+
until: s.until,
|
|
253
|
+
used: s.used,
|
|
254
|
+
left: s.left,
|
|
255
|
+
tracked: held.credit_usage?.[s.credit.id] !== undefined,
|
|
256
|
+
text: `${creditBack(s)} ${creditWhere(catalog, s.credit)}: ${status}.`,
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
function introSummary(card, held, today) {
|
|
261
|
+
const [first] = card.intro_rates;
|
|
262
|
+
if (!first)
|
|
263
|
+
return null;
|
|
264
|
+
const opened = openedOn(held);
|
|
265
|
+
if (opened === null)
|
|
266
|
+
return { state: 'no-date', until: null, days: first.days };
|
|
267
|
+
const [active] = introWindows(card, held, today);
|
|
268
|
+
if (active)
|
|
269
|
+
return { state: 'active', until: active.until, days: active.intro.days };
|
|
270
|
+
return { state: 'ended', until: introUntil(first, opened), days: first.days };
|
|
271
|
+
}
|
|
224
272
|
function pointValues(catalog, wallet) {
|
|
225
273
|
return Object.keys(wallet.valuations)
|
|
226
274
|
.filter((id) => catalog.currencies.some((c) => c.id === id))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const FALLBACK = '#5c5650';
|
|
2
|
+
export function cardColor(catalog, cardId) {
|
|
3
|
+
const card = catalog.cards.find((c) => c.id === cardId);
|
|
4
|
+
if (card?.color)
|
|
5
|
+
return card.color;
|
|
6
|
+
return catalog.issuers.find((i) => i.id === card?.issuer)?.color ?? FALLBACK;
|
|
7
|
+
}
|
|
8
|
+
// Relative luminance, to keep the chip and edge visible on light cards.
|
|
9
|
+
function light(hex) {
|
|
10
|
+
const [r, g, b] = [1, 3, 5].map((i) => {
|
|
11
|
+
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
|
|
12
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
13
|
+
});
|
|
14
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.35;
|
|
15
|
+
}
|
|
16
|
+
// 86 x 54 is a card's 85.6 x 54 mm; `width` scales it.
|
|
17
|
+
export function cardArtSvg(color, width = 48) {
|
|
18
|
+
const height = Math.round((width * 54) / 86);
|
|
19
|
+
const edge = light(color) ? 'rgba(0,0,0,0.18)' : 'rgba(255,255,255,0.14)';
|
|
20
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 86 54" width="${String(width)}" height="${String(height)}" aria-hidden="true" focusable="false"><rect x="0.5" y="0.5" width="85" height="53" rx="5" fill="${color}" stroke="${edge}"/><path d="M0 40 L56 0 H80 Q85.5 0 85.5 5 V8 L20 54 H5 Q0 54 0 49 Z" fill="#fff" opacity="0.07"/><rect x="10" y="17" width="14" height="11" rx="2" fill="#d9c08a" stroke="rgba(0,0,0,0.25)" stroke-width="0.6"/><path d="M10 22.5 H24 M17 17 V28" stroke="rgba(0,0,0,0.25)" stroke-width="0.6"/><rect x="10" y="40" width="30" height="3" rx="1.5" fill="${light(color) ? '#000' : '#fff'}" opacity="0.28"/></svg>`;
|
|
21
|
+
}
|
package/dist/format/card-text.js
CHANGED
|
@@ -9,11 +9,12 @@ export function cardText(catalog) {
|
|
|
9
9
|
const cards = new Map(catalog.cards.map((c) => [c.id, c]));
|
|
10
10
|
function shortName(id) {
|
|
11
11
|
const name = cards.get(id)?.name ?? id;
|
|
12
|
-
return name
|
|
12
|
+
return (name
|
|
13
13
|
.replace(/^American Express /, 'Amex ')
|
|
14
14
|
.replace(/^The Platinum Card from American Express$/, 'Amex Platinum')
|
|
15
|
-
|
|
16
|
-
.replace(/
|
|
15
|
+
// "Card" is part of Apple Card's name, not a suffix.
|
|
16
|
+
.replace(/ (Visa Signature |Visa Infinite )?(Credit )?(?<!^Apple )Card$/, '')
|
|
17
|
+
.replace(/ Card by /, ' by '));
|
|
17
18
|
}
|
|
18
19
|
function feeLabel(id) {
|
|
19
20
|
const card = cards.get(id);
|
package/dist/format/cards.js
CHANGED
|
@@ -20,6 +20,7 @@ export function formatCardInfo(card) {
|
|
|
20
20
|
: []),
|
|
21
21
|
...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
|
|
22
22
|
...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
|
|
23
|
+
...card.introRates.map((i) => `new accounts: ${i.text}; needs the opening date`),
|
|
23
24
|
...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
|
|
24
25
|
foreignFeeText(card.foreignTransactionFee).toLowerCase(),
|
|
25
26
|
];
|
package/dist/format/explore.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { membershipName, membershipNote, } from "../engine/memberships.js";
|
|
2
|
-
import { choiceName, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
|
|
2
|
+
import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
|
|
3
3
|
const membershipFees = (memberships) => memberships
|
|
4
4
|
.map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
|
|
5
5
|
.join('');
|
|
@@ -15,7 +15,23 @@ export function formatExploration(r, catalog, all) {
|
|
|
15
15
|
return `No cards match at ${title}.\n`;
|
|
16
16
|
const shown = all ? r.ranked : r.ranked.slice(0, TOP_BEST);
|
|
17
17
|
const width = Math.max(...shown.map((e) => e.cardName.length));
|
|
18
|
-
const lines = [
|
|
18
|
+
const lines = [
|
|
19
|
+
...(r.credits.length === 0
|
|
20
|
+
? []
|
|
21
|
+
: [
|
|
22
|
+
`${CREDIT_GROUP} at ${title}:`,
|
|
23
|
+
'',
|
|
24
|
+
...r.credits.flatMap((c) => [
|
|
25
|
+
`- ${c.cardName}: ${c.text}${c.enroll ? ` (${ENROLL_FIRST})` : ''}. ${capitalize(feeText(c))}.`,
|
|
26
|
+
...(c.evidence
|
|
27
|
+
? [` "${c.evidence.quote}" (${hostname(c.evidence.url)})`]
|
|
28
|
+
: []),
|
|
29
|
+
]),
|
|
30
|
+
'',
|
|
31
|
+
]),
|
|
32
|
+
`Best cards for ${title}${r.credits.length === 0 ? '' : ' by rate'}:`,
|
|
33
|
+
'',
|
|
34
|
+
];
|
|
19
35
|
shown.forEach((e, i) => {
|
|
20
36
|
const earned = e.earned === null ? '' : ` ${dollarsAndCents(e.earned)}`;
|
|
21
37
|
lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
|
|
@@ -89,7 +105,7 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
89
105
|
]);
|
|
90
106
|
if (notes.size > 0)
|
|
91
107
|
lines.push('', ...notes);
|
|
92
|
-
lines.push('',
|
|
108
|
+
lines.push('', `Assumes each card's best picks, activations done each quarter, and no welcome bonuses. ${CREDITS_LEFT_OUT}`);
|
|
93
109
|
return `${lines.join('\n')}\n`;
|
|
94
110
|
}
|
|
95
111
|
export function formatAdditions(r, catalog, all) {
|
|
@@ -117,6 +133,6 @@ export function formatAdditions(r, catalog, all) {
|
|
|
117
133
|
]);
|
|
118
134
|
if (notes.size > 0)
|
|
119
135
|
lines.push('', ...notes);
|
|
120
|
-
lines.push('',
|
|
136
|
+
lines.push('', `Assumes your picks carry over, rotating categories get activated, and caps start fresh. Annual fees are counted in full: statement credits and perks that offset them are left out. ${CREDITS_LEFT_OUT} Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.`);
|
|
121
137
|
return `${lines.join('\n')}\n`;
|
|
122
138
|
}
|
|
@@ -33,7 +33,8 @@ function cardLines(cards, style, bonusPick) {
|
|
|
33
33
|
const earned = (c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`) +
|
|
34
34
|
(c.milestoneBonus === null
|
|
35
35
|
? ''
|
|
36
|
-
: ` + ${money(c.milestoneBonus.dollars)} spend bonus`)
|
|
36
|
+
: ` + ${money(c.milestoneBonus.dollars)} spend bonus`) +
|
|
37
|
+
(c.credit?.dollars ? ` + ${money(c.credit.dollars)} credit` : '');
|
|
37
38
|
const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
|
|
38
39
|
lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
|
|
39
40
|
for (const r of c.reasoning)
|
|
@@ -59,9 +60,12 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
|
|
|
59
60
|
const milestone = top.milestoneBonus === null
|
|
60
61
|
? ''
|
|
61
62
|
: `, plus a spend bonus worth about ${money(top.milestoneBonus.dollars)}`;
|
|
63
|
+
const credit = top.credit?.dollars
|
|
64
|
+
? `, plus ${money(top.credit.dollars)} back from its ${top.credit.name} credit`
|
|
65
|
+
: '';
|
|
62
66
|
const confidence = rec.decision.confidence;
|
|
63
67
|
const why = rec.bonus ? ' for its welcome bonus' : '';
|
|
64
|
-
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}${milestone}.`)} ${capitalize(confidence)} confidence.`);
|
|
68
|
+
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}${milestone}${credit}.`)} ${capitalize(confidence)} confidence.`);
|
|
65
69
|
if (rec.bonus && rec.notes[0])
|
|
66
70
|
lines.push(rec.notes[0]);
|
|
67
71
|
}
|
|
@@ -19,6 +19,7 @@ function compact(wallet) {
|
|
|
19
19
|
? {}
|
|
20
20
|
: { checkout_label: c.checkout_label }),
|
|
21
21
|
...(c.anniversary === undefined ? {} : { anniversary: c.anniversary }),
|
|
22
|
+
...(c.opened === undefined ? {} : { opened: c.opened }),
|
|
22
23
|
...(Object.keys(c.cap_usage).length > 0
|
|
23
24
|
? { cap_usage: c.cap_usage }
|
|
24
25
|
: {}),
|
package/dist/format/wallet.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CAP_PERIODS, money, NETWORK_NAMES, shortDate } from "../engine/text.js";
|
|
1
|
+
import { CAP_PERIODS, introAdjective, introSince, money, NETWORK_NAMES, periodPhrase, roundCents, shortDate, } from "../engine/text.js";
|
|
2
2
|
// "To do now", above the card details; nothing when all's done.
|
|
3
3
|
export function formatChecklist(items) {
|
|
4
4
|
return items.length === 0
|
|
@@ -36,11 +36,19 @@ export function formatWalletSummary(summary) {
|
|
|
36
36
|
for (const cap of c.caps)
|
|
37
37
|
lines.push(cap.remaining === null
|
|
38
38
|
? ` Cap on ${cap.label}: ${money(cap.amount)} ${CAP_PERIODS[cap.period]}; give the card's anniversary to track it (cap key ${cap.key}).`
|
|
39
|
-
: ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used ${cap.period
|
|
39
|
+
: ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used ${periodPhrase(cap.period, cap.currentPeriod ?? '')}${cap.until === null ? '' : `, through ${shortDate(cap.until)}`} (cap key ${cap.key}).`);
|
|
40
40
|
for (const m of c.milestones)
|
|
41
41
|
lines.push(` Spend bonus (${m.id}): ${m.text}`);
|
|
42
|
+
for (const credit of c.credits)
|
|
43
|
+
lines.push(` Credit (${credit.id}): ${credit.text}`);
|
|
42
44
|
if (c.anniversary !== null)
|
|
43
45
|
lines.push(` Account anniversary: ${c.anniversary}.`);
|
|
46
|
+
if (c.intro?.state === 'active' && c.intro.until !== null)
|
|
47
|
+
lines.push(` Opened ${c.opened ?? ''}: ${introAdjective(c.intro.days)} rates through ${shortDate(c.intro.until)}.`);
|
|
48
|
+
else if (c.intro?.state === 'no-date')
|
|
49
|
+
lines.push(` Has ${introAdjective(c.intro.days)} rates for new accounts; give its opening date if it opened ${introSince(c.intro.days)}.`);
|
|
50
|
+
else if (c.opened !== null)
|
|
51
|
+
lines.push(` Opened ${c.opened}.`);
|
|
44
52
|
const b = c.bonus;
|
|
45
53
|
if (b?.state === 'open' && b.spend !== null)
|
|
46
54
|
lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
|
|
@@ -56,11 +64,13 @@ export function formatWalletSummary(summary) {
|
|
|
56
64
|
: "Point values: earnrate's defaults.";
|
|
57
65
|
return [...cards, memberships, values].join('\n');
|
|
58
66
|
}
|
|
59
|
-
export function formatBooking(amount, cardId, cap, bonus, milestones = []) {
|
|
67
|
+
export function formatBooking(amount, cardId, cap, bonus, milestones = [], credit = null) {
|
|
60
68
|
const messages = [];
|
|
69
|
+
if (credit)
|
|
70
|
+
messages.push(`Used ${money(roundCents(credit.after - credit.before))} of ${cardId}'s ${credit.name} credit: ${money(credit.after)} of ${money(credit.amount)} ${periodPhrase(credit.periodKind, credit.period)}, ${money(roundCents(Math.max(0, credit.amount - credit.after)))} left.`);
|
|
61
71
|
if (cap) {
|
|
62
72
|
const left = Math.max(0, cap.capAmount - cap.after);
|
|
63
|
-
messages.push(`Recorded ${money(amount)} on ${cardId} against "${cap.capKey}": ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)}
|
|
73
|
+
messages.push(`Recorded ${money(amount)} on ${cardId} against "${cap.capKey}": ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} ${periodPhrase(cap.periodKind, cap.period)}, ${money(left)} left.`);
|
|
64
74
|
}
|
|
65
75
|
if (bonus) {
|
|
66
76
|
const left = Math.max(0, bonus.spend - bonus.after);
|
|
@@ -74,7 +84,7 @@ export function formatBooking(amount, cardId, cap, bonus, milestones = []) {
|
|
|
74
84
|
const reached = milestones.filter((m) => m.before < m.spend && m.after >= m.spend);
|
|
75
85
|
const next = milestones.find((m) => m.after < m.spend);
|
|
76
86
|
messages.push([
|
|
77
|
-
`Spend on ${cardId} this year: ${money(first.after)}.`,
|
|
87
|
+
`Spend on ${cardId} this ${first.periodKind === 'cardmember_year' ? 'cardmember year' : 'year'}: ${money(first.after)}.`,
|
|
78
88
|
...reached.map((m) => `That reaches its ${money(m.spend)} spend bonus.`),
|
|
79
89
|
...(next
|
|
80
90
|
? [
|
package/dist/import/analyze.js
CHANGED
|
@@ -87,6 +87,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
87
87
|
cardId: held.id,
|
|
88
88
|
milestoneId: p.milestone.id,
|
|
89
89
|
period: p.period,
|
|
90
|
+
periodKind: p.milestone.period,
|
|
90
91
|
spend: p.milestone.spend,
|
|
91
92
|
before: p.spent,
|
|
92
93
|
after: p.spent,
|
|
@@ -181,8 +182,13 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
181
182
|
date: line.date,
|
|
182
183
|
country: 'US',
|
|
183
184
|
};
|
|
184
|
-
|
|
185
|
-
const
|
|
185
|
+
// Past statements don't say which credits were used, so they're left out.
|
|
186
|
+
const used = recommend(catalog, actual, purchase, merchant, {
|
|
187
|
+
credits: false,
|
|
188
|
+
}).ranked.find((r) => r.cardId === cardId);
|
|
189
|
+
const top = recommend(catalog, simulated, purchase, merchant, {
|
|
190
|
+
credits: false,
|
|
191
|
+
}).ranked[0];
|
|
186
192
|
if (!used || !top)
|
|
187
193
|
continue;
|
|
188
194
|
earned += used.earned ?? 0;
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
|
|
3
3
|
import { profileText, VALUATION_PROFILES } from "../engine/profiles.js";
|
|
4
4
|
import { knownMemberships } from "../engine/setup.js";
|
|
5
|
+
import { PaymentMethod } from "../schema/card.js";
|
|
5
6
|
import { IsoDate } from "../schema/common.js";
|
|
6
7
|
import { COUNTRY_CODE } from "../schema/common.js";
|
|
7
8
|
export const Merchant = z
|
|
@@ -18,6 +19,7 @@ export const CountryInput = z
|
|
|
18
19
|
.regex(COUNTRY_CODE, 'Use a two-letter country code, like US or FR.')
|
|
19
20
|
.optional()
|
|
20
21
|
.describe('Where the purchase is made, as a two-letter country code (FR, GB, JP). Pass it when the user is paying abroad or in another currency: abroad, cards are ranked after their foreign transaction fees. Default US.');
|
|
22
|
+
export const PaymentInput = PaymentMethod.optional().describe("How the purchase is paid, only when the user says: card (the card itself or its number), apple-pay, google-pay or paypal (PayPal checkout). Optional; omit it when unknown, and never assume Apple Pay. Some rates need one (Apple Card's 2% with Apple Pay, PayPal Cashback's 3% through PayPal checkout), and the answer's assumptions say when paying another way would change the pick.");
|
|
21
23
|
export const CardId = z
|
|
22
24
|
.string()
|
|
23
25
|
.min(1)
|
|
@@ -64,6 +66,9 @@ const cardFields = {
|
|
|
64
66
|
.regex(/^\d{2}-\d{2}$/)
|
|
65
67
|
.optional()
|
|
66
68
|
.describe("The account's anniversary as MM-DD, for cards whose cap resets on it."),
|
|
69
|
+
opened: IsoDate.nullable()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe('Optional: the day the account opened, YYYY-MM-DD. Only for cards with a first-year rate (search_cards lists it), which applies only while a purchase falls in the window; without a date the card is ranked at its standard rates. Ask the user; never guess or assume a card is new. null removes it.'),
|
|
67
72
|
activated: z
|
|
68
73
|
.array(z.string())
|
|
69
74
|
.optional()
|
|
@@ -76,6 +81,14 @@ const cardFields = {
|
|
|
76
81
|
.record(z.string(), z.number().nonnegative())
|
|
77
82
|
.optional()
|
|
78
83
|
.describe('Dollars already spent on the card this year, for its spend milestones (bonuses after a yearly spending total, as search_cards lists them): milestone id → dollars, e.g. {"spend-15k": 9000}. Every purchase on the card counts, so give each milestone the same total. Ask; recorded purchases add to it from then on.'),
|
|
84
|
+
enrolled: z
|
|
85
|
+
.array(z.string())
|
|
86
|
+
.optional()
|
|
87
|
+
.describe('Merchant credit ids the user has enrolled in with the issuer (search_cards and list_wallet list each card\'s credits, e.g. "lululemon" on Amex Platinum). A credit that needs enrolling counts in answers only once it\'s here. Replaces the current list. Ask; never assume.'),
|
|
88
|
+
creditUsed: z
|
|
89
|
+
.record(z.string(), z.number().nonnegative())
|
|
90
|
+
.optional()
|
|
91
|
+
.describe('Dollars of a merchant credit already received this period: credit id → dollars, e.g. {"lululemon": 40}. Ask; recorded purchases add to it from then on.'),
|
|
79
92
|
bonus: z
|
|
80
93
|
.object({
|
|
81
94
|
opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
|
package/dist/mcp/prompts.js
CHANGED
|
@@ -33,10 +33,15 @@ export function registerPrompts(server, deps, ctx) {
|
|
|
33
33
|
.string()
|
|
34
34
|
.optional()
|
|
35
35
|
.describe('Where you are paying, if not the U.S.: FR, Japan'),
|
|
36
|
+
paying: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('How you are paying, if you know: Apple Pay, PayPal'),
|
|
36
40
|
}),
|
|
37
41
|
}, (args) => {
|
|
38
42
|
const amount = args.amount?.trim() || null;
|
|
39
43
|
const country = args.country?.trim() || null;
|
|
44
|
+
const paying = args.paying?.trim() || null;
|
|
40
45
|
const call = [
|
|
41
46
|
`merchant ${JSON.stringify(args.store)}`,
|
|
42
47
|
...(amount === null
|
|
@@ -45,8 +50,11 @@ export function registerPrompts(server, deps, ctx) {
|
|
|
45
50
|
...(country === null
|
|
46
51
|
? []
|
|
47
52
|
: [`country as the two-letter code for ${JSON.stringify(country)}`]),
|
|
53
|
+
...(paying === null
|
|
54
|
+
? []
|
|
55
|
+
: [`payment for paying with ${JSON.stringify(paying)}`]),
|
|
48
56
|
];
|
|
49
|
-
return ask(`Which credit card should I use at ${args.store}${amount === null ? '' : ` for ${dollars(amount)}`}${country === null ? '' : ` in ${country}`}?`, [
|
|
57
|
+
return ask(`Which credit card should I use at ${args.store}${amount === null ? '' : ` for ${dollars(amount)}`}${country === null ? '' : ` in ${country}`}${paying === null ? '' : ` with ${paying}`}?`, [
|
|
50
58
|
`Call earnrate's best_card_for_purchase with ${call.join(', ')}.`,
|
|
51
59
|
`It answers from ${FROM_SAVED_OR_ALL}, so don't ask for their cards first.`,
|
|
52
60
|
'Say which card to use and why, with the assumptions and warnings it gives.',
|