earnrate 0.2.0 → 0.2.2
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 +23 -20
- package/data/cards/amex-blue-business-plus.yaml +1 -0
- package/data/cards/amex-blue-cash-everyday.yaml +4 -3
- package/data/cards/amex-blue-cash-preferred.yaml +5 -7
- package/data/cards/amex-gold.yaml +1 -0
- package/data/cards/amex-platinum.yaml +1 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +6 -6
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +6 -6
- package/data/cards/chase-freedom-flex.yaml +5 -1
- package/data/cards/chase-southwest-plus.yaml +5 -5
- package/data/cards/chase-southwest-premier-business.yaml +8 -5
- package/data/cards/chase-southwest-premier.yaml +6 -6
- package/data/cards/chase-southwest-priority.yaml +6 -6
- package/data/cards/citi-aadvantage-business.yaml +6 -6
- package/data/cards/citi-aadvantage-platinum-select.yaml +10 -7
- package/data/cards/citi-costco-anywhere-visa-business.yaml +4 -4
- 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 +78 -3
- package/dist/engine/bonus.js +15 -12
- package/dist/engine/cheat-sheet.js +2 -2
- package/dist/engine/evaluate.js +56 -13
- package/dist/engine/explore.js +9 -22
- package/dist/engine/offers.js +10 -22
- package/dist/engine/periods.js +9 -0
- package/dist/engine/recommend.js +14 -53
- package/dist/engine/record.js +2 -7
- package/dist/engine/setup.js +2 -2
- 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 +46 -19
- 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 +15 -18
- package/dist/{cli/format.js → format/recommendation.js} +2 -6
- package/dist/format/wallet.js +57 -0
- package/dist/import/analyze.js +17 -19
- package/dist/mcp/context.js +92 -0
- package/dist/mcp/inputs.js +155 -0
- package/dist/mcp/schemas.js +2 -1
- package/dist/mcp/server.js +15 -819
- package/dist/mcp/tools/account.js +78 -0
- package/dist/mcp/tools/answers.js +338 -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/card.js +4 -0
- package/package.json +3 -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/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,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { capByKey } from "./evaluate.js";
|
|
2
|
+
import { choicePeriod, periodKey } from "./periods.js";
|
|
2
3
|
import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
|
|
3
4
|
const failed = (error) => ({ ok: false, error });
|
|
4
5
|
const list = (ids) => ids.join(', ');
|
|
5
|
-
|
|
6
|
+
function findCatalogCard(catalog, id, today) {
|
|
6
7
|
const card = catalog.cards.find((c) => c.id === id);
|
|
7
8
|
if (card)
|
|
8
9
|
return card;
|
|
@@ -11,27 +12,23 @@ export function findCatalogCard(catalog, id, today) {
|
|
|
11
12
|
? `No card '${id}' in earnrate. Closest: ${list(near)}.`
|
|
12
13
|
: `No card '${id}' in earnrate. Search the catalog to find its id.`;
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
function isAnniversary(text) {
|
|
15
16
|
const m = /^(\d{2})-(\d{2})$/.exec(text);
|
|
16
17
|
if (!m)
|
|
17
18
|
return false;
|
|
18
19
|
const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
|
|
19
20
|
return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
|
|
20
21
|
}
|
|
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);
|
|
22
|
+
function pickKey(card, today) {
|
|
23
|
+
const period = choicePeriod(card);
|
|
24
|
+
return period === null ? null : periodKey(period, today, null);
|
|
30
25
|
}
|
|
31
26
|
export function cardFields(card, held, input, today) {
|
|
32
27
|
const problems = [];
|
|
33
28
|
const fields = {};
|
|
34
|
-
if (input.checkoutLabel
|
|
29
|
+
if (input.checkoutLabel === null)
|
|
30
|
+
fields.checkout_label = null;
|
|
31
|
+
else if (input.checkoutLabel !== undefined) {
|
|
35
32
|
if (input.checkoutLabel.trim() === '')
|
|
36
33
|
problems.push('The checkout label is empty.');
|
|
37
34
|
// earnrate never stores card numbers; a last four is fine.
|
|
@@ -77,7 +74,7 @@ export function cardFields(card, held, input, today) {
|
|
|
77
74
|
problems.push(`${choiceId} takes ${choice.pick === 1 ? '1 pick' : `1 to ${String(choice.pick)} picks`}, not ${String(picks.length)}.`);
|
|
78
75
|
}
|
|
79
76
|
}
|
|
80
|
-
const period =
|
|
77
|
+
const period = pickKey(card, today);
|
|
81
78
|
if (period === null) {
|
|
82
79
|
problems.push(`${card.id} has picks on different schedules; edit the wallet by hand.`);
|
|
83
80
|
}
|
|
@@ -89,6 +86,34 @@ export function cardFields(card, held, input, today) {
|
|
|
89
86
|
};
|
|
90
87
|
}
|
|
91
88
|
}
|
|
89
|
+
if (input.capSpent !== undefined) {
|
|
90
|
+
const usage = { ...held?.cap_usage };
|
|
91
|
+
const anniversary = fields.anniversary ?? held?.anniversary ?? null;
|
|
92
|
+
for (const [key, spent] of Object.entries(input.capSpent)) {
|
|
93
|
+
const cap = capByKey(card, key);
|
|
94
|
+
if (!cap) {
|
|
95
|
+
const keys = [
|
|
96
|
+
...card.caps.map((c) => c.id),
|
|
97
|
+
...card.rules
|
|
98
|
+
.filter((r) => typeof r.cap === 'object')
|
|
99
|
+
.map((r) => r.id),
|
|
100
|
+
];
|
|
101
|
+
problems.push(keys.length > 0
|
|
102
|
+
? `${card.id} has no cap '${key}'. Its caps are: ${list(keys)}.`
|
|
103
|
+
: `${card.id} has no spending caps.`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!(spent >= 0))
|
|
107
|
+
problems.push('Spending on a cap must be $0 or more.');
|
|
108
|
+
const period = periodKey(cap.period, today, anniversary);
|
|
109
|
+
if (period === null) {
|
|
110
|
+
problems.push(`The '${key}' cap resets on the card's anniversary; give the anniversary (MM-DD) too.`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
usage[key] = { period, spent };
|
|
114
|
+
}
|
|
115
|
+
fields.cap_usage = usage;
|
|
116
|
+
}
|
|
92
117
|
if (input.bonus === null)
|
|
93
118
|
fields.bonus = null;
|
|
94
119
|
else if (input.bonus !== undefined) {
|
|
@@ -116,20 +141,22 @@ export function cardFields(card, held, input, today) {
|
|
|
116
141
|
}
|
|
117
142
|
return problems.length > 0 ? problems.join('\n') : fields;
|
|
118
143
|
}
|
|
119
|
-
|
|
144
|
+
const newHolding = (id) => ({
|
|
120
145
|
id,
|
|
121
146
|
cap_usage: {},
|
|
122
147
|
activated: [],
|
|
123
148
|
});
|
|
124
|
-
|
|
149
|
+
const withFields = ({ bonus, checkout_label, ...held }, f) => ({
|
|
125
150
|
...held,
|
|
126
151
|
...(bonus && f.bonus === undefined ? { bonus } : {}),
|
|
127
|
-
...(f.checkout_label === undefined
|
|
128
|
-
? {}
|
|
129
|
-
: {
|
|
152
|
+
...(checkout_label !== undefined && f.checkout_label === undefined
|
|
153
|
+
? { checkout_label }
|
|
154
|
+
: {}),
|
|
155
|
+
...(f.checkout_label ? { checkout_label: f.checkout_label } : {}),
|
|
130
156
|
...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
|
|
131
157
|
...(f.chosen === undefined ? {} : { chosen: f.chosen }),
|
|
132
158
|
...(f.activated === undefined ? {} : { activated: f.activated }),
|
|
159
|
+
...(f.cap_usage === undefined ? {} : { cap_usage: f.cap_usage }),
|
|
133
160
|
...(f.bonus ? { bonus: f.bonus } : {}),
|
|
134
161
|
});
|
|
135
162
|
// `wallet` is null when there isn't one yet; adding a card starts it.
|
|
@@ -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,17 @@
|
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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`;
|
|
4
|
+
const amount = o.dollars !== null
|
|
5
|
+
? money(o.dollars)
|
|
6
|
+
: `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
|
|
7
|
+
return o.varies ? `Up to ${amount}` : amount;
|
|
16
8
|
}
|
|
9
|
+
const asOf = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
|
|
10
|
+
month: 'short',
|
|
11
|
+
day: 'numeric',
|
|
12
|
+
year: 'numeric',
|
|
13
|
+
timeZone: 'UTC',
|
|
14
|
+
});
|
|
17
15
|
export function formatOffers(c, currencyName, all) {
|
|
18
16
|
if (c.offers.length === 0) {
|
|
19
17
|
return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
|
|
@@ -23,13 +21,12 @@ export function formatOffers(c, currencyName, all) {
|
|
|
23
21
|
const lines = [];
|
|
24
22
|
shown.forEach((o, i) => {
|
|
25
23
|
const weeks = (o.months * 52) / 12;
|
|
26
|
-
const pace = o.spend > 0 ? ` (about ${
|
|
24
|
+
const pace = o.spend > 0 ? ` (about ${money(Math.round(o.spend / weeks))} a week)` : '';
|
|
27
25
|
const spend = o.spend > 0
|
|
28
|
-
? `after ${
|
|
26
|
+
? `after ${money(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
|
|
29
27
|
: 'with no spending required';
|
|
30
|
-
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${
|
|
31
|
-
|
|
32
|
-
lines.push(` Offer ends ${o.validTo}.`);
|
|
28
|
+
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.' : ''}`);
|
|
29
|
+
lines.push(` ${o.validTo ? `Offer ends ${o.validTo}. ` : ''}Public offer as of ${asOf(o.lastVerified)}; yours may differ.`);
|
|
33
30
|
if (o.note)
|
|
34
31
|
lines.push(` ${o.note}`);
|
|
35
32
|
});
|
|
@@ -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,57 @@
|
|
|
1
|
+
import { money } from "../engine/text.js";
|
|
2
|
+
const NETWORKS = {
|
|
3
|
+
visa: 'Visa',
|
|
4
|
+
mastercard: 'Mastercard',
|
|
5
|
+
amex: 'American Express',
|
|
6
|
+
discover: 'Discover',
|
|
7
|
+
store: 'store card',
|
|
8
|
+
};
|
|
9
|
+
const words = (id) => id.replace(/[-_.]/g, ' ');
|
|
10
|
+
export function formatWalletSummary(summary) {
|
|
11
|
+
const cards = summary.cards.map((c) => {
|
|
12
|
+
const lines = [
|
|
13
|
+
`- ${c.checkoutLabel ?? c.cardName} (${c.cardId}): ${NETWORKS[c.network] ?? c.network}, ${c.currency} at ${String(c.centsPerUnit)}¢. Data checked ${c.lastVerified}${c.dataIsStale ? ' (old; check the issuer)' : ''}.`,
|
|
14
|
+
];
|
|
15
|
+
if (c.onlyAt)
|
|
16
|
+
lines.push(` Works only at ${c.onlyAt.join(', ')}.`);
|
|
17
|
+
if (c.chosen) {
|
|
18
|
+
const picks = Object.entries(c.chosen.picks)
|
|
19
|
+
.map(([choice, options]) => `${words(choice)}: ${options.map(words).join(', ')}`)
|
|
20
|
+
.join('; ');
|
|
21
|
+
lines.push(c.chosen.current
|
|
22
|
+
? ` Picks for ${c.chosen.period}: ${picks}. They need choosing again when the period ends.`
|
|
23
|
+
: ` Picks are for ${c.chosen.period}, which is over: ask for this period's picks.`);
|
|
24
|
+
}
|
|
25
|
+
if (c.activated.length > 0)
|
|
26
|
+
lines.push(` Activated: ${c.activated.join(', ')}.`);
|
|
27
|
+
if (c.rotationGap !== null)
|
|
28
|
+
lines.push(` Rotating categories for ${c.rotationGap} aren't in earnrate yet.`);
|
|
29
|
+
for (const cap of c.caps)
|
|
30
|
+
if (cap.remaining !== null)
|
|
31
|
+
lines.push(` Cap ${cap.key}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''}.`);
|
|
32
|
+
const b = c.bonus;
|
|
33
|
+
if (b?.state === 'open' && b.spend !== null)
|
|
34
|
+
lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
|
|
35
|
+
else if (b)
|
|
36
|
+
lines.push(` Welcome bonus: ${b.state}.`);
|
|
37
|
+
return lines.join('\n');
|
|
38
|
+
});
|
|
39
|
+
const memberships = summary.memberships.length > 0
|
|
40
|
+
? `Memberships: ${summary.memberships.join(', ')}.`
|
|
41
|
+
: 'Memberships: none saved.';
|
|
42
|
+
return [...cards, memberships].join('\n');
|
|
43
|
+
}
|
|
44
|
+
export function formatBooking(amount, cardId, cap, bonus) {
|
|
45
|
+
const messages = [];
|
|
46
|
+
if (cap) {
|
|
47
|
+
const left = Math.max(0, cap.capAmount - cap.after);
|
|
48
|
+
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.`);
|
|
49
|
+
}
|
|
50
|
+
if (bonus) {
|
|
51
|
+
const left = Math.max(0, bonus.spend - bonus.after);
|
|
52
|
+
messages.push(left === 0
|
|
53
|
+
? `Welcome bonus spend met on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)}.`
|
|
54
|
+
: `Welcome bonus on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
|
|
55
|
+
}
|
|
56
|
+
return messages.join(' ');
|
|
57
|
+
}
|
package/dist/import/analyze.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
|
|
2
|
-
import { categoryMatches } from "../engine/evaluate.js";
|
|
2
|
+
import { capByKey, categoryMatches } from "../engine/evaluate.js";
|
|
3
3
|
import { periodKey } from "../engine/periods.js";
|
|
4
|
-
import { applyBooking, capBooking } from "../engine/record.js";
|
|
5
4
|
import { recommend } from "../engine/recommend.js";
|
|
5
|
+
import { applyBooking, capBooking } from "../engine/record.js";
|
|
6
|
+
import { roundCents } from "../engine/text.js";
|
|
6
7
|
import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
|
|
7
8
|
import { cleanDescriptor } from "./statement.js";
|
|
8
|
-
const round = (n) => Math.round(n * 100) / 100;
|
|
9
9
|
function resolveLine(catalog, line) {
|
|
10
10
|
const cleaned = cleanDescriptor(line.description);
|
|
11
11
|
// "NAME-CHEAP.COM* LV0I6X": the part before the asterisk names the merchant.
|
|
@@ -30,12 +30,10 @@ function resolveLine(catalog, line) {
|
|
|
30
30
|
function capPeriodKey(catalog, wallet, booking, date) {
|
|
31
31
|
const card = catalog.cards.find((c) => c.id === booking.cardId);
|
|
32
32
|
const held = wallet.cards.find((c) => c.id === booking.cardId);
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
const period = shared?.period ?? (typeof inline === 'object' ? inline.period : undefined);
|
|
36
|
-
if (!period)
|
|
33
|
+
const cap = card && capByKey(card, booking.capKey);
|
|
34
|
+
if (!cap)
|
|
37
35
|
return null;
|
|
38
|
-
return periodKey(period, date, held?.anniversary ?? null);
|
|
36
|
+
return periodKey(cap.period, date, held?.anniversary ?? null);
|
|
39
37
|
}
|
|
40
38
|
// Replays the statements twice: once on the cards actually used, once on
|
|
41
39
|
// earnrate's picks, each with its own running cap usage, so a cap that fills
|
|
@@ -76,7 +74,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
76
74
|
if (progress &&
|
|
77
75
|
countsTowardBonus(progress, line.date) &&
|
|
78
76
|
!alreadyRecorded(entry, 'bonus')) {
|
|
79
|
-
progress.after =
|
|
77
|
+
progress.after = roundCents(Math.max(0, progress.after + line.amount));
|
|
80
78
|
progress.entries.push(entry);
|
|
81
79
|
}
|
|
82
80
|
const { name, merchant, known } = resolveLine(catalog, line);
|
|
@@ -96,7 +94,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
96
94
|
const signed = line.amount < 0
|
|
97
95
|
? {
|
|
98
96
|
...booking,
|
|
99
|
-
after:
|
|
97
|
+
after: roundCents(Math.max(0, booking.before + line.amount)),
|
|
100
98
|
}
|
|
101
99
|
: booking;
|
|
102
100
|
bookings.push({ ...entry, booking: signed });
|
|
@@ -181,21 +179,21 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
181
179
|
from: sorted[0]?.line.date ?? '',
|
|
182
180
|
to: sorted.at(-1)?.line.date ?? '',
|
|
183
181
|
purchases,
|
|
184
|
-
spend:
|
|
185
|
-
refunds:
|
|
186
|
-
earned:
|
|
187
|
-
best:
|
|
182
|
+
spend: roundCents(spend),
|
|
183
|
+
refunds: roundCents(refunds),
|
|
184
|
+
earned: roundCents(earned),
|
|
185
|
+
best: roundCents(best),
|
|
188
186
|
byCard: [...byCard.values()].map((t) => ({
|
|
189
187
|
...t,
|
|
190
|
-
spend:
|
|
191
|
-
earned:
|
|
188
|
+
spend: roundCents(t.spend),
|
|
189
|
+
earned: roundCents(t.earned),
|
|
192
190
|
})),
|
|
193
191
|
missed: [...missed.values()]
|
|
194
192
|
.map((m) => ({
|
|
195
193
|
...m,
|
|
196
|
-
spend:
|
|
197
|
-
earned:
|
|
198
|
-
best:
|
|
194
|
+
spend: roundCents(m.spend),
|
|
195
|
+
earned: roundCents(m.earned),
|
|
196
|
+
best: roundCents(m.best),
|
|
199
197
|
}))
|
|
200
198
|
.sort((a, b) => b.best - b.earned - (a.best - a.earned)),
|
|
201
199
|
coding: [...coding.values()].sort((a, b) => b.count - a.count),
|