earnrate 0.2.0 → 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 +22 -20
- package/dist/brand.js +2 -3
- package/dist/cli/args.js +64 -51
- package/dist/cli/cheatsheet.js +9 -12
- package/dist/cli/explore.js +28 -27
- package/dist/cli/import.js +3 -8
- package/dist/cli/offers.js +7 -6
- package/dist/cli/run.js +17 -13
- package/dist/cli/wallet.js +7 -6
- package/dist/data/file-store.js +54 -15
- package/dist/data/load.js +1 -1
- package/dist/data/store.js +0 -16
- package/dist/data/wallet-edit.js +68 -2
- package/dist/engine/bonus.js +15 -12
- package/dist/engine/cheat-sheet.js +2 -2
- package/dist/engine/evaluate.js +34 -10
- package/dist/engine/explore.js +9 -22
- package/dist/engine/offers.js +8 -21
- package/dist/engine/periods.js +9 -0
- package/dist/engine/recommend.js +14 -53
- package/dist/engine/record.js +2 -7
- package/dist/engine/simulate.js +12 -19
- package/dist/engine/text.js +53 -0
- package/dist/engine/transient.js +5 -0
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +9 -15
- package/dist/engine/wallet-summary.js +4 -8
- package/dist/format/card-text.js +79 -0
- package/dist/format/cheat-sheet.js +1 -1
- package/dist/format/explore.js +3 -13
- package/dist/format/offers.js +5 -14
- package/dist/{cli/format.js → format/recommendation.js} +2 -6
- 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 +1 -1
- package/dist/mcp/server.js +14 -818
- 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/package.json +2 -2
- 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/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 &&
|
|
@@ -265,11 +236,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
265
236
|
assumptions.push(`Assumes ${merchant.resolvedName} falls outside the usual bonus categories.`);
|
|
266
237
|
}
|
|
267
238
|
else {
|
|
268
|
-
|
|
269
|
-
const mcc = merchant.likelyMcc ??
|
|
270
|
-
(category?.mccs.length === 1 ? category.mccs[0] : undefined);
|
|
271
|
-
const code = mcc === undefined ? '' : ` (MCC ${String(mcc)})`;
|
|
272
|
-
assumptions.push(`Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`);
|
|
239
|
+
assumptions.push(codesAs(catalog, merchant));
|
|
273
240
|
}
|
|
274
241
|
if (merchant.inference) {
|
|
275
242
|
assumptions.push(`Claude's reasoning: ${merchant.inference.rationale}`);
|
|
@@ -280,15 +247,9 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
280
247
|
if (purchase.channel === null && merchant.channel === 'online') {
|
|
281
248
|
assumptions.push('Assumes an online purchase, since you gave a web address.');
|
|
282
249
|
}
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
pointValues.set(e.valuation.currency, e.valuation.centsPerUnit);
|
|
287
|
-
}
|
|
288
|
-
if (pointValues.size > 0) {
|
|
289
|
-
const list = [...pointValues].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
|
|
290
|
-
assumptions.push(`Values points at: ${list.join(', ')}.`);
|
|
291
|
-
}
|
|
250
|
+
const values = pointValuesText(catalog, entries.map((e) => e.valuation));
|
|
251
|
+
if (values !== null)
|
|
252
|
+
assumptions.push(values);
|
|
292
253
|
return assumptions;
|
|
293
254
|
}
|
|
294
255
|
export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
|
package/dist/engine/record.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
2
|
import { bonusStatus, countsTowardBonus } from "./bonus.js";
|
|
3
3
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
4
|
-
|
|
4
|
+
import { roundCents } from "./text.js";
|
|
5
5
|
// Books the spend against the cap of the highest-rate rule that matches, even
|
|
6
6
|
// when that cap is already used up, so usage stays accurate across a reset.
|
|
7
7
|
// Returns null when there's nothing to track: no cap, or a cardmember-year cap
|
|
@@ -50,12 +50,8 @@ export function applyBooking(wallet, booking) {
|
|
|
50
50
|
: c),
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
|
-
export function recordPurchase(catalog, wallet, purchase) {
|
|
54
|
-
const booking = capBooking(catalog, wallet, purchase);
|
|
55
|
-
return booking ? applyBooking(wallet, booking) : wallet;
|
|
56
|
-
}
|
|
57
53
|
// The purchase's progress toward the card's open welcome bonus, if it counts.
|
|
58
|
-
|
|
54
|
+
function bonusBooking(catalog, wallet, purchase) {
|
|
59
55
|
const held = wallet.cards.find((c) => c.id === purchase.cardId);
|
|
60
56
|
const status = held ? bonusStatus(catalog, wallet, held, purchase.date) : null;
|
|
61
57
|
if (!status ||
|
|
@@ -79,7 +75,6 @@ export function applyBonusBooking(wallet, b) {
|
|
|
79
75
|
: c),
|
|
80
76
|
};
|
|
81
77
|
}
|
|
82
|
-
// Everything recording a purchase changes: its cap and its welcome bonus.
|
|
83
78
|
export function bookPurchase(catalog, wallet, purchase, merchant) {
|
|
84
79
|
const cap = capBooking(catalog, wallet, purchase, merchant);
|
|
85
80
|
const bonus = bonusBooking(catalog, wallet, purchase);
|
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,14 +286,14 @@ 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
|
}
|
|
@@ -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
|
+
}
|
package/dist/engine/transient.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { addToWallet, withMemberships, } from "./wallet-change.js";
|
|
2
|
+
export const EMPTY_WALLET = {
|
|
3
|
+
valuations: {},
|
|
4
|
+
memberships: [],
|
|
5
|
+
cards: [],
|
|
6
|
+
};
|
|
2
7
|
// A wallet for one answer, from cards the user named in the conversation.
|
|
3
8
|
// Nothing is saved; the same rules as adding cards to a saved wallet apply.
|
|
4
9
|
export function walletFromCards(catalog, named, today) {
|
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
|
}
|
|
@@ -116,12 +110,12 @@ export function cardFields(card, held, input, today) {
|
|
|
116
110
|
}
|
|
117
111
|
return problems.length > 0 ? problems.join('\n') : fields;
|
|
118
112
|
}
|
|
119
|
-
|
|
113
|
+
const newHolding = (id) => ({
|
|
120
114
|
id,
|
|
121
115
|
cap_usage: {},
|
|
122
116
|
activated: [],
|
|
123
117
|
});
|
|
124
|
-
|
|
118
|
+
const withFields = ({ bonus, ...held }, f) => ({
|
|
125
119
|
...held,
|
|
126
120
|
...(bonus && f.bonus === undefined ? { bonus } : {}),
|
|
127
121
|
...(f.checkout_label === undefined
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { bonusStatus } from "./bonus.js";
|
|
2
|
-
import { pickedCategories } from "./evaluate.js";
|
|
3
|
-
import { daysBetween
|
|
4
|
-
import { rotationGap } from "./rotation.js";
|
|
2
|
+
import { capUsed, pickedCategories } from "./evaluate.js";
|
|
3
|
+
import { daysBetween } from "./periods.js";
|
|
5
4
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
5
|
+
import { rotationGap } from "./rotation.js";
|
|
6
6
|
import { cardValuation } from "./valuation.js";
|
|
7
7
|
function capSummaries(card, held, today) {
|
|
8
8
|
const caps = new Map();
|
|
@@ -19,11 +19,7 @@ function capSummaries(card, held, today) {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
return [...caps].map(([key, { cap, rules }]) => {
|
|
22
|
-
const currentPeriod =
|
|
23
|
-
const usage = held.cap_usage[key];
|
|
24
|
-
const spent = currentPeriod !== null && usage?.period === currentPeriod
|
|
25
|
-
? usage.spent
|
|
26
|
-
: 0;
|
|
22
|
+
const { period: currentPeriod, spent } = capUsed(cap, key, held, today);
|
|
27
23
|
return {
|
|
28
24
|
key,
|
|
29
25
|
rules,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { earnText } from "../engine/text.js";
|
|
2
|
+
// Card names, fees and requirements as earnrate.dev and its static search
|
|
3
|
+
// pages show them. Node-free: the site bundles it.
|
|
4
|
+
const ISSUERS = {
|
|
5
|
+
amex: 'American Express',
|
|
6
|
+
'bank-of-america': 'Bank of America',
|
|
7
|
+
'capital-one': 'Capital One',
|
|
8
|
+
chase: 'Chase',
|
|
9
|
+
citi: 'Citi',
|
|
10
|
+
column: 'Bilt',
|
|
11
|
+
sofi: 'SoFi',
|
|
12
|
+
'td-bank': 'TD Bank',
|
|
13
|
+
usbank: 'U.S. Bank',
|
|
14
|
+
'wells-fargo': 'Wells Fargo',
|
|
15
|
+
};
|
|
16
|
+
export const MEMBERSHIPS = {
|
|
17
|
+
'amazon-prime': 'Amazon Prime',
|
|
18
|
+
'bank-of-america-account': 'Bank of America checking or savings',
|
|
19
|
+
'bofa-rewards-member': 'BofA Rewards Member',
|
|
20
|
+
'bofa-rewards-preferred-plus': 'BofA Rewards Preferred Plus',
|
|
21
|
+
'bofa-rewards-preferred-honors': 'BofA Rewards Preferred Honors',
|
|
22
|
+
'bofa-rewards-premier': 'BofA Rewards Premier',
|
|
23
|
+
};
|
|
24
|
+
export const PICK_PERIODS = {
|
|
25
|
+
month: 'each month',
|
|
26
|
+
quarter: 'each quarter',
|
|
27
|
+
calendar_year: 'each year',
|
|
28
|
+
until_changed: 'until you change it',
|
|
29
|
+
};
|
|
30
|
+
export const CAP_PERIODS = {
|
|
31
|
+
month: 'a month',
|
|
32
|
+
quarter: 'a quarter',
|
|
33
|
+
calendar_year: 'a year',
|
|
34
|
+
cardmember_year: 'a card year',
|
|
35
|
+
};
|
|
36
|
+
export const words = (id) => id.replace(/[-_.]/g, ' ');
|
|
37
|
+
// Whole dollars, for fees and caps.
|
|
38
|
+
export const usd = (n) => `${n < 0 ? '−' : ''}$${Math.abs(Math.round(n)).toLocaleString('en-US')}`;
|
|
39
|
+
export const issuerName = (id) => ISSUERS[id] ?? id;
|
|
40
|
+
export const membershipName = (id) => MEMBERSHIPS[id] ?? words(id);
|
|
41
|
+
export function cardText(catalog) {
|
|
42
|
+
const cards = new Map(catalog.cards.map((c) => [c.id, c]));
|
|
43
|
+
function shortName(id) {
|
|
44
|
+
const name = cards.get(id)?.name ?? id;
|
|
45
|
+
return name
|
|
46
|
+
.replace(/^American Express /, 'Amex ')
|
|
47
|
+
.replace(/^The Platinum Card from American Express$/, 'Amex Platinum')
|
|
48
|
+
.replace(/ (Visa Signature |Visa Infinite )?(Credit )?Card$/, '')
|
|
49
|
+
.replace(/ Card by /, ' by ');
|
|
50
|
+
}
|
|
51
|
+
function feeLabel(id) {
|
|
52
|
+
const card = cards.get(id);
|
|
53
|
+
const fee = card?.annual_fee;
|
|
54
|
+
if (fee === undefined)
|
|
55
|
+
return 'Annual fee not on file';
|
|
56
|
+
if (fee === 0)
|
|
57
|
+
return 'No annual fee';
|
|
58
|
+
const first = card?.first_year_fee;
|
|
59
|
+
if (first !== undefined && first !== fee)
|
|
60
|
+
return `${usd(fee)} a year (${first === 0 ? 'waived' : usd(first)} the first year)`;
|
|
61
|
+
return `${usd(fee)} a year`;
|
|
62
|
+
}
|
|
63
|
+
// `until` is the last day a time-limited rate applies, already formatted.
|
|
64
|
+
function requirementLines(cardId, r, until = null) {
|
|
65
|
+
const lines = [];
|
|
66
|
+
if (r.pick)
|
|
67
|
+
lines.push(`Pick ${r.pick.options.map(words).join(' or ')} as a bonus category (${PICK_PERIODS[r.pick.period]})`);
|
|
68
|
+
if (r.activation !== null)
|
|
69
|
+
lines.push(`Activate the bonus with ${issuerName(cards.get(cardId)?.issuer ?? '')} (free)`);
|
|
70
|
+
if (r.membership !== null)
|
|
71
|
+
lines.push(`With ${membershipName(r.membership)}`);
|
|
72
|
+
if (until !== null)
|
|
73
|
+
lines.push(`This rate runs through ${until}`);
|
|
74
|
+
if (r.cap)
|
|
75
|
+
lines.push(`Up to ${usd(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period] ?? ''}, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}`);
|
|
76
|
+
return lines;
|
|
77
|
+
}
|
|
78
|
+
return { cards, shortName, feeLabel, requirementLines };
|
|
79
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { pct } from "../engine/text.js";
|
|
2
2
|
export function formatCheatSheet(sheet) {
|
|
3
3
|
const labelWidth = Math.max(...sheet.rows.map((r) => r.label.length));
|
|
4
4
|
const cardWidth = Math.max(...sheet.rows.map((r) => (r.cardName ?? 'none of your cards').length));
|
package/dist/format/explore.js
CHANGED
|
@@ -1,15 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
const wholeUsd = (n) =>
|
|
3
|
-
const pct = (n) => `${String(Number(n.toFixed(2)))}%`;
|
|
4
|
-
function feeText(e) {
|
|
5
|
-
if (e.annualFee === null)
|
|
6
|
-
return 'annual fee not on file';
|
|
7
|
-
if (e.annualFee === 0)
|
|
8
|
-
return 'no annual fee';
|
|
9
|
-
if (e.firstYearFee !== null && e.firstYearFee !== e.annualFee)
|
|
10
|
-
return `${usd(e.annualFee)} annual fee (${e.firstYearFee === 0 ? 'waived' : usd(e.firstYearFee)} the first year)`;
|
|
11
|
-
return `${usd(e.annualFee)} annual fee`;
|
|
12
|
-
}
|
|
1
|
+
import { feeText, money, pct } from "../engine/text.js";
|
|
2
|
+
const wholeUsd = (n) => money(Math.round(n));
|
|
13
3
|
const TOP_BEST = 10;
|
|
14
4
|
export function formatExploration(r, all) {
|
|
15
5
|
const m = r.merchant;
|
|
@@ -24,7 +14,7 @@ export function formatExploration(r, all) {
|
|
|
24
14
|
const width = Math.max(...shown.map((e) => e.cardName.length));
|
|
25
15
|
const lines = [`Best cards for ${title}:`, ''];
|
|
26
16
|
shown.forEach((e, i) => {
|
|
27
|
-
const earned = e.earned === null ? '' : ` ${
|
|
17
|
+
const earned = e.earned === null ? '' : ` ${money(e.earned)}`;
|
|
28
18
|
lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
|
|
29
19
|
for (const c of e.conditions)
|
|
30
20
|
lines.push(` ${c}`);
|
package/dist/format/offers.js
CHANGED
|
@@ -1,19 +1,10 @@
|
|
|
1
|
+
import { feeText, money } from "../engine/text.js";
|
|
1
2
|
const TOP = 10;
|
|
2
|
-
const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
3
3
|
function reward(o, currencyName) {
|
|
4
4
|
if (o.dollars !== null)
|
|
5
|
-
return
|
|
5
|
+
return money(o.dollars);
|
|
6
6
|
return `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
|
|
7
7
|
}
|
|
8
|
-
function feeText(o) {
|
|
9
|
-
if (o.annualFee === null)
|
|
10
|
-
return 'annual fee not on file';
|
|
11
|
-
if (o.annualFee === 0)
|
|
12
|
-
return 'no annual fee';
|
|
13
|
-
if (o.firstYearFee !== null && o.firstYearFee !== o.annualFee)
|
|
14
|
-
return `${usd(o.annualFee)} annual fee (${o.firstYearFee === 0 ? 'waived' : usd(o.firstYearFee)} the first year)`;
|
|
15
|
-
return `${usd(o.annualFee)} annual fee`;
|
|
16
|
-
}
|
|
17
8
|
export function formatOffers(c, currencyName, all) {
|
|
18
9
|
if (c.offers.length === 0) {
|
|
19
10
|
return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
|
|
@@ -23,11 +14,11 @@ export function formatOffers(c, currencyName, all) {
|
|
|
23
14
|
const lines = [];
|
|
24
15
|
shown.forEach((o, i) => {
|
|
25
16
|
const weeks = (o.months * 52) / 12;
|
|
26
|
-
const pace = o.spend > 0 ? ` (about ${
|
|
17
|
+
const pace = o.spend > 0 ? ` (about ${money(Math.round(o.spend / weeks))} a week)` : '';
|
|
27
18
|
const spend = o.spend > 0
|
|
28
|
-
? `after ${
|
|
19
|
+
? `after ${money(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
|
|
29
20
|
: 'with no spending required';
|
|
30
|
-
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${
|
|
21
|
+
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${money(Math.round(o.firstYearValue))} the first year`, ` ${reward(o, currencyName(o.currency))} ${spend}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.held ? ' In your wallet.' : ''}`);
|
|
31
22
|
if (o.validTo)
|
|
32
23
|
lines.push(` Offer ends ${o.validTo}.`);
|
|
33
24
|
if (o.note)
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
+
import { money, pct } from "../engine/text.js";
|
|
1
2
|
export const plain = (_format, text) => text;
|
|
2
3
|
const TOP = 3;
|
|
3
|
-
const pct = (n) => `${String(Number(n.toFixed(2)))}%`;
|
|
4
4
|
const cents = (n) => `$${n.toFixed(2)}`;
|
|
5
|
-
const amountText = (n) => `$${n.toLocaleString('en-US', {
|
|
6
|
-
minimumFractionDigits: Number.isInteger(n) ? 0 : 2,
|
|
7
|
-
maximumFractionDigits: 2,
|
|
8
|
-
})}`;
|
|
9
5
|
const label = (c) => c.checkoutLabel ?? c.cardName;
|
|
10
6
|
function header(rec, purchase) {
|
|
11
7
|
const m = rec.merchant;
|
|
@@ -23,7 +19,7 @@ function header(rec, purchase) {
|
|
|
23
19
|
: m.resolvedName,
|
|
24
20
|
];
|
|
25
21
|
if (purchase.amount !== null)
|
|
26
|
-
parts.push(
|
|
22
|
+
parts.push(money(purchase.amount));
|
|
27
23
|
if (m.channel)
|
|
28
24
|
parts.push(m.channel === 'online' ? 'online' : 'in-store');
|
|
29
25
|
return parts.join(' · ');
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { money } from "../engine/text.js";
|
|
2
|
+
export function formatWalletSummary(summary) {
|
|
3
|
+
return summary.cards
|
|
4
|
+
.map((c) => {
|
|
5
|
+
const caps = c.caps
|
|
6
|
+
.filter((cap) => cap.remaining !== null)
|
|
7
|
+
.map((cap) => `${cap.key} ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''}`);
|
|
8
|
+
const chosen = c.chosen
|
|
9
|
+
? `; chosen categories for ${c.chosen.period}${c.chosen.current ? '' : ' (out of date)'}`
|
|
10
|
+
: '';
|
|
11
|
+
const onlyAt = c.onlyAt ? `; works only at ${c.onlyAt.join(', ')}` : '';
|
|
12
|
+
const rotation = c.rotationGap === null
|
|
13
|
+
? ''
|
|
14
|
+
: `; rotating categories for ${c.rotationGap} not in earnrate yet`;
|
|
15
|
+
const b = c.bonus;
|
|
16
|
+
const bonus = b?.state === 'open' && b.spend !== null
|
|
17
|
+
? `; welcome bonus ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}`
|
|
18
|
+
: b
|
|
19
|
+
? `; welcome bonus ${b.state}`
|
|
20
|
+
: '';
|
|
21
|
+
return `- ${c.checkoutLabel ?? c.cardName} (${c.cardId}): ${c.currency} at ${String(c.centsPerUnit)}¢${caps.length > 0 ? `; ${caps.join(', ')}` : ''}${chosen}${rotation}${onlyAt}${bonus}`;
|
|
22
|
+
})
|
|
23
|
+
.join('\n');
|
|
24
|
+
}
|
|
25
|
+
export function formatBooking(amount, cardId, cap, bonus) {
|
|
26
|
+
const messages = [];
|
|
27
|
+
if (cap) {
|
|
28
|
+
const left = Math.max(0, cap.capAmount - cap.after);
|
|
29
|
+
messages.push(`Recorded ${money(amount)} on ${cardId} against "${cap.capKey}": ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} for ${cap.period}, ${money(left)} left.`);
|
|
30
|
+
}
|
|
31
|
+
if (bonus) {
|
|
32
|
+
const left = Math.max(0, bonus.spend - bonus.after);
|
|
33
|
+
messages.push(left === 0
|
|
34
|
+
? `Welcome bonus spend met on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)}.`
|
|
35
|
+
: `Welcome bonus on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
|
|
36
|
+
}
|
|
37
|
+
return messages.join(' ');
|
|
38
|
+
}
|