earnrate 0.2.4 → 0.2.5
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 +5 -5
- package/data/CHANGELOG.md +39 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-gold.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +5 -1
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +5 -1
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +5 -1
- package/data/cards/chase-freedom-flex.yaml +1 -1
- package/data/cards/chase-ihg-one-rewards-premier.yaml +4 -0
- package/data/cards/chase-ihg-one-rewards.yaml +7 -0
- package/data/cards/chase-united-club.yaml +1 -1
- package/data/cards/citi-costco-anywhere-visa-business.yaml +1 -7
- package/data/cards/citi-costco-anywhere-visa.yaml +1 -7
- package/data/cards/td-bank-nordstrom-credit-card.yaml +4 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +4 -0
- package/data/categories.yaml +12 -0
- package/data/currencies.yaml +2 -0
- package/data/memberships.yaml +33 -0
- package/data/merchants/overrides.yaml +25 -0
- package/dist/cli/cheatsheet.js +2 -2
- package/dist/cli/explore.js +11 -10
- package/dist/cli/import.js +9 -9
- package/dist/cli/offers.js +2 -1
- package/dist/cli/run.js +17 -3
- package/dist/cli/wallet.js +42 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +18 -2
- package/dist/data/load.js +10 -2
- package/dist/data/store.js +8 -1
- package/dist/data/wallet-edit.js +75 -0
- package/dist/engine/cheat-sheet.js +14 -17
- package/dist/engine/compare.js +8 -0
- package/dist/engine/explore.js +10 -21
- package/dist/engine/memberships.js +41 -15
- package/dist/engine/offers.js +16 -4
- package/dist/engine/profiles.js +8 -0
- package/dist/engine/recommend.js +10 -23
- package/dist/engine/setup.js +14 -16
- package/dist/engine/simulate.js +5 -5
- package/dist/engine/text.js +36 -22
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +17 -0
- package/dist/engine/wallet-summary.js +24 -6
- package/dist/format/card-text.js +15 -19
- package/dist/format/cards.js +3 -3
- package/dist/format/explore.js +14 -15
- package/dist/format/offers.js +10 -15
- package/dist/format/recommendation.js +5 -5
- package/dist/format/wallet.js +4 -1
- package/dist/mcp/context.js +31 -4
- package/dist/mcp/inputs.js +15 -6
- package/dist/mcp/schemas.js +8 -0
- package/dist/mcp/server.js +1 -0
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +28 -12
- package/dist/mcp/tools/wallet.js +58 -5
- package/dist/schema/card.js +4 -11
- package/dist/schema/category.js +4 -0
- package/dist/schema/currency.js +2 -0
- package/dist/schema/membership.js +15 -0
- package/dist/schema/merchant.js +4 -0
- package/package.json +1 -1
package/dist/cli/explore.js
CHANGED
|
@@ -2,19 +2,20 @@ import { parseArgs } from 'node:util';
|
|
|
2
2
|
import { CLI_NAME } from "../brand.js";
|
|
3
3
|
import { loadCatalog, walletPath } from "../data/load.js";
|
|
4
4
|
import { ALL_PERSONAL_CARDS, exploreCards, findCategory, } from "../engine/explore.js";
|
|
5
|
-
import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
|
|
5
|
+
import { isValuationProfile, profileText, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
|
|
6
6
|
import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, TYPICAL_MONTHLY_SPEND, TYPICAL_SPEND_NOTE, } from "../engine/simulate.js";
|
|
7
7
|
import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../format/explore.js";
|
|
8
8
|
import { BOTH_CHANNELS, cardList, channelOf, dateProblem, membershipProblem, splitAmount, valuationProblem, } from "./args.js";
|
|
9
|
-
import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
|
|
9
|
+
import { cardsToUse, RUNTIME_ERROR, savedSettings, USAGE_ERROR, } from "./run.js";
|
|
10
10
|
const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or points and miles cards only.
|
|
11
11
|
--no-fee Cards with no annual fee.
|
|
12
12
|
--issuer <id> One issuer's cards, e.g. chase or amex.
|
|
13
13
|
--business Business cards instead of personal ones.
|
|
14
14
|
--closed Include cards the issuer no longer takes applications for.
|
|
15
15
|
--member <name> A membership you have, e.g. amazon-prime or costco. Repeatable.
|
|
16
|
-
--valuation <profile> flat (
|
|
17
|
-
(
|
|
16
|
+
--valuation <profile> flat (${profileText('flat')}) or points-heavy
|
|
17
|
+
(${profileText('points-heavy')}), instead of your
|
|
18
|
+
saved point values.`;
|
|
18
19
|
const BEST_HELP = `Usage: ${CLI_NAME} best <merchant or category> [amount] [options]
|
|
19
20
|
|
|
20
21
|
Ranks every card in earnrate's catalog for one purchase, not just yours. Each
|
|
@@ -67,7 +68,7 @@ const FILTER_FLAGS = {
|
|
|
67
68
|
json: { type: 'boolean' },
|
|
68
69
|
help: { type: 'boolean', short: 'h' },
|
|
69
70
|
};
|
|
70
|
-
function settingsFrom(values, catalog) {
|
|
71
|
+
function settingsFrom(values, catalog, saved) {
|
|
71
72
|
if (values.cash && values.points)
|
|
72
73
|
return 'Pick --cash or --points, not both.';
|
|
73
74
|
const problem = valuationProblem(values.valuation) ??
|
|
@@ -86,10 +87,10 @@ function settingsFrom(values, catalog) {
|
|
|
86
87
|
issuer: values.issuer ?? null,
|
|
87
88
|
includeClosed: values.closed ?? false,
|
|
88
89
|
},
|
|
89
|
-
memberships: values.member ??
|
|
90
|
+
memberships: values.member ?? saved.memberships,
|
|
90
91
|
valuations: values.valuation !== undefined && isValuationProfile(values.valuation)
|
|
91
92
|
? VALUATION_PROFILES[values.valuation]
|
|
92
|
-
:
|
|
93
|
+
: saved.valuations,
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
96
|
export async function runBest(argv, io) {
|
|
@@ -135,7 +136,7 @@ export async function runBest(argv, io) {
|
|
|
135
136
|
return USAGE_ERROR;
|
|
136
137
|
}
|
|
137
138
|
const catalog = await loadCatalog();
|
|
138
|
-
const settings = settingsFrom(values, catalog);
|
|
139
|
+
const settings = settingsFrom(values, catalog, await savedSettings(io));
|
|
139
140
|
if (typeof settings === 'string') {
|
|
140
141
|
io.stderr(`${settings}\n`);
|
|
141
142
|
return USAGE_ERROR;
|
|
@@ -150,7 +151,7 @@ export async function runBest(argv, io) {
|
|
|
150
151
|
}, settings);
|
|
151
152
|
io.stdout(values.json
|
|
152
153
|
? `${JSON.stringify(result, null, 2)}\n`
|
|
153
|
-
: formatExploration(result, values.all ?? false));
|
|
154
|
+
: formatExploration(result, catalog, values.all ?? false));
|
|
154
155
|
return 0;
|
|
155
156
|
}
|
|
156
157
|
function parseSpend(catalog, args) {
|
|
@@ -195,7 +196,7 @@ export async function runSimulate(argv, io) {
|
|
|
195
196
|
return 0;
|
|
196
197
|
}
|
|
197
198
|
const catalog = await loadCatalog();
|
|
198
|
-
const settings = settingsFrom(values, catalog);
|
|
199
|
+
const settings = settingsFrom(values, catalog, await savedSettings(io));
|
|
199
200
|
if (typeof settings === 'string') {
|
|
200
201
|
io.stderr(`${settings}\n`);
|
|
201
202
|
return USAGE_ERROR;
|
package/dist/cli/import.js
CHANGED
|
@@ -12,6 +12,7 @@ import { cardValuation } from "../engine/valuation.js";
|
|
|
12
12
|
import { analyzeStatement, } from "../import/analyze.js";
|
|
13
13
|
import { parseStatement } from "../import/statement.js";
|
|
14
14
|
import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
|
|
15
|
+
import { dollarsAndCents } from "../engine/text.js";
|
|
15
16
|
const IMPORT_HELP = `Usage: ${CLI_NAME} import <statement.csv> --card <card-id> [--apply]
|
|
16
17
|
${CLI_NAME} import <statement.csv>:<card-id> [<statement.csv>:<card-id> ...]
|
|
17
18
|
|
|
@@ -44,7 +45,6 @@ const LedgerSchema = z.strictObject({
|
|
|
44
45
|
const fingerprint = ({ cardId, line }, kind = 'cap') => createHash('sha256')
|
|
45
46
|
.update(`${kind === 'bonus' ? 'bonus|' : ''}${cardId}|${line.date}|${String(line.amount)}|${line.description}`)
|
|
46
47
|
.digest('hex');
|
|
47
|
-
const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
48
48
|
// "statement.csv:chase-sapphire-preferred" names the card inline.
|
|
49
49
|
function splitTarget(arg) {
|
|
50
50
|
const m = /^(.+):([a-z0-9]+(?:-[a-z0-9]+)*)$/.exec(arg);
|
|
@@ -77,20 +77,20 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
|
|
|
77
77
|
several
|
|
78
78
|
? `${String(a.byCard.length)} statements (${names.join(', ')}) · ${a.from} to ${a.to}`
|
|
79
79
|
: `${names[0] ?? ''} statement · ${a.from} to ${a.to}`,
|
|
80
|
-
`${String(a.purchases)} purchases, ${
|
|
80
|
+
`${String(a.purchases)} purchases, ${dollarsAndCents(a.spend)}${a.refunds > 0 ? ` (refunds and credits: ${dollarsAndCents(a.refunds)})` : ''}`,
|
|
81
81
|
'',
|
|
82
82
|
];
|
|
83
83
|
if (several) {
|
|
84
|
-
out.push(`Earned on the cards you used: about ${
|
|
84
|
+
out.push(`Earned on the cards you used: about ${dollarsAndCents(a.earned)}`);
|
|
85
85
|
const width = Math.max(...names.map((n) => n.length));
|
|
86
86
|
for (const t of a.byCard) {
|
|
87
|
-
out.push(` ${labelOf(t.cardId).padEnd(width)} ${String(t.purchases).padStart(4)} × ${
|
|
87
|
+
out.push(` ${labelOf(t.cardId).padEnd(width)} ${String(t.purchases).padStart(4)} × ${dollarsAndCents(t.spend).padStart(11)} ${dollarsAndCents(t.earned)}`);
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
else {
|
|
91
|
-
out.push(`Earned on ${names[0] ?? ''}: about ${
|
|
91
|
+
out.push(`Earned on ${names[0] ?? ''}: about ${dollarsAndCents(a.earned)}`);
|
|
92
92
|
}
|
|
93
|
-
out.push(`With earnrate's picks: about ${
|
|
93
|
+
out.push(`With earnrate's picks: about ${dollarsAndCents(a.best)} (+${dollarsAndCents(a.best - a.earned)})`, valuation);
|
|
94
94
|
if (a.missed.length > 0) {
|
|
95
95
|
out.push('', 'Where another card would have earned more:');
|
|
96
96
|
const shown = a.missed.slice(0, 10);
|
|
@@ -99,7 +99,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
|
|
|
99
99
|
const cardWidth = Math.max(...shown.map((m) => labelOf(m.usedCardId).length));
|
|
100
100
|
for (const m of shown) {
|
|
101
101
|
const used = several ? `${labelOf(m.usedCardId).padEnd(cardWidth)} ` : '';
|
|
102
|
-
out.push(` ${name(m).slice(0, width).padEnd(width)} ${used}${String(m.count).padStart(3)} × ${
|
|
102
|
+
out.push(` ${name(m).slice(0, width).padEnd(width)} ${used}${String(m.count).padStart(3)} × ${dollarsAndCents(m.spend).padStart(10)} ${dollarsAndCents(m.earned)} → ${m.bestCard} ${dollarsAndCents(m.best)} (+${dollarsAndCents(m.best - m.earned)})`);
|
|
103
103
|
}
|
|
104
104
|
if (a.missed.length > 10)
|
|
105
105
|
out.push(` …and ${String(a.missed.length - 10)} more merchants`);
|
|
@@ -117,7 +117,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
|
|
|
117
117
|
const last = new Map();
|
|
118
118
|
for (const { cardId, booking } of a.bookings)
|
|
119
119
|
last.set(several ? `${labelOf(cardId)} ${booking.capKey}` : booking.capKey, booking.after);
|
|
120
|
-
out.push(`Cap usage for the current period: ${[...last].map(([k, v]) => `${k} ${
|
|
120
|
+
out.push(`Cap usage for the current period: ${[...last].map(([k, v]) => `${k} ${dollarsAndCents(v)}`).join(', ')}.`);
|
|
121
121
|
}
|
|
122
122
|
else if (a.alreadyImported > 0) {
|
|
123
123
|
out.push(`Caps: nothing new to record. ${String(a.alreadyImported)} purchase(s) were recorded by an earlier import.`);
|
|
@@ -129,7 +129,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
|
|
|
129
129
|
out.push('Caps: nothing new to record.');
|
|
130
130
|
}
|
|
131
131
|
for (const b of a.bonuses) {
|
|
132
|
-
out.push(`Welcome bonus on ${labelOf(b.cardId)}: ${
|
|
132
|
+
out.push(`Welcome bonus on ${labelOf(b.cardId)}: ${dollarsAndCents(b.before)} → ${dollarsAndCents(b.after)} of ${dollarsAndCents(b.spend)}${b.deadline ? ` by ${b.deadline}` : ''}.`);
|
|
133
133
|
}
|
|
134
134
|
return `${out.join('\n')}\n`;
|
|
135
135
|
}
|
package/dist/cli/offers.js
CHANGED
|
@@ -9,7 +9,8 @@ import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
|
|
|
9
9
|
const OFFERS_HELP = `Usage: ${CLI_NAME} offers [--cash | --points] [--no-fee] [--business] [options]
|
|
10
10
|
|
|
11
11
|
Ranks the welcome offers on cards you don't have by first-year value: the
|
|
12
|
-
bonus at your point values minus the first year's annual fee
|
|
12
|
+
bonus at your point values minus the first year's annual fee and any
|
|
13
|
+
membership the card needs that you don't have.
|
|
13
14
|
|
|
14
15
|
Options:
|
|
15
16
|
--cash Cash back cards only.
|
package/dist/cli/run.js
CHANGED
|
@@ -6,7 +6,7 @@ import { CLI_NAME, CONFIG_DIR, LLM_ENV_VAR, LLM_MAX_CALLS_ENV_VAR, LLM_MODEL_ENV
|
|
|
6
6
|
import { walletProblems } from "../data/integrity.js";
|
|
7
7
|
import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
|
|
8
8
|
import { ALL_PERSONAL_CARDS, categoryResolution, exploreCards, findCategory, } from "../engine/explore.js";
|
|
9
|
-
import { VALUATION_PROFILES, withValuationProfile } from "../engine/profiles.js";
|
|
9
|
+
import { profileText, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
|
|
10
10
|
import { recommend } from "../engine/recommend.js";
|
|
11
11
|
import { walletFromCards } from "../engine/transient.js";
|
|
12
12
|
import { formatExploration } from "../format/explore.js";
|
|
@@ -43,6 +43,7 @@ Save your cards (optional):
|
|
|
43
43
|
${CLI_NAME} update <card-id> [--pick ...] Change picks, label or activations.
|
|
44
44
|
${CLI_NAME} remove <card-id> Remove a card.
|
|
45
45
|
${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime, costco).
|
|
46
|
+
${CLI_NAME} values [UR=1.5 ...] Show or set what your points are worth.
|
|
46
47
|
|
|
47
48
|
Other commands:
|
|
48
49
|
${CLI_NAME} cheatsheet The best card for each category this quarter.
|
|
@@ -53,7 +54,8 @@ Other commands:
|
|
|
53
54
|
|
|
54
55
|
Options:
|
|
55
56
|
--online, --in-store How you're paying. Default: inferred from the merchant.
|
|
56
|
-
--valuation <profile> flat (
|
|
57
|
+
--valuation <profile> flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}),
|
|
58
|
+
instead of your saved point values.
|
|
57
59
|
Default: the values in your wallet.
|
|
58
60
|
--all List every card, not just the top three.
|
|
59
61
|
--json Print the full recommendation as JSON.
|
|
@@ -120,6 +122,18 @@ export async function cardsToUse(catalog, file, io, named) {
|
|
|
120
122
|
return loaded;
|
|
121
123
|
}
|
|
122
124
|
export const NO_WALLET_HINT = `These are every card earnrate knows. For the best of your own cards, name them with --cards amex-gold,chase-prime-visa, or save them: ${CLI_NAME} add <card-id> (${CLI_NAME} cards finds ids).`;
|
|
125
|
+
// The point values and memberships saved in the wallet, which every answer
|
|
126
|
+
// uses unless --valuation or --member says otherwise. No wallet, or one that
|
|
127
|
+
// can't be read, has none.
|
|
128
|
+
export async function savedSettings(io) {
|
|
129
|
+
try {
|
|
130
|
+
const { valuations, memberships } = await loadWallet(walletPath(io.env));
|
|
131
|
+
return { valuations, memberships };
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return { valuations: {}, memberships: [] };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
123
137
|
export async function readWallet(file, io) {
|
|
124
138
|
try {
|
|
125
139
|
return await loadWallet(file);
|
|
@@ -222,7 +236,7 @@ export async function run(argv, io) {
|
|
|
222
236
|
io.stdout(`${JSON.stringify(explored, null, 2)}\n`);
|
|
223
237
|
}
|
|
224
238
|
else {
|
|
225
|
-
io.stdout(`${formatExploration(explored, options.all)}\n${NO_WALLET_HINT}\n`);
|
|
239
|
+
io.stdout(`${formatExploration(explored, catalog, options.all)}\n${NO_WALLET_HINT}\n`);
|
|
226
240
|
}
|
|
227
241
|
for (const n of notices)
|
|
228
242
|
io.stderr(`${n}\n`);
|
package/dist/cli/wallet.js
CHANGED
|
@@ -2,6 +2,7 @@ import { parseArgs } from 'node:util';
|
|
|
2
2
|
import { CLI_NAME } from "../brand.js";
|
|
3
3
|
import { FileWalletStore } from "../data/file-store.js";
|
|
4
4
|
import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
|
|
5
|
+
import { isValuationProfile, profileText, VALUATION_PROFILES, } from "../engine/profiles.js";
|
|
5
6
|
import { knownMemberships, searchCards } from "../engine/setup.js";
|
|
6
7
|
import { formatCardInfo, formatNotes } from "../format/cards.js";
|
|
7
8
|
import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
|
|
@@ -53,6 +54,20 @@ Removes a card from your wallet, with its tracked cap usage.
|
|
|
53
54
|
|
|
54
55
|
With no arguments, shows your memberships and the ones earnrate knows. With
|
|
55
56
|
names, replaces the list. --none clears it.
|
|
57
|
+
`,
|
|
58
|
+
values: `Usage: ${CLI_NAME} values [<currency>=<cents>...] [--profile <name>]
|
|
59
|
+
|
|
60
|
+
What your points are worth to you, in cents per point, saved in your wallet
|
|
61
|
+
file; every answer then uses them unless you pass --valuation. With no
|
|
62
|
+
arguments, shows them.
|
|
63
|
+
|
|
64
|
+
Examples:
|
|
65
|
+
${CLI_NAME} values UR=1.5 MR=1.6
|
|
66
|
+
${CLI_NAME} values UR=default back to earnrate's value
|
|
67
|
+
${CLI_NAME} values --profile points-heavy
|
|
68
|
+
|
|
69
|
+
Options:
|
|
70
|
+
--profile <name> flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}).
|
|
56
71
|
`,
|
|
57
72
|
};
|
|
58
73
|
const HINTS = {
|
|
@@ -118,6 +133,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
118
133
|
'bonus-by': { type: 'string' },
|
|
119
134
|
'no-bonus': { type: 'boolean' },
|
|
120
135
|
none: { type: 'boolean' },
|
|
136
|
+
profile: { type: 'string' },
|
|
121
137
|
help: { type: 'boolean', short: 'h' },
|
|
122
138
|
},
|
|
123
139
|
});
|
|
@@ -160,6 +176,32 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
160
176
|
io.stdout(`${header}${result.cards.map(formatCardInfo).join('\n')}\n`);
|
|
161
177
|
return 0;
|
|
162
178
|
}
|
|
179
|
+
if (command === 'values') {
|
|
180
|
+
const changes = {};
|
|
181
|
+
if (values.profile !== undefined) {
|
|
182
|
+
if (!isValuationProfile(values.profile))
|
|
183
|
+
return usage(`Unknown profile "${values.profile}". Use flat or points-heavy.`);
|
|
184
|
+
Object.assign(changes, VALUATION_PROFILES[values.profile]);
|
|
185
|
+
}
|
|
186
|
+
for (const arg of positionals) {
|
|
187
|
+
const [id, value] = arg.split('=');
|
|
188
|
+
const cents = value === 'default' ? null : Number(value);
|
|
189
|
+
if (!id ||
|
|
190
|
+
value === undefined ||
|
|
191
|
+
(cents !== null && !Number.isFinite(cents)))
|
|
192
|
+
return usage(`"${arg}" isn't <currency>=<cents>, like UR=1.5.`);
|
|
193
|
+
changes[id] = cents;
|
|
194
|
+
}
|
|
195
|
+
if (Object.keys(changes).length === 0) {
|
|
196
|
+
const held = await heldWallet(io);
|
|
197
|
+
const set = Object.entries(held?.valuations ?? {});
|
|
198
|
+
io.stdout(`${set.length > 0 ? `Yours: ${set.map(([id, v]) => `${id}=${typeof v === 'number' ? String(v) : JSON.stringify(v)}`).join(' ')}` : "Yours: earnrate's defaults"}\nCurrencies: ${catalog.currencies.map((c) => c.id).join(', ')}\n`);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
return report(await store.setValuations(changes), `Point values updated in ${where}: ${Object.entries(changes)
|
|
202
|
+
.map(([id, c]) => `${id} ${c === null ? 'back to the default' : `${String(c)}¢`}`)
|
|
203
|
+
.join(', ')}.`, null, io);
|
|
204
|
+
}
|
|
163
205
|
if (command === 'memberships') {
|
|
164
206
|
if (positionals.length === 0 && !values.none) {
|
|
165
207
|
const held = await heldWallet(io);
|
package/dist/data/file-store.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { bookPurchase } from "../engine/record.js";
|
|
2
|
-
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
|
|
2
|
+
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
|
|
3
3
|
import { loadWallet } from "./load.js";
|
|
4
|
-
import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, updateCardText, writeProgress, } from "./wallet-edit.js";
|
|
4
|
+
import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, setValuationsText, updateCardText, writeProgress, } from "./wallet-edit.js";
|
|
5
5
|
const failed = (error) => ({ ok: false, error });
|
|
6
6
|
const errorText = (e) => (e instanceof Error ? e.message : String(e));
|
|
7
7
|
const queues = new Map();
|
|
@@ -76,6 +76,9 @@ export class FileWalletStore {
|
|
|
76
76
|
setMemberships(memberships) {
|
|
77
77
|
return this.change(this.existing((w) => withMemberships(this.catalog, w, memberships)), (text, change) => setMembershipsText(text ?? '', change.wallet.memberships));
|
|
78
78
|
}
|
|
79
|
+
setValuations(changes) {
|
|
80
|
+
return this.change(this.existing((w) => withValuations(this.catalog, w, changes)), (text) => setValuationsText(text ?? '', changes));
|
|
81
|
+
}
|
|
79
82
|
record(purchase, merchant) {
|
|
80
83
|
return withWalletLock(this.where, async () => {
|
|
81
84
|
const wallet = await this.load();
|
package/dist/data/integrity.js
CHANGED
|
@@ -60,8 +60,19 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
60
60
|
for (const id of card.welcome_offer?.not_with ?? [])
|
|
61
61
|
if (!cardIds.has(id))
|
|
62
62
|
problems.push(`${where} welcome_offer.not_with: unknown card '${id}'`);
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
const memberships = new Set(catalog.memberships.map((m) => m.id));
|
|
64
|
+
for (const id of [
|
|
65
|
+
...(card.requires_membership === undefined
|
|
66
|
+
? []
|
|
67
|
+
: [card.requires_membership]),
|
|
68
|
+
...card.rules.flatMap((r) => r.requires?.membership ?? []),
|
|
69
|
+
])
|
|
70
|
+
if (!memberships.has(id))
|
|
71
|
+
problems.push(`${where}: unknown membership '${id}' (data/memberships.yaml)`);
|
|
72
|
+
if (card.requires_membership !== undefined &&
|
|
73
|
+
catalog.memberships.find((m) => m.id === card.requires_membership)
|
|
74
|
+
?.annual_fee === undefined)
|
|
75
|
+
problems.push(`${where}: requires_membership '${card.requires_membership}' has no annual_fee`);
|
|
65
76
|
if (card.pools_into) {
|
|
66
77
|
if (!currencies.has(card.pools_into.currency)) {
|
|
67
78
|
problems.push(`${where} pools_into: unknown currency '${card.pools_into.currency}'`);
|
|
@@ -167,6 +178,7 @@ export function catalogProblems(catalog) {
|
|
|
167
178
|
['category', catalog.categories.map((c) => c.id)],
|
|
168
179
|
['currency', catalog.currencies.map((c) => c.id)],
|
|
169
180
|
['card', catalog.cards.map((c) => c.id)],
|
|
181
|
+
['membership', catalog.memberships.map((m) => m.id)],
|
|
170
182
|
]) {
|
|
171
183
|
for (const id of duplicates(ids))
|
|
172
184
|
problems.push(`duplicate ${kind} id '${id}'`);
|
|
@@ -187,6 +199,10 @@ export function catalogProblems(catalog) {
|
|
|
187
199
|
problems.push(`merchant ${m.id}: booked_via '${via}' is not a merchant`);
|
|
188
200
|
}
|
|
189
201
|
}
|
|
202
|
+
for (const m of catalog.memberships)
|
|
203
|
+
for (const id of m.members_shop_at)
|
|
204
|
+
if (!merchantIds.has(id))
|
|
205
|
+
problems.push(`membership ${m.id}: members_shop_at '${id}' is not a merchant`);
|
|
190
206
|
for (const card of catalog.cards) {
|
|
191
207
|
problems.push(...cardProblems(card, catalog, merchantIds));
|
|
192
208
|
}
|
package/dist/data/load.js
CHANGED
|
@@ -8,6 +8,7 @@ import { CONFIG_DIR, WALLET_ENV_VAR } from "../brand.js";
|
|
|
8
8
|
import { CardSchema } from "../schema/card.js";
|
|
9
9
|
import { CategoriesFileSchema } from "../schema/category.js";
|
|
10
10
|
import { CurrenciesFileSchema } from "../schema/currency.js";
|
|
11
|
+
import { MembershipsFileSchema } from "../schema/membership.js";
|
|
11
12
|
import { MerchantsFileSchema } from "../schema/merchant.js";
|
|
12
13
|
import { WalletSchema } from "../schema/wallet.js";
|
|
13
14
|
export const DATA_DIR = fileURLToPath(new URL('../../data/', import.meta.url));
|
|
@@ -30,14 +31,21 @@ async function loadCards(dir) {
|
|
|
30
31
|
}));
|
|
31
32
|
}
|
|
32
33
|
export async function loadCatalog(dir = DATA_DIR) {
|
|
33
|
-
const [categories, currencies, cards, seed, overrides] = await Promise.all([
|
|
34
|
+
const [categories, currencies, memberships, cards, seed, overrides] = await Promise.all([
|
|
34
35
|
readYaml(path.join(dir, 'categories.yaml'), CategoriesFileSchema),
|
|
35
36
|
readYaml(path.join(dir, 'currencies.yaml'), CurrenciesFileSchema),
|
|
37
|
+
readYaml(path.join(dir, 'memberships.yaml'), MembershipsFileSchema),
|
|
36
38
|
loadCards(path.join(dir, 'cards')),
|
|
37
39
|
readYaml(path.join(dir, 'merchants', 'seed.yaml'), MerchantsFileSchema),
|
|
38
40
|
readYaml(path.join(dir, 'merchants', 'overrides.yaml'), MerchantsFileSchema),
|
|
39
41
|
]);
|
|
40
|
-
return {
|
|
42
|
+
return {
|
|
43
|
+
categories,
|
|
44
|
+
currencies,
|
|
45
|
+
memberships,
|
|
46
|
+
cards,
|
|
47
|
+
merchants: { seed, overrides },
|
|
48
|
+
};
|
|
41
49
|
}
|
|
42
50
|
// MCP clients pass config values through without a shell, so "~" or
|
|
43
51
|
// "${HOME}" can arrive as literal text; expand them here.
|
package/dist/data/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { bookPurchase } from "../engine/record.js";
|
|
2
|
-
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
|
|
2
|
+
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
|
|
3
3
|
import { WalletSchema } from "../schema/wallet.js";
|
|
4
4
|
const failed = (error) => ({ ok: false, error });
|
|
5
5
|
// A wallet stored as JSON text. Writes are serialized within this instance;
|
|
@@ -60,6 +60,9 @@ export class JsonWalletStore {
|
|
|
60
60
|
setMemberships(memberships) {
|
|
61
61
|
return this.change(this.needsWallet((w) => withMemberships(this.catalog, w, memberships)));
|
|
62
62
|
}
|
|
63
|
+
setValuations(changes) {
|
|
64
|
+
return this.change(this.needsWallet((w) => withValuations(this.catalog, w, changes)));
|
|
65
|
+
}
|
|
63
66
|
replace(wallet) {
|
|
64
67
|
return this.change(() => ({ ok: true, wallet, fields: {}, notes: [] }));
|
|
65
68
|
}
|
|
@@ -70,6 +73,10 @@ export class JsonWalletStore {
|
|
|
70
73
|
disconnect() {
|
|
71
74
|
return Promise.resolve();
|
|
72
75
|
}
|
|
76
|
+
// The backend doesn't record when it began.
|
|
77
|
+
started() {
|
|
78
|
+
return Promise.resolve(null);
|
|
79
|
+
}
|
|
73
80
|
record(purchase, merchant) {
|
|
74
81
|
return this.serialized(async () => {
|
|
75
82
|
const wallet = await this.load();
|
package/dist/data/wallet-edit.js
CHANGED
|
@@ -170,6 +170,81 @@ export function setMembershipsText(text, memberships) {
|
|
|
170
170
|
const cards = root.items.find((p) => keyOf(p) === 'cards');
|
|
171
171
|
return insertLines(text, lineStart(text, range(cards?.key)[0]), line);
|
|
172
172
|
}
|
|
173
|
+
// Point values, one currency at a time so the rest of the file, comments
|
|
174
|
+
// included, stays as it was: a value is replaced in place, a new one goes
|
|
175
|
+
// after the last, a removed one loses its line. An inline map is rewritten.
|
|
176
|
+
export function setValuationsText(text, changes) {
|
|
177
|
+
let out = text;
|
|
178
|
+
for (const [id, cents] of Object.entries(changes))
|
|
179
|
+
out = setValuationText(out, id, cents);
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
// The start of the line at `at`, moved up past the comments right above it,
|
|
183
|
+
// so a comment stays with the line it describes.
|
|
184
|
+
function aboveComments(text, at) {
|
|
185
|
+
let start = lineStart(text, at);
|
|
186
|
+
while (start > 0) {
|
|
187
|
+
const previous = lineStart(text, start - 1);
|
|
188
|
+
if (!text.slice(previous, start).trimStart().startsWith('#'))
|
|
189
|
+
break;
|
|
190
|
+
start = previous;
|
|
191
|
+
}
|
|
192
|
+
return start;
|
|
193
|
+
}
|
|
194
|
+
function setValuationText(text, id, cents) {
|
|
195
|
+
const { doc } = cardsSeq(text);
|
|
196
|
+
const root = doc.contents;
|
|
197
|
+
if (!isMap(root))
|
|
198
|
+
throw new Error(`the wallet isn't a map; ${BY_HAND}`);
|
|
199
|
+
const pair = root.items.find((p) => keyOf(p) === 'valuations');
|
|
200
|
+
const map = pair?.value;
|
|
201
|
+
const line = cents === null ? '' : `${id}: ${String(cents)}`;
|
|
202
|
+
if (!pair || map === null || (isMap(map) && map.items.length === 0)) {
|
|
203
|
+
if (cents === null)
|
|
204
|
+
return text;
|
|
205
|
+
const block = `valuations:\n ${line}\n`;
|
|
206
|
+
if (pair) {
|
|
207
|
+
const start = range(pair.key)[0];
|
|
208
|
+
const end = afterLine(text, range(map ?? pair.key)[1] - 1);
|
|
209
|
+
return text.slice(0, start) + block + text.slice(end);
|
|
210
|
+
}
|
|
211
|
+
const before = root.items.find((p) => keyOf(p) === 'memberships' || keyOf(p) === 'cards');
|
|
212
|
+
return insertLines(text, aboveComments(text, range(before?.key)[0]), block);
|
|
213
|
+
}
|
|
214
|
+
if (!isMap(map))
|
|
215
|
+
throw new Error(`"valuations" isn't a map; ${BY_HAND}`);
|
|
216
|
+
if (map.flow) {
|
|
217
|
+
const values = Object.entries(map.toJSON()).filter(([k]) => k !== id);
|
|
218
|
+
if (cents !== null)
|
|
219
|
+
values.push([id, cents]);
|
|
220
|
+
const entries = values.map(([k, v]) => `${k}: ${stringify(v, { flow: true }).trimEnd()}`);
|
|
221
|
+
const start = range(pair.key)[0];
|
|
222
|
+
const end = afterLine(text, range(map)[1] - 1);
|
|
223
|
+
return (text.slice(0, start) +
|
|
224
|
+
(entries.length === 0 ? '' : `valuations: { ${entries.join(', ')} }\n`) +
|
|
225
|
+
text.slice(end));
|
|
226
|
+
}
|
|
227
|
+
const own = map.items.find((p) => keyOf(p) === id);
|
|
228
|
+
if (own) {
|
|
229
|
+
if (cents === null) {
|
|
230
|
+
if (map.items.length === 1) {
|
|
231
|
+
const start = lineStart(text, range(pair.key)[0]);
|
|
232
|
+
return (text.slice(0, start) + text.slice(afterLine(text, range(map)[1] - 1)));
|
|
233
|
+
}
|
|
234
|
+
const start = lineStart(text, range(own.key)[0]);
|
|
235
|
+
return (text.slice(0, start) +
|
|
236
|
+
text.slice(afterLine(text, range(own.value)[1] - 1)));
|
|
237
|
+
}
|
|
238
|
+
const [start, end] = range(own.value);
|
|
239
|
+
return text.slice(0, start) + String(cents) + text.slice(end);
|
|
240
|
+
}
|
|
241
|
+
if (cents === null)
|
|
242
|
+
return text;
|
|
243
|
+
const first = map.items[0];
|
|
244
|
+
const last = map.items.at(-1);
|
|
245
|
+
const indent = ' '.repeat(column(text, range(first?.key)[0]));
|
|
246
|
+
return insertLines(text, afterLine(text, range(last?.value ?? last?.key)[1] - 1), `${indent}${line}\n`);
|
|
247
|
+
}
|
|
173
248
|
function flowUsage(booking) {
|
|
174
249
|
const period = /^\d+$/.test(booking.period)
|
|
175
250
|
? `"${booking.period}"`
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { resolveMerchantById } from "../merchants/resolve.js";
|
|
2
2
|
import { categoryResolution, exploreCards, } from "./explore.js";
|
|
3
3
|
import { bonusStatus } from "./bonus.js";
|
|
4
|
+
import { EPSILON, isClose } from "./compare.js";
|
|
4
5
|
import { periodKey } from "./periods.js";
|
|
5
6
|
import { recommend } from "./recommend.js";
|
|
6
7
|
import { setupNotes } from "./setup.js";
|
|
7
|
-
import { listed, money, shortDate } from "./text.js";
|
|
8
|
-
|
|
8
|
+
import { feeText, listed, money, shortDate } from "./text.js";
|
|
9
|
+
// The everyday kinds of purchase: the cheat sheet's rows and the site's
|
|
10
|
+
// quick picks.
|
|
11
|
+
export const EVERYDAY_PURCHASES = [
|
|
9
12
|
{ label: 'Groceries', category: 'grocery.supermarket' },
|
|
10
13
|
{ label: 'Dining', category: 'dining' },
|
|
11
14
|
{ label: 'Gas', category: 'gas' },
|
|
@@ -18,7 +21,7 @@ const CHEAT_SHEET_ITEMS = [
|
|
|
18
21
|
];
|
|
19
22
|
// The best card in the wallet for each everyday kind of purchase on `date`,
|
|
20
23
|
// with caps, picks and activations as the wallet has them.
|
|
21
|
-
export function cheatSheet(catalog, wallet, date, items =
|
|
24
|
+
export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
22
25
|
const ranked = [];
|
|
23
26
|
const rows = items.map((item) => {
|
|
24
27
|
const merchant = 'merchant' in item
|
|
@@ -53,8 +56,8 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
|
53
56
|
const name = held.checkout_label ??
|
|
54
57
|
catalog.cards.find((c) => c.id === held.id)?.name ??
|
|
55
58
|
held.id;
|
|
56
|
-
// Rows where the bonus card matches the row's card, or
|
|
57
|
-
//
|
|
59
|
+
// Rows where the bonus card matches the row's card, or is close to it
|
|
60
|
+
// (as single answers mention it).
|
|
58
61
|
const matches = [];
|
|
59
62
|
const near = [];
|
|
60
63
|
for (const { label, cards } of ranked) {
|
|
@@ -62,9 +65,9 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
|
62
65
|
const own = cards.find((c) => c.cardId === held.id)?.effectiveReturnPct;
|
|
63
66
|
if (best === undefined || own === undefined)
|
|
64
67
|
continue;
|
|
65
|
-
if (best - own <
|
|
68
|
+
if (best - own < EPSILON)
|
|
66
69
|
matches.push(label.toLowerCase());
|
|
67
|
-
else if (
|
|
70
|
+
else if (isClose(own, best))
|
|
68
71
|
near.push(label.toLowerCase());
|
|
69
72
|
}
|
|
70
73
|
const where = [
|
|
@@ -97,14 +100,12 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
|
97
100
|
// The best card earnrate knows for each everyday kind of purchase, for
|
|
98
101
|
// someone who hasn't named any cards. Each row's note says what the card
|
|
99
102
|
// costs or needs.
|
|
100
|
-
|
|
103
|
+
const capitalized = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
|
104
|
+
export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCHASES) {
|
|
101
105
|
const rows = items.map((item) => {
|
|
102
106
|
const top = exploreCards(catalog, 'merchant' in item
|
|
103
107
|
? {
|
|
104
|
-
merchant: catalog
|
|
105
|
-
?.name ??
|
|
106
|
-
catalog.merchants.overrides.find((m) => m.id === item.merchant)
|
|
107
|
-
?.name ??
|
|
108
|
+
merchant: resolveMerchantById(catalog, item.merchant, null)?.resolvedName ??
|
|
108
109
|
item.label,
|
|
109
110
|
category: null,
|
|
110
111
|
amount: null,
|
|
@@ -118,11 +119,7 @@ export function catalogCheatSheet(catalog, date, options, items = CHEAT_SHEET_IT
|
|
|
118
119
|
channel: null,
|
|
119
120
|
date,
|
|
120
121
|
}, options).ranked[0];
|
|
121
|
-
const fee = top
|
|
122
|
-
? null
|
|
123
|
-
: top.annualFee === 0
|
|
124
|
-
? 'No annual fee.'
|
|
125
|
-
: `$${String(top.annualFee)} annual fee.`;
|
|
122
|
+
const fee = top ? `${capitalized(feeText(top))}.` : null;
|
|
126
123
|
return {
|
|
127
124
|
label: item.label,
|
|
128
125
|
cardId: top?.cardId ?? null,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Returns closer than this are equal: floating-point noise, not a difference.
|
|
2
|
+
export const EPSILON = 1e-9;
|
|
3
|
+
// A card close to the best one: within a percentage point, and at least
|
|
4
|
+
// three quarters of its rate. Answers and cheat sheets mention a welcome
|
|
5
|
+
// bonus card when it's this close.
|
|
6
|
+
export function isClose(own, best) {
|
|
7
|
+
return best - own <= 1 && own >= best * 0.75;
|
|
8
|
+
}
|
package/dist/engine/explore.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
2
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
|
-
import { membershipNeeded, paidMemberships } from "./memberships.js";
|
|
3
|
+
import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
|
|
4
4
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
5
5
|
import { recommend } from "./recommend.js";
|
|
6
6
|
import { rotationGap } from "./rotation.js";
|
|
7
7
|
import { membershipsUsed } from "./setup.js";
|
|
8
|
-
import {
|
|
8
|
+
import { optionName } from "./wallet-summary.js";
|
|
9
|
+
import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, money, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
9
10
|
import { cardValuation } from "./valuation.js";
|
|
10
|
-
|
|
11
|
+
import { EPSILON } from "./compare.js";
|
|
11
12
|
export const ALL_PERSONAL_CARDS = {
|
|
12
13
|
kind: 'any',
|
|
13
14
|
business: false,
|
|
@@ -48,18 +49,6 @@ export function idealHolding(card, date, picks = {}) {
|
|
|
48
49
|
: {}),
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
|
-
const PICK_PERIODS = {
|
|
52
|
-
month: 'set each month',
|
|
53
|
-
quarter: 'picked each quarter',
|
|
54
|
-
calendar_year: 'picked each year',
|
|
55
|
-
until_changed: 'until you change it',
|
|
56
|
-
};
|
|
57
|
-
const CAP_PERIODS = {
|
|
58
|
-
month: 'a month',
|
|
59
|
-
quarter: 'a quarter',
|
|
60
|
-
calendar_year: 'a calendar year',
|
|
61
|
-
cardmember_year: 'a cardmember year',
|
|
62
|
-
};
|
|
63
52
|
const plainWords = (s) => s
|
|
64
53
|
.toLowerCase()
|
|
65
54
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
@@ -148,17 +137,17 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
|
|
|
148
137
|
: null,
|
|
149
138
|
};
|
|
150
139
|
}
|
|
151
|
-
function conditionsText(catalog, r) {
|
|
140
|
+
function conditionsText(catalog, card, r) {
|
|
152
141
|
const conditions = [];
|
|
153
142
|
if (r.pick) {
|
|
154
|
-
conditions.push(`Needs ${r.pick.options.map(
|
|
143
|
+
conditions.push(`Needs ${r.pick.options.map((o) => optionName(catalog, card, r.pick?.choice ?? '', o)).join(' or ')} as its ${choiceName(r.pick.choice)} pick (${r.pick.period === 'until_changed' ? '' : 'picked '}${PICK_PERIODS[r.pick.period]}).`);
|
|
155
144
|
}
|
|
156
145
|
if (r.activation !== null)
|
|
157
146
|
conditions.push('Needs its bonus activated with the issuer (free).');
|
|
158
147
|
if (r.membership !== null)
|
|
159
|
-
conditions.push(`
|
|
148
|
+
conditions.push(`With your ${membershipName(catalog, r.membership)}.`);
|
|
160
149
|
if (r.cap) {
|
|
161
|
-
conditions.push(`Up to ${money(r.cap.amount)} ${CAP_PERIODS[r.cap.period]
|
|
150
|
+
conditions.push(`Up to ${money(r.cap.amount)} ${CAP_PERIODS[r.cap.period]} at this rate, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}.`);
|
|
162
151
|
}
|
|
163
152
|
return conditions;
|
|
164
153
|
}
|
|
@@ -243,8 +232,8 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
243
232
|
picks,
|
|
244
233
|
requirements,
|
|
245
234
|
conditions: [
|
|
246
|
-
...conditionsText(catalog, requirements),
|
|
247
|
-
...membershipNeeded(card, paid),
|
|
235
|
+
...conditionsText(catalog, card, requirements),
|
|
236
|
+
...membershipNeeded(catalog, card, paid),
|
|
248
237
|
],
|
|
249
238
|
evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
|
|
250
239
|
withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
|