earnrate 0.1.4 → 0.2.1
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 +52 -17
- package/data/CHANGELOG.md +8 -0
- package/data/VERSION +1 -1
- package/data/categories.yaml +22 -7
- package/dist/brand.js +2 -3
- package/dist/cli/args.js +67 -45
- package/dist/cli/cheatsheet.js +36 -35
- package/dist/cli/explore.js +47 -163
- package/dist/cli/import.js +3 -8
- package/dist/cli/offers.js +14 -56
- package/dist/cli/run.js +90 -20
- package/dist/cli/wallet.js +47 -6
- package/dist/data/file-store.js +95 -0
- package/dist/data/load.js +10 -2
- package/dist/data/store.js +92 -0
- package/dist/data/wallet-edit.js +93 -4
- package/dist/engine/bonus.js +15 -12
- package/dist/engine/cheat-sheet.js +59 -4
- package/dist/engine/evaluate.js +34 -10
- package/dist/engine/explore.js +16 -28
- package/dist/engine/offers.js +8 -21
- package/dist/engine/periods.js +9 -0
- package/dist/engine/recommend.js +17 -53
- package/dist/engine/record.js +34 -4
- package/dist/engine/simulate.js +33 -19
- package/dist/engine/text.js +53 -0
- package/dist/engine/transient.js +26 -0
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +36 -15
- package/dist/engine/wallet-summary.js +4 -8
- package/dist/format/card-text.js +79 -0
- package/dist/format/cheat-sheet.js +19 -0
- package/dist/format/explore.js +114 -0
- package/dist/format/offers.js +33 -0
- package/dist/{cli/format.js → format/recommendation.js} +9 -11
- package/dist/format/wallet-yaml.js +45 -0
- package/dist/format/wallet.js +38 -0
- package/dist/import/analyze.js +17 -19
- package/dist/mcp/context.js +92 -0
- package/dist/mcp/inputs.js +148 -0
- package/dist/mcp/schemas.js +8 -2
- package/dist/mcp/serve.js +3 -1
- package/dist/mcp/server.js +43 -660
- package/dist/mcp/tools/account.js +71 -0
- package/dist/mcp/tools/answers.js +329 -0
- package/dist/mcp/tools/wallet.js +128 -0
- package/dist/merchants/from-env.js +1 -1
- package/dist/merchants/resolve.js +3 -3
- package/dist/schema/catalog.js +1 -0
- package/package.json +8 -3
- package/dist/data/wallet-setup.js +0 -60
- package/dist/data/wallet-write.js +0 -140
- /package/dist/{cli → format}/cards.js +0 -0
package/dist/engine/explore.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
2
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
|
-
import { periodKey } from "./periods.js";
|
|
4
|
-
import {
|
|
3
|
+
import { choicePeriod, periodKey } from "./periods.js";
|
|
4
|
+
import { recommend } from "./recommend.js";
|
|
5
5
|
import { rotationGap } from "./rotation.js";
|
|
6
6
|
import { membershipsUsed } from "./setup.js";
|
|
7
|
+
import { categoryPhrase, codesAs, earnText, money, pointValuesText, } from "./text.js";
|
|
7
8
|
import { cardValuation } from "./valuation.js";
|
|
8
9
|
const EPSILON = 1e-9;
|
|
9
10
|
export const ALL_PERSONAL_CARDS = {
|
|
@@ -29,18 +30,11 @@ export function matchesFilters(card, filters) {
|
|
|
29
30
|
return false;
|
|
30
31
|
return true;
|
|
31
32
|
}
|
|
32
|
-
// The period a card's picks are keyed by in a wallet.
|
|
33
|
-
export function choicePeriod(card) {
|
|
34
|
-
for (const c of card.choices)
|
|
35
|
-
if (c.period !== 'until_changed')
|
|
36
|
-
return c.period;
|
|
37
|
-
return 'quarter';
|
|
38
|
-
}
|
|
39
33
|
// A card as its holder would set it up for the best result: the given picks,
|
|
40
34
|
// every activation done, and no cap used yet. The anniversary is `date`, so a
|
|
41
35
|
// cardmember-year cap starts fresh.
|
|
42
36
|
export function idealHolding(card, date, picks = {}) {
|
|
43
|
-
const period = choicePeriod(card);
|
|
37
|
+
const period = choicePeriod(card) ?? 'calendar_year';
|
|
44
38
|
return {
|
|
45
39
|
id: card.id,
|
|
46
40
|
anniversary: date.slice(5),
|
|
@@ -92,7 +86,7 @@ export function categoryResolution(catalog, category, channel) {
|
|
|
92
86
|
ambiguity: null,
|
|
93
87
|
channel,
|
|
94
88
|
confidence: 'high',
|
|
95
|
-
source: '
|
|
89
|
+
source: 'category',
|
|
96
90
|
bookedVia: [],
|
|
97
91
|
inference: null,
|
|
98
92
|
};
|
|
@@ -163,7 +157,7 @@ function conditionsText(catalog, r) {
|
|
|
163
157
|
if (r.membership !== null)
|
|
164
158
|
conditions.push(`Needs ${r.membership}.`);
|
|
165
159
|
if (r.cap) {
|
|
166
|
-
conditions.push(`Up to ${
|
|
160
|
+
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)}.`);
|
|
167
161
|
}
|
|
168
162
|
return conditions;
|
|
169
163
|
}
|
|
@@ -198,10 +192,11 @@ function compareExplored(a, b) {
|
|
|
198
192
|
}
|
|
199
193
|
// Ranks every card in the catalog that passes the filters for one purchase,
|
|
200
194
|
// each set up at its best: the pick that covers it, activated, cap unused.
|
|
201
|
-
export function exploreCards(catalog, query, options) {
|
|
202
|
-
const merchant =
|
|
203
|
-
|
|
204
|
-
|
|
195
|
+
export function exploreCards(catalog, query, options, resolved) {
|
|
196
|
+
const merchant = resolved ??
|
|
197
|
+
(query.merchant !== null
|
|
198
|
+
? resolveMerchant(catalog, query.merchant, query.channel)
|
|
199
|
+
: categoryResolution(catalog, query.category ?? 'other', query.channel));
|
|
205
200
|
const purchase = {
|
|
206
201
|
merchant: merchant.input,
|
|
207
202
|
amount: query.amount,
|
|
@@ -257,26 +252,19 @@ export function exploreCards(catalog, query, options) {
|
|
|
257
252
|
}
|
|
258
253
|
function exploreAssumptions(catalog, merchant, ranked, options) {
|
|
259
254
|
const assumptions = [];
|
|
260
|
-
if (merchant.source === '
|
|
255
|
+
if (merchant.source === 'category') {
|
|
261
256
|
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
262
257
|
}
|
|
263
258
|
else if (merchant.source !== 'none') {
|
|
264
|
-
|
|
265
|
-
assumptions.push(`Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`);
|
|
259
|
+
assumptions.push(codesAs(catalog, merchant));
|
|
266
260
|
}
|
|
267
261
|
assumptions.push('Assumes each card is set up for this purchase: the right category pick, any activation done, and none of its cap used yet. A card you hold may differ.');
|
|
268
262
|
assumptions.push(options.memberships.length === 0
|
|
269
263
|
? 'Assumes no memberships (like Amazon Prime). Rates that need one are listed separately.'
|
|
270
264
|
: `Assumes these memberships: ${options.memberships.join(', ')}.`);
|
|
271
|
-
const values =
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
values.set(e.currency, e.centsPerUnit);
|
|
275
|
-
}
|
|
276
|
-
if (values.size > 0) {
|
|
277
|
-
const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
|
|
278
|
-
assumptions.push(`Values points at: ${list.join(', ')}.`);
|
|
279
|
-
}
|
|
265
|
+
const values = pointValuesText(catalog, ranked.slice(0, 10));
|
|
266
|
+
if (values !== null)
|
|
267
|
+
assumptions.push(values);
|
|
280
268
|
return assumptions;
|
|
281
269
|
}
|
|
282
270
|
function exploreWarnings(catalog, merchant) {
|
package/dist/engine/offers.js
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
|
+
import { bonusDollars } from "./bonus.js";
|
|
2
|
+
import { matchesFilters } from "./explore.js";
|
|
3
|
+
import { roundCents } from "./text.js";
|
|
1
4
|
import { cardValuation } from "./valuation.js";
|
|
2
|
-
const round = (n) => Math.round(n * 100) / 100;
|
|
3
|
-
function bonusValue(card, centsPerUnit, points, dollars) {
|
|
4
|
-
if (points !== undefined)
|
|
5
|
-
return (points * centsPerUnit) / 100;
|
|
6
|
-
if (dollars === undefined)
|
|
7
|
-
return 0;
|
|
8
|
-
// A cash back card's bonus is paid like its rewards, so it pools when they
|
|
9
|
-
// do; a points card's dollar bonus is a statement credit.
|
|
10
|
-
return card.currency === 'cash' ? dollars * centsPerUnit : dollars;
|
|
11
|
-
}
|
|
12
5
|
export function compareOffers(catalog, wallet, filters, today) {
|
|
13
6
|
const held = new Set(wallet.cards.map((c) => c.id));
|
|
14
7
|
const offers = [];
|
|
@@ -16,16 +9,9 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
16
9
|
for (const card of catalog.cards) {
|
|
17
10
|
if (card.network === 'store')
|
|
18
11
|
continue;
|
|
19
|
-
if (card.business !== filters.business)
|
|
20
|
-
continue;
|
|
21
12
|
if (!filters.includeHeld && held.has(card.id))
|
|
22
13
|
continue;
|
|
23
|
-
|
|
24
|
-
if (filters.kind === 'cash' && !isCash)
|
|
25
|
-
continue;
|
|
26
|
-
if (filters.kind === 'points' && isCash)
|
|
27
|
-
continue;
|
|
28
|
-
if (filters.noAnnualFee && card.annual_fee !== 0)
|
|
14
|
+
if (!matchesFilters(card, { ...filters, issuer: null, includeClosed: true }))
|
|
29
15
|
continue;
|
|
30
16
|
const offer = card.welcome_offer;
|
|
31
17
|
if (!offer || (offer.valid_to !== undefined && offer.valid_to < today)) {
|
|
@@ -33,7 +19,8 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
33
19
|
continue;
|
|
34
20
|
}
|
|
35
21
|
const valuation = cardValuation(catalog, wallet, card);
|
|
36
|
-
const value =
|
|
22
|
+
const value = bonusDollars(card, valuation.centsPerUnit, offer.points, offer.dollars) ??
|
|
23
|
+
0;
|
|
37
24
|
const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
|
|
38
25
|
offers.push({
|
|
39
26
|
cardId: card.id,
|
|
@@ -50,8 +37,8 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
50
37
|
note: offer.note ?? null,
|
|
51
38
|
annualFee: card.annual_fee ?? null,
|
|
52
39
|
firstYearFee,
|
|
53
|
-
bonusValue:
|
|
54
|
-
firstYearValue:
|
|
40
|
+
bonusValue: roundCents(value),
|
|
41
|
+
firstYearValue: roundCents(value - (firstYearFee ?? 0)),
|
|
55
42
|
held: held.has(card.id),
|
|
56
43
|
lastVerified: card.last_verified,
|
|
57
44
|
verifiedBy: card.verified_by,
|
package/dist/engine/periods.js
CHANGED
|
@@ -29,3 +29,12 @@ export function localToday(now = new Date()) {
|
|
|
29
29
|
String(now.getDate()).padStart(2, '0'),
|
|
30
30
|
].join('-');
|
|
31
31
|
}
|
|
32
|
+
// The period a card's picks are filed under in a wallet. Picks that carry
|
|
33
|
+
// over (until_changed) are never compared, so they file under the calendar
|
|
34
|
+
// year. null when the card's picks reset on different schedules.
|
|
35
|
+
export function choicePeriod(card) {
|
|
36
|
+
const periods = [
|
|
37
|
+
...new Set(card.choices.map((c) => c.period).filter((p) => p !== 'until_changed')),
|
|
38
|
+
];
|
|
39
|
+
return periods.length > 1 ? null : (periods[0] ?? 'calendar_year');
|
|
40
|
+
}
|
package/dist/engine/recommend.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { resolveMerchant, resolveMerchantById } from "../merchants/resolve.js";
|
|
2
|
-
import { evaluateCard, purchaseContext, } from "./evaluate.js";
|
|
3
2
|
import { bonusStatus } from "./bonus.js";
|
|
3
|
+
import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
5
|
import { rotationGap } from "./rotation.js";
|
|
6
|
+
import { categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, num, pointValuesText, } from "./text.js";
|
|
6
7
|
import { cardValuation } from "./valuation.js";
|
|
7
8
|
const EPSILON = 1e-9;
|
|
8
9
|
export const STALE_AFTER_DAYS = 180;
|
|
@@ -20,36 +21,9 @@ const PERIOD_WORDS = {
|
|
|
20
21
|
cardmember_year: 'cardmember-year',
|
|
21
22
|
};
|
|
22
23
|
const displayName = (e) => e.held.checkout_label ?? e.card.name;
|
|
23
|
-
export const num = (n) => String(Number(n.toFixed(2)));
|
|
24
|
-
// Whole dollars stay whole ($2,800); anything else shows cents ($0.40).
|
|
25
|
-
const money = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
26
|
-
export const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
|
|
27
|
-
export function currencyName(catalog, id) {
|
|
28
|
-
return catalog.currencies.find((c) => c.id === id)?.name ?? id;
|
|
29
|
-
}
|
|
30
|
-
export function currencyLabel(catalog, id) {
|
|
31
|
-
const name = currencyName(catalog, id);
|
|
32
|
-
return /^[A-Z]{2,4}$/.test(id) ? `${name} (${id})` : name;
|
|
33
|
-
}
|
|
34
24
|
// "Worth X or more" must hold at X itself, so round up; "less than X" rounds down.
|
|
35
25
|
const roundUp = (n) => String(Math.ceil(n * 100 - EPSILON) / 100);
|
|
36
26
|
const roundDown = (n) => String(Math.floor(n * 100 + EPSILON) / 100);
|
|
37
|
-
function categoryName(catalog, id) {
|
|
38
|
-
return catalog.categories.find((c) => c.id === id)?.name ?? id;
|
|
39
|
-
}
|
|
40
|
-
// Mid-sentence form: "Supermarkets" becomes "supermarkets", but "TV and
|
|
41
|
-
// internet service" and "EV charging" keep their acronyms.
|
|
42
|
-
export function categoryPhrase(catalog, id) {
|
|
43
|
-
const name = categoryName(catalog, id);
|
|
44
|
-
return /^[A-Z]{2}/.test(name)
|
|
45
|
-
? name
|
|
46
|
-
: name.charAt(0).toLowerCase() + name.slice(1);
|
|
47
|
-
}
|
|
48
|
-
export function earnText(catalog, rate, currency) {
|
|
49
|
-
return currency === 'cash'
|
|
50
|
-
? `${num(rate)}% cash back`
|
|
51
|
-
: `${num(rate)}x ${currencyName(catalog, currency)}`;
|
|
52
|
-
}
|
|
53
27
|
function compareEntries(a, b) {
|
|
54
28
|
const byReturn = b.effectiveReturnPct - a.effectiveReturnPct;
|
|
55
29
|
if (Math.abs(byReturn) > EPSILON)
|
|
@@ -71,14 +45,11 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
71
45
|
const card = catalog.cards.find((c) => c.id === held.id);
|
|
72
46
|
if (!card)
|
|
73
47
|
continue;
|
|
74
|
-
if (card
|
|
75
|
-
|
|
76
|
-
|
|
48
|
+
if (!usableAt(card, merchant)) {
|
|
49
|
+
// A store card elsewhere isn't worth mentioning; a network the merchant
|
|
50
|
+
// doesn't take is.
|
|
51
|
+
if (card.only_at || card.network === 'store' || !merchant.accepts)
|
|
77
52
|
continue;
|
|
78
|
-
}
|
|
79
|
-
else if (card.network !== 'store' &&
|
|
80
|
-
merchant.accepts &&
|
|
81
|
-
!merchant.accepts.includes(card.network)) {
|
|
82
53
|
const takes = merchant.accepts
|
|
83
54
|
.map((n) => NETWORK_NAMES[n] ?? n)
|
|
84
55
|
.join(' or ');
|
|
@@ -126,7 +97,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
126
97
|
const cap = best.cap;
|
|
127
98
|
if (cap && amount !== null && amount > cap.remaining) {
|
|
128
99
|
const over = amount - cap.remaining;
|
|
129
|
-
lines.push(`${
|
|
100
|
+
lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
|
|
130
101
|
}
|
|
131
102
|
if (valuation.pooledFrom !== null) {
|
|
132
103
|
const partners = card.pools_into?.when_wallet_has ?? [];
|
|
@@ -147,7 +118,7 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
147
118
|
const { best, nominal } = evaluation;
|
|
148
119
|
const warnings = [];
|
|
149
120
|
const capLabel = (e) => e.cap
|
|
150
|
-
? `${
|
|
121
|
+
? `${money(e.cap.cap.amount)} ${PERIOD_WORDS[e.cap.cap.period] ?? ''} cap`
|
|
151
122
|
: '';
|
|
152
123
|
if (best.cap && best.rule) {
|
|
153
124
|
const { remaining, cap, period } = best.cap;
|
|
@@ -155,10 +126,10 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
155
126
|
warnings.push(`Add this card's anniversary (MM-DD) to your wallet so its ${capLabel(best)} can be tracked.`);
|
|
156
127
|
}
|
|
157
128
|
else if (purchase.amount !== null && purchase.amount > remaining) {
|
|
158
|
-
warnings.push(`This purchase crosses the ${capLabel(best)} on "${best.rule.id}": only ${
|
|
129
|
+
warnings.push(`This purchase crosses the ${capLabel(best)} on "${best.rule.id}": only ${money(remaining)} is left for ${period}.`);
|
|
159
130
|
}
|
|
160
131
|
else if (remaining < cap.amount * NEAR_CAP_SHARE) {
|
|
161
|
-
warnings.push(`Only ${
|
|
132
|
+
warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on "${best.rule.id}" for ${period}.`);
|
|
162
133
|
}
|
|
163
134
|
}
|
|
164
135
|
if (nominal.rule &&
|
|
@@ -258,15 +229,14 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
258
229
|
if (merchant.source === 'none') {
|
|
259
230
|
assumptions.push(`Treats "${merchant.input}" as an everyday purchase with no bonus category.`);
|
|
260
231
|
}
|
|
232
|
+
else if (merchant.source === 'category') {
|
|
233
|
+
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
234
|
+
}
|
|
261
235
|
else if (merchant.category === 'other') {
|
|
262
236
|
assumptions.push(`Assumes ${merchant.resolvedName} falls outside the usual bonus categories.`);
|
|
263
237
|
}
|
|
264
238
|
else {
|
|
265
|
-
|
|
266
|
-
const mcc = merchant.likelyMcc ??
|
|
267
|
-
(category?.mccs.length === 1 ? category.mccs[0] : undefined);
|
|
268
|
-
const code = mcc === undefined ? '' : ` (MCC ${String(mcc)})`;
|
|
269
|
-
assumptions.push(`Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`);
|
|
239
|
+
assumptions.push(codesAs(catalog, merchant));
|
|
270
240
|
}
|
|
271
241
|
if (merchant.inference) {
|
|
272
242
|
assumptions.push(`Claude's reasoning: ${merchant.inference.rationale}`);
|
|
@@ -277,15 +247,9 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
277
247
|
if (purchase.channel === null && merchant.channel === 'online') {
|
|
278
248
|
assumptions.push('Assumes an online purchase, since you gave a web address.');
|
|
279
249
|
}
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
pointValues.set(e.valuation.currency, e.valuation.centsPerUnit);
|
|
284
|
-
}
|
|
285
|
-
if (pointValues.size > 0) {
|
|
286
|
-
const list = [...pointValues].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
|
|
287
|
-
assumptions.push(`Values points at: ${list.join(', ')}.`);
|
|
288
|
-
}
|
|
250
|
+
const values = pointValuesText(catalog, entries.map((e) => e.valuation));
|
|
251
|
+
if (values !== null)
|
|
252
|
+
assumptions.push(values);
|
|
289
253
|
return assumptions;
|
|
290
254
|
}
|
|
291
255
|
export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
|
package/dist/engine/record.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
|
+
import { bonusStatus, countsTowardBonus } from "./bonus.js";
|
|
2
3
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
|
-
|
|
4
|
+
import { roundCents } from "./text.js";
|
|
4
5
|
// Books the spend against the cap of the highest-rate rule that matches, even
|
|
5
6
|
// when that cap is already used up, so usage stays accurate across a reset.
|
|
6
7
|
// Returns null when there's nothing to track: no cap, or a cardmember-year cap
|
|
@@ -49,7 +50,36 @@ export function applyBooking(wallet, booking) {
|
|
|
49
50
|
: c),
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
// The purchase's progress toward the card's open welcome bonus, if it counts.
|
|
54
|
+
function bonusBooking(catalog, wallet, purchase) {
|
|
55
|
+
const held = wallet.cards.find((c) => c.id === purchase.cardId);
|
|
56
|
+
const status = held ? bonusStatus(catalog, wallet, held, purchase.date) : null;
|
|
57
|
+
if (!status ||
|
|
58
|
+
status.spend === null ||
|
|
59
|
+
(status.remaining ?? 0) <= 0 ||
|
|
60
|
+
!countsTowardBonus(status, purchase.date))
|
|
61
|
+
return null;
|
|
62
|
+
return {
|
|
63
|
+
cardId: purchase.cardId,
|
|
64
|
+
before: status.spent,
|
|
65
|
+
after: roundCents(status.spent + purchase.amount),
|
|
66
|
+
spend: status.spend,
|
|
67
|
+
deadline: status.deadline,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function applyBonusBooking(wallet, b) {
|
|
71
|
+
return {
|
|
72
|
+
...wallet,
|
|
73
|
+
cards: wallet.cards.map((c) => c.id === b.cardId && c.bonus
|
|
74
|
+
? { ...c, bonus: { ...c.bonus, spent: b.after } }
|
|
75
|
+
: c),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function bookPurchase(catalog, wallet, purchase, merchant) {
|
|
79
|
+
const cap = capBooking(catalog, wallet, purchase, merchant);
|
|
80
|
+
const bonus = bonusBooking(catalog, wallet, purchase);
|
|
81
|
+
let next = cap ? applyBooking(wallet, cap) : wallet;
|
|
82
|
+
if (bonus)
|
|
83
|
+
next = applyBonusBooking(next, bonus);
|
|
84
|
+
return { wallet: next, cap, bonus };
|
|
55
85
|
}
|
package/dist/engine/simulate.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
|
-
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
|
-
import { categoryResolution,
|
|
4
|
-
import { periodKey } from "./periods.js";
|
|
2
|
+
import { evaluateCard, purchaseContext, usableAt } from "./evaluate.js";
|
|
3
|
+
import { categoryResolution, findCategory, idealHolding, matchesFilters, } from "./explore.js";
|
|
4
|
+
import { choicePeriod, periodKey } from "./periods.js";
|
|
5
5
|
import { applyBooking, capBooking } from "./record.js";
|
|
6
6
|
import { rotationGap } from "./rotation.js";
|
|
7
|
+
import { roundCents } from "./text.js";
|
|
7
8
|
import { cardValuation } from "./valuation.js";
|
|
8
9
|
const EPSILON = 1e-9;
|
|
9
10
|
const MONTHS = 12;
|
|
10
11
|
const SHORTLIST = 8; // best single cards considered for pairs
|
|
11
12
|
const PER_LINE = 2; // plus the best cards for each spending line
|
|
12
|
-
const round = (n) => Math.round(n * 100) / 100;
|
|
13
13
|
export function resolveSpendLines(catalog, lines) {
|
|
14
14
|
return lines
|
|
15
15
|
.filter((l) => l.monthly > 0)
|
|
@@ -37,17 +37,10 @@ function withCurrentPicks(card, held, date) {
|
|
|
37
37
|
...held,
|
|
38
38
|
chosen: {
|
|
39
39
|
...held.chosen,
|
|
40
|
-
period: periodKey(choicePeriod(card), date, null) ?? '',
|
|
40
|
+
period: periodKey(choicePeriod(card) ?? 'calendar_year', date, null) ?? '',
|
|
41
41
|
},
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
|
-
function usableAt(card, merchant) {
|
|
45
|
-
if (card.only_at)
|
|
46
|
-
return (merchant.merchantId !== null && card.only_at.includes(merchant.merchantId));
|
|
47
|
-
return (card.network === 'store' ||
|
|
48
|
-
merchant.accepts === null ||
|
|
49
|
-
merchant.accepts.includes(card.network));
|
|
50
|
-
}
|
|
51
44
|
// Spends `lines` every month for a year. Each dollar goes to the card that
|
|
52
45
|
// earns the most on it right then, so spend moves to another card once a
|
|
53
46
|
// cap runs out.
|
|
@@ -119,13 +112,13 @@ export function simulateWallet(catalog, wallet, lines, start) {
|
|
|
119
112
|
};
|
|
120
113
|
return {
|
|
121
114
|
label: line.label,
|
|
122
|
-
spend:
|
|
123
|
-
earned:
|
|
115
|
+
spend: roundCents(r.spend),
|
|
116
|
+
earned: roundCents(r.earned),
|
|
124
117
|
returnPct: r.spend > 0 ? (r.earned / r.spend) * 100 : 0,
|
|
125
118
|
cards: [...r.byCard].sort((a, b) => b[1] - a[1]).map(([id]) => id),
|
|
126
119
|
};
|
|
127
120
|
});
|
|
128
|
-
const rewards =
|
|
121
|
+
const rewards = roundCents(lineResults.reduce((sum, l) => sum + l.earned, 0));
|
|
129
122
|
const annualFees = cards.reduce((sum, c) => sum + (c.card.annual_fee ?? 0), 0);
|
|
130
123
|
const notes = [];
|
|
131
124
|
const dates = monthDates(start);
|
|
@@ -146,10 +139,10 @@ export function simulateWallet(catalog, wallet, lines, start) {
|
|
|
146
139
|
cardId: h.id,
|
|
147
140
|
picks: h.chosen?.picks ?? {},
|
|
148
141
|
})),
|
|
149
|
-
spend:
|
|
142
|
+
spend: roundCents(lineResults.reduce((sum, l) => sum + l.spend, 0)),
|
|
150
143
|
rewards,
|
|
151
144
|
annualFees,
|
|
152
|
-
net:
|
|
145
|
+
net: roundCents(rewards - annualFees),
|
|
153
146
|
lines: lineResults,
|
|
154
147
|
notes,
|
|
155
148
|
};
|
|
@@ -293,17 +286,38 @@ export function simulateAdditions(catalog, wallet, lines, options) {
|
|
|
293
286
|
memberships: wallet.memberships,
|
|
294
287
|
valuations: wallet.valuations,
|
|
295
288
|
});
|
|
296
|
-
const extraRewards =
|
|
289
|
+
const extraRewards = roundCents(simulation.rewards - current.rewards);
|
|
297
290
|
const annualFee = card.annual_fee ?? 0;
|
|
298
291
|
additions.push({
|
|
299
292
|
cardId: card.id,
|
|
300
293
|
picks: simulation.cards.at(-1)?.picks ?? {},
|
|
301
294
|
extraRewards,
|
|
302
295
|
annualFee,
|
|
303
|
-
gain:
|
|
296
|
+
gain: roundCents(extraRewards - annualFee),
|
|
304
297
|
simulation,
|
|
305
298
|
});
|
|
306
299
|
}
|
|
307
300
|
additions.sort((a, b) => b.gain - a.gain || a.annualFee - b.annualFee);
|
|
308
301
|
return { current, additions };
|
|
309
302
|
}
|
|
303
|
+
// Rough monthly amounts for a U.S. household: the starting point when someone
|
|
304
|
+
// hasn't said what they spend. Answers built on them say so.
|
|
305
|
+
export const TYPICAL_MONTHLY_SPEND = [
|
|
306
|
+
{ what: 'Groceries', monthly: 500 },
|
|
307
|
+
{ what: 'Dining', monthly: 250 },
|
|
308
|
+
{ what: 'Gas', monthly: 150 },
|
|
309
|
+
{ what: 'Amazon', monthly: 100 },
|
|
310
|
+
{ what: 'Streaming', monthly: 40 },
|
|
311
|
+
{ what: 'Travel', monthly: 150 },
|
|
312
|
+
{ what: 'Everything else', monthly: 800 },
|
|
313
|
+
];
|
|
314
|
+
// A spending line from everyday words: a category when one matches
|
|
315
|
+
// ("groceries"), otherwise a merchant ("Amazon").
|
|
316
|
+
export function spendLineFor(catalog, what, monthly) {
|
|
317
|
+
const category = findCategory(catalog, what);
|
|
318
|
+
return { merchant: category === null ? what : null, category, monthly };
|
|
319
|
+
}
|
|
320
|
+
export function typicalSpendLines(catalog) {
|
|
321
|
+
return resolveSpendLines(catalog, TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly)));
|
|
322
|
+
}
|
|
323
|
+
export const TYPICAL_SPEND_NOTE = `No spending was given, so this uses rough typical monthly amounts for a U.S. household: ${TYPICAL_MONTHLY_SPEND.map((t) => `${t.what} $${String(t.monthly)}`).join(', ')}. Give your own amounts for a closer answer.`;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { singleMcc } from "./evaluate.js";
|
|
2
|
+
export const num = (n) => String(Number(n.toFixed(2)));
|
|
3
|
+
export const pct = (n) => `${num(n)}%`;
|
|
4
|
+
export const roundCents = (n) => Math.round(n * 100) / 100;
|
|
5
|
+
// Whole dollars stay whole ($2,800); anything else shows cents ($0.40).
|
|
6
|
+
export const money = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
7
|
+
export function feeText(fees) {
|
|
8
|
+
const { annualFee, firstYearFee } = fees;
|
|
9
|
+
if (annualFee === null)
|
|
10
|
+
return 'annual fee not on file';
|
|
11
|
+
if (annualFee === 0)
|
|
12
|
+
return 'no annual fee';
|
|
13
|
+
if (firstYearFee !== null && firstYearFee !== annualFee)
|
|
14
|
+
return `${money(annualFee)} annual fee (${firstYearFee === 0 ? 'waived' : money(firstYearFee)} the first year)`;
|
|
15
|
+
return `${money(annualFee)} annual fee`;
|
|
16
|
+
}
|
|
17
|
+
export function currencyName(catalog, id) {
|
|
18
|
+
return catalog.currencies.find((c) => c.id === id)?.name ?? id;
|
|
19
|
+
}
|
|
20
|
+
export function currencyLabel(catalog, id) {
|
|
21
|
+
const name = currencyName(catalog, id);
|
|
22
|
+
return /^[A-Z]{2,4}$/.test(id) ? `${name} (${id})` : name;
|
|
23
|
+
}
|
|
24
|
+
// Mid-sentence form: "Supermarkets" becomes "supermarkets", but "TV and
|
|
25
|
+
// internet service" and "EV charging" keep their acronyms.
|
|
26
|
+
export function categoryPhrase(catalog, id) {
|
|
27
|
+
const name = catalog.categories.find((c) => c.id === id)?.name ?? id;
|
|
28
|
+
return /^[A-Z]{2}/.test(name)
|
|
29
|
+
? name
|
|
30
|
+
: name.charAt(0).toLowerCase() + name.slice(1);
|
|
31
|
+
}
|
|
32
|
+
export function earnText(catalog, rate, currency) {
|
|
33
|
+
return currency === 'cash'
|
|
34
|
+
? `${num(rate)}% cash back`
|
|
35
|
+
: `${num(rate)}x ${currencyName(catalog, currency)}`;
|
|
36
|
+
}
|
|
37
|
+
export function codesAs(catalog, merchant) {
|
|
38
|
+
const mcc = merchant.likelyMcc ?? singleMcc(catalog, merchant.category);
|
|
39
|
+
const code = mcc === null ? '' : ` (MCC ${String(mcc)})`;
|
|
40
|
+
return `Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`;
|
|
41
|
+
}
|
|
42
|
+
// "Values points at: Membership Rewards (MR) at 1¢." for the currencies an
|
|
43
|
+
// answer ranks, or null when every card shown earns cash.
|
|
44
|
+
export function pointValuesText(catalog, shown) {
|
|
45
|
+
const values = new Map();
|
|
46
|
+
for (const e of shown)
|
|
47
|
+
if (e.currency !== 'cash')
|
|
48
|
+
values.set(e.currency, e.centsPerUnit);
|
|
49
|
+
if (values.size === 0)
|
|
50
|
+
return null;
|
|
51
|
+
const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
|
|
52
|
+
return `Values points at: ${list.join(', ')}.`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { addToWallet, withMemberships, } from "./wallet-change.js";
|
|
2
|
+
export const EMPTY_WALLET = {
|
|
3
|
+
valuations: {},
|
|
4
|
+
memberships: [],
|
|
5
|
+
cards: [],
|
|
6
|
+
};
|
|
7
|
+
// A wallet for one answer, from cards the user named in the conversation.
|
|
8
|
+
// Nothing is saved; the same rules as adding cards to a saved wallet apply.
|
|
9
|
+
export function walletFromCards(catalog, named, today) {
|
|
10
|
+
let wallet = null;
|
|
11
|
+
for (const input of named.cards) {
|
|
12
|
+
const change = addToWallet(catalog, wallet, input, today);
|
|
13
|
+
if (!change.ok)
|
|
14
|
+
return change.error;
|
|
15
|
+
wallet = change.wallet;
|
|
16
|
+
}
|
|
17
|
+
if (!wallet)
|
|
18
|
+
return 'Name at least one card.';
|
|
19
|
+
if (named.memberships && named.memberships.length > 0) {
|
|
20
|
+
const change = withMemberships(catalog, wallet, named.memberships);
|
|
21
|
+
if (!change.ok)
|
|
22
|
+
return change.error;
|
|
23
|
+
wallet = change.wallet;
|
|
24
|
+
}
|
|
25
|
+
return wallet;
|
|
26
|
+
}
|
package/dist/engine/valuation.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { periodKey } from "./periods.js";
|
|
1
|
+
import { choicePeriod, periodKey } from "./periods.js";
|
|
2
2
|
import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
|
|
3
3
|
const failed = (error) => ({ ok: false, error });
|
|
4
4
|
const list = (ids) => ids.join(', ');
|
|
5
|
-
|
|
5
|
+
function findCatalogCard(catalog, id, today) {
|
|
6
6
|
const card = catalog.cards.find((c) => c.id === id);
|
|
7
7
|
if (card)
|
|
8
8
|
return card;
|
|
@@ -11,22 +11,16 @@ export function findCatalogCard(catalog, id, today) {
|
|
|
11
11
|
? `No card '${id}' in earnrate. Closest: ${list(near)}.`
|
|
12
12
|
: `No card '${id}' in earnrate. Search the catalog to find its id.`;
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
function isAnniversary(text) {
|
|
15
15
|
const m = /^(\d{2})-(\d{2})$/.exec(text);
|
|
16
16
|
if (!m)
|
|
17
17
|
return false;
|
|
18
18
|
const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
|
|
19
19
|
return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
|
|
20
20
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const periods = [
|
|
25
|
-
...new Set(card.choices.map((c) => c.period).filter((p) => p !== 'until_changed')),
|
|
26
|
-
];
|
|
27
|
-
if (periods.length > 1)
|
|
28
|
-
return null;
|
|
29
|
-
return periodKey(periods[0] ?? 'calendar_year', today, null);
|
|
21
|
+
function pickKey(card, today) {
|
|
22
|
+
const period = choicePeriod(card);
|
|
23
|
+
return period === null ? null : periodKey(period, today, null);
|
|
30
24
|
}
|
|
31
25
|
export function cardFields(card, held, input, today) {
|
|
32
26
|
const problems = [];
|
|
@@ -77,7 +71,7 @@ export function cardFields(card, held, input, today) {
|
|
|
77
71
|
problems.push(`${choiceId} takes ${choice.pick === 1 ? '1 pick' : `1 to ${String(choice.pick)} picks`}, not ${String(picks.length)}.`);
|
|
78
72
|
}
|
|
79
73
|
}
|
|
80
|
-
const period =
|
|
74
|
+
const period = pickKey(card, today);
|
|
81
75
|
if (period === null) {
|
|
82
76
|
problems.push(`${card.id} has picks on different schedules; edit the wallet by hand.`);
|
|
83
77
|
}
|
|
@@ -89,21 +83,48 @@ export function cardFields(card, held, input, today) {
|
|
|
89
83
|
};
|
|
90
84
|
}
|
|
91
85
|
}
|
|
86
|
+
if (input.bonus === null)
|
|
87
|
+
fields.bonus = null;
|
|
88
|
+
else if (input.bonus !== undefined) {
|
|
89
|
+
const b = input.bonus;
|
|
90
|
+
const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d) &&
|
|
91
|
+
new Date(`${d}T00:00:00Z`).toISOString().startsWith(d);
|
|
92
|
+
if (!isDate(b.opened))
|
|
93
|
+
problems.push(`"${b.opened}" isn't a date. Use YYYY-MM-DD.`);
|
|
94
|
+
else if (b.opened > today)
|
|
95
|
+
problems.push("The account's opening date is in the future.");
|
|
96
|
+
if (b.spent !== undefined && !(b.spent >= 0))
|
|
97
|
+
problems.push('Spending toward the bonus must be $0 or more.');
|
|
98
|
+
if (b.spend !== undefined && !(b.spend > 0))
|
|
99
|
+
problems.push('The spending needed must be more than $0.');
|
|
100
|
+
if (b.by !== undefined && (!isDate(b.by) || b.by < b.opened))
|
|
101
|
+
problems.push(`"${b.by}" isn't a deadline after the opening date.`);
|
|
102
|
+
if (!card.welcome_offer && (b.spend === undefined || b.by === undefined))
|
|
103
|
+
problems.push(`earnrate has no published welcome offer for ${card.id}, so give the spending needed and the deadline.`);
|
|
104
|
+
fields.bonus = {
|
|
105
|
+
opened: b.opened,
|
|
106
|
+
spent: b.spent ?? held?.bonus?.spent ?? 0,
|
|
107
|
+
...(b.spend === undefined ? {} : { spend: b.spend }),
|
|
108
|
+
...(b.by === undefined ? {} : { by: b.by }),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
92
111
|
return problems.length > 0 ? problems.join('\n') : fields;
|
|
93
112
|
}
|
|
94
|
-
|
|
113
|
+
const newHolding = (id) => ({
|
|
95
114
|
id,
|
|
96
115
|
cap_usage: {},
|
|
97
116
|
activated: [],
|
|
98
117
|
});
|
|
99
|
-
|
|
118
|
+
const withFields = ({ bonus, ...held }, f) => ({
|
|
100
119
|
...held,
|
|
120
|
+
...(bonus && f.bonus === undefined ? { bonus } : {}),
|
|
101
121
|
...(f.checkout_label === undefined
|
|
102
122
|
? {}
|
|
103
123
|
: { checkout_label: f.checkout_label }),
|
|
104
124
|
...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
|
|
105
125
|
...(f.chosen === undefined ? {} : { chosen: f.chosen }),
|
|
106
126
|
...(f.activated === undefined ? {} : { activated: f.activated }),
|
|
127
|
+
...(f.bonus ? { bonus: f.bonus } : {}),
|
|
107
128
|
});
|
|
108
129
|
// `wallet` is null when there isn't one yet; adding a card starts it.
|
|
109
130
|
export function addToWallet(catalog, wallet, input, today) {
|