earnrate 0.2.8 → 0.2.9
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 +31 -5
- package/data/CHANGELOG.md +133 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +4 -0
- package/data/cards/amex-blue-cash-everyday.yaml +4 -0
- package/data/cards/amex-blue-cash-preferred.yaml +36 -3
- package/data/cards/amex-gold.yaml +4 -0
- package/data/cards/amex-platinum.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +24 -1
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +4 -0
- package/data/cards/bilt-obsidian.yaml +4 -0
- package/data/cards/capital-one-kohls-card.yaml +2 -0
- package/data/cards/capital-one-kohls-rewards-visa.yaml +2 -0
- package/data/cards/capital-one-savor.yaml +4 -0
- package/data/cards/capital-one-venture-x.yaml +4 -0
- package/data/cards/capital-one-venture.yaml +4 -0
- package/data/cards/chase-aeroplan.yaml +5 -0
- package/data/cards/chase-freedom-flex.yaml +4 -0
- package/data/cards/chase-freedom-unlimited.yaml +4 -0
- package/data/cards/chase-ihg-one-rewards-business.yaml +29 -1
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +37 -2
- package/data/cards/chase-ihg-one-rewards-premier.yaml +29 -1
- package/data/cards/chase-ihg-one-rewards.yaml +57 -7
- package/data/cards/chase-ink-business-cash.yaml +4 -0
- package/data/cards/chase-ink-business-preferred.yaml +4 -0
- package/data/cards/chase-prime-visa.yaml +4 -0
- package/data/cards/chase-sapphire-preferred.yaml +4 -0
- package/data/cards/chase-sapphire-reserve.yaml +4 -0
- package/data/cards/chase-southwest-performance-business.yaml +4 -0
- package/data/cards/chase-southwest-plus.yaml +4 -0
- package/data/cards/chase-southwest-premier-business.yaml +4 -0
- package/data/cards/chase-southwest-premier.yaml +4 -0
- package/data/cards/chase-southwest-priority.yaml +4 -0
- package/data/cards/chase-united-business.yaml +5 -0
- package/data/cards/chase-united-club-business.yaml +5 -0
- package/data/cards/chase-united-club.yaml +5 -0
- package/data/cards/chase-united-explorer.yaml +5 -0
- package/data/cards/chase-united-gateway.yaml +5 -0
- package/data/cards/chase-united-quest.yaml +29 -1
- package/data/cards/citi-aadvantage-business.yaml +4 -0
- package/data/cards/citi-aadvantage-executive.yaml +4 -0
- package/data/cards/citi-aadvantage-globe.yaml +4 -0
- package/data/cards/citi-aadvantage-mileup.yaml +4 -0
- package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
- package/data/cards/citi-costco-anywhere-visa-business.yaml +4 -0
- package/data/cards/citi-costco-anywhere-visa.yaml +4 -0
- package/data/cards/citi-custom-cash.yaml +2 -0
- package/data/cards/citi-double-cash.yaml +4 -0
- package/data/cards/citi-my-best-buy-visa.yaml +4 -0
- package/data/cards/citi-strata-elite.yaml +4 -0
- package/data/cards/citi-strata-premier.yaml +4 -0
- package/data/cards/sofi-unlimited-2.yaml +4 -0
- package/data/cards/td-bank-nordstrom-credit-card.yaml +2 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +2 -0
- package/data/cards/td-bank-target-circle-card.yaml +2 -0
- package/data/cards/usbank-cash-plus.yaml +5 -0
- package/data/cards/wells-fargo-active-cash.yaml +4 -0
- package/data/cards/wells-fargo-autograph.yaml +4 -0
- package/data/merchants/overrides.yaml +284 -0
- package/data/merchants/seed.yaml +1055 -2
- package/dist/cli/args.js +3 -6
- package/dist/cli/explore.js +10 -0
- package/dist/cli/import.js +18 -4
- package/dist/cli/run.js +3 -1
- package/dist/cli/wallet.js +5 -1
- package/dist/data/file-store.js +2 -2
- package/dist/data/integrity.js +17 -0
- package/dist/data/store.js +1 -1
- package/dist/data/wallet-edit.js +32 -18
- package/dist/engine/cheat-sheet.js +46 -2
- package/dist/engine/checklist.js +19 -8
- package/dist/engine/evaluate.js +2 -0
- package/dist/engine/explore.js +1 -1
- package/dist/engine/milestones.js +83 -0
- package/dist/engine/periods.js +29 -12
- package/dist/engine/recommend.js +39 -9
- package/dist/engine/record.js +39 -1
- package/dist/engine/rule-text.js +5 -1
- package/dist/engine/setup.js +13 -2
- package/dist/engine/simulate.js +25 -3
- package/dist/engine/text.js +11 -0
- package/dist/engine/wallet-change.js +67 -0
- package/dist/engine/wallet-summary.js +17 -5
- package/dist/format/cards.js +3 -1
- package/dist/format/wallet.js +18 -4
- package/dist/import/analyze.js +42 -2
- package/dist/mcp/inputs.js +10 -0
- package/dist/mcp/schemas.js +47 -2
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +8 -4
- package/dist/mcp/tools/wallet.js +18 -10
- package/dist/schema/card.js +22 -1
- package/dist/schema/common.js +4 -0
- package/dist/schema/wallet.js +2 -0
- package/package.json +5 -3
package/dist/cli/args.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { isValuationProfile, } from "../engine/profiles.js";
|
|
3
3
|
import { knownMemberships } from "../engine/setup.js";
|
|
4
|
+
import { COUNTRY_CODE, countryProblem } from "../schema/common.js";
|
|
4
5
|
// Shared by every subcommand, so each flag means the same thing everywhere.
|
|
5
6
|
// A string result is the error to show.
|
|
6
7
|
const LOOKS_LIKE_AMOUNT = /^\$?[\d,]*\.?\d+$/;
|
|
@@ -109,12 +110,8 @@ export function parseCliArgs(argv) {
|
|
|
109
110
|
? values.valuation
|
|
110
111
|
: null;
|
|
111
112
|
const country = values.country ?? 'US';
|
|
112
|
-
if (
|
|
113
|
-
return {
|
|
114
|
-
kind: 'error',
|
|
115
|
-
message: `"${country}" isn't a country code. Use two letters, like US or FR.`,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
113
|
+
if (!COUNTRY_CODE.test(country))
|
|
114
|
+
return { kind: 'error', message: countryProblem(country) };
|
|
118
115
|
return {
|
|
119
116
|
kind: 'run',
|
|
120
117
|
options: {
|
package/dist/cli/explore.js
CHANGED
|
@@ -6,6 +6,7 @@ import { isValuationProfile, profileText, VALUATION_PROFILES, withValuationProfi
|
|
|
6
6
|
import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, typicalSpend, TYPICAL_SPEND_NOTE, } from "../engine/simulate.js";
|
|
7
7
|
import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../format/explore.js";
|
|
8
8
|
import { BOTH_CHANNELS, cardList, channelOf, dateProblem, membershipProblem, splitAmount, valuationProblem, } from "./args.js";
|
|
9
|
+
import { COUNTRY_CODE, countryProblem } from "../schema/common.js";
|
|
9
10
|
import { cardsToUse, RUNTIME_ERROR, savedSettings, USAGE_ERROR, } from "./run.js";
|
|
10
11
|
const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or points and miles cards only.
|
|
11
12
|
--no-fee Cards with no annual fee.
|
|
@@ -30,6 +31,8 @@ Options:
|
|
|
30
31
|
${FILTER_OPTIONS}
|
|
31
32
|
--online, --in-store How you're paying. Default: inferred from the merchant.
|
|
32
33
|
--date <YYYY-MM-DD> Purchase date. Default: today.
|
|
34
|
+
--country <code> Where you're paying. Abroad, cards rank after their
|
|
35
|
+
foreign transaction fees. Default: US.
|
|
33
36
|
--all List every card, not just the top 10.
|
|
34
37
|
--json Print the full result as JSON.
|
|
35
38
|
-h, --help Show this help.
|
|
@@ -104,6 +107,7 @@ export async function runBest(argv, io) {
|
|
|
104
107
|
online: { type: 'boolean' },
|
|
105
108
|
'in-store': { type: 'boolean' },
|
|
106
109
|
date: { type: 'string' },
|
|
110
|
+
country: { type: 'string' },
|
|
107
111
|
},
|
|
108
112
|
});
|
|
109
113
|
}
|
|
@@ -135,6 +139,11 @@ export async function runBest(argv, io) {
|
|
|
135
139
|
io.stderr(`${dated}\n`);
|
|
136
140
|
return USAGE_ERROR;
|
|
137
141
|
}
|
|
142
|
+
const country = values.country ?? 'US';
|
|
143
|
+
if (!COUNTRY_CODE.test(country)) {
|
|
144
|
+
io.stderr(`${countryProblem(country)}\n`);
|
|
145
|
+
return USAGE_ERROR;
|
|
146
|
+
}
|
|
138
147
|
const catalog = await loadCatalog();
|
|
139
148
|
const settings = settingsFrom(values, catalog, await savedSettings(io));
|
|
140
149
|
if (typeof settings === 'string') {
|
|
@@ -147,6 +156,7 @@ export async function runBest(argv, io) {
|
|
|
147
156
|
amount,
|
|
148
157
|
channel: channelOf(values),
|
|
149
158
|
date: values.date ?? io.today,
|
|
159
|
+
country: country.toUpperCase(),
|
|
150
160
|
}, settings);
|
|
151
161
|
io.stdout(values.json
|
|
152
162
|
? `${JSON.stringify(result, null, 2)}\n`
|
package/dist/cli/import.js
CHANGED
|
@@ -41,10 +41,10 @@ const LedgerSchema = z.strictObject({
|
|
|
41
41
|
// sha256 of card, date, amount and description. Not reversible.
|
|
42
42
|
recorded: z.array(z.string()),
|
|
43
43
|
});
|
|
44
|
-
// Caps
|
|
45
|
-
// for its cap still counts toward a bonus added
|
|
44
|
+
// Caps, welcome bonuses and spend milestones are recorded separately, so a
|
|
45
|
+
// purchase imported for its cap still counts toward a bonus added later.
|
|
46
46
|
const fingerprint = ({ cardId, line }, kind = 'cap') => createHash('sha256')
|
|
47
|
-
.update(`${kind === '
|
|
47
|
+
.update(`${kind === 'cap' ? '' : `${kind}|`}${cardId}|${line.date}|${String(line.amount)}|${line.description}`)
|
|
48
48
|
.digest('hex');
|
|
49
49
|
// "statement.csv:chase-sapphire-preferred" names the card inline.
|
|
50
50
|
function splitTarget(arg) {
|
|
@@ -132,6 +132,9 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
|
|
|
132
132
|
for (const b of a.bonuses) {
|
|
133
133
|
out.push(`Welcome bonus on ${labelOf(b.cardId)}: ${dollarsAndCents(b.before)} → ${dollarsAndCents(b.after)} of ${dollarsAndCents(b.spend)}${b.deadline ? ` by ${b.deadline}` : ''}.`);
|
|
134
134
|
}
|
|
135
|
+
for (const m of a.milestones) {
|
|
136
|
+
out.push(`Spend bonus on ${labelOf(m.cardId)}: ${dollarsAndCents(m.before)} → ${dollarsAndCents(m.after)} of ${dollarsAndCents(m.spend)} for ${m.period}${m.before < m.spend && m.after >= m.spend ? ', reached' : ''}.`);
|
|
137
|
+
}
|
|
135
138
|
return `${out.join('\n')}\n`;
|
|
136
139
|
}
|
|
137
140
|
export async function runImport(argv, io) {
|
|
@@ -229,6 +232,12 @@ export async function runImport(argv, io) {
|
|
|
229
232
|
], values.valuation);
|
|
230
233
|
io.stdout(formatAnalysis(analysis, labelOf, valuation, categoryName));
|
|
231
234
|
const bonusEntries = analysis.bonuses.flatMap((b) => b.entries);
|
|
235
|
+
// A purchase counts toward each of its card's milestones; record it once.
|
|
236
|
+
const milestoneEntries = [
|
|
237
|
+
...new Map(analysis.milestones
|
|
238
|
+
.flatMap((m) => m.entries)
|
|
239
|
+
.map((e) => [fingerprint(e, 'milestone'), e])).values(),
|
|
240
|
+
];
|
|
232
241
|
const toRecord = [
|
|
233
242
|
...(analysis.bookings.length > 0
|
|
234
243
|
? [`${String(analysis.bookings.length)} purchase(s) against your caps`]
|
|
@@ -236,6 +245,9 @@ export async function runImport(argv, io) {
|
|
|
236
245
|
...(bonusEntries.length > 0
|
|
237
246
|
? [`${String(bonusEntries.length)} toward welcome bonuses`]
|
|
238
247
|
: []),
|
|
248
|
+
...(milestoneEntries.length > 0
|
|
249
|
+
? [`${String(milestoneEntries.length)} toward spend bonuses`]
|
|
250
|
+
: []),
|
|
239
251
|
];
|
|
240
252
|
if (!values.apply || toRecord.length === 0) {
|
|
241
253
|
io.stdout(values.apply || toRecord.length === 0
|
|
@@ -243,11 +255,13 @@ export async function runImport(argv, io) {
|
|
|
243
255
|
: `Nothing was written. Run again with --apply to record ${toRecord.join(' and ')}.\n`);
|
|
244
256
|
return 0;
|
|
245
257
|
}
|
|
246
|
-
await writeProgress(walletFile, analysis.bookings.map((e) => e.booking), analysis.bonuses);
|
|
258
|
+
await writeProgress(walletFile, analysis.bookings.map((e) => e.booking), analysis.bonuses, analysis.milestones);
|
|
247
259
|
for (const entry of analysis.bookings)
|
|
248
260
|
recorded.add(fingerprint(entry));
|
|
249
261
|
for (const entry of bonusEntries)
|
|
250
262
|
recorded.add(fingerprint(entry, 'bonus'));
|
|
263
|
+
for (const entry of milestoneEntries)
|
|
264
|
+
recorded.add(fingerprint(entry, 'milestone'));
|
|
251
265
|
await writeFileAtomic(ledgerFile, `${JSON.stringify({ version: 1, recorded: [...recorded] }, null, 2)}\n`);
|
|
252
266
|
io.stdout(`Recorded ${toRecord.join(' and ')} in ${displayPath(walletFile)}.\n`);
|
|
253
267
|
return 0;
|
package/dist/cli/run.js
CHANGED
|
@@ -65,7 +65,8 @@ Options:
|
|
|
65
65
|
--all List every card, not just the top three.
|
|
66
66
|
--json Print the full recommendation as JSON.
|
|
67
67
|
--date <YYYY-MM-DD> Purchase date. Default: today.
|
|
68
|
-
--country <code> Where you're paying.
|
|
68
|
+
--country <code> Where you're paying. Abroad, cards rank after their
|
|
69
|
+
foreign transaction fees. Default: US.
|
|
69
70
|
--cards <ids> Answer for these cards instead of your wallet, comma
|
|
70
71
|
separated: --cards amex-gold,chase-prime-visa.
|
|
71
72
|
--member <name> A membership you have, in place of the saved ones.
|
|
@@ -237,6 +238,7 @@ export async function run(argv, io) {
|
|
|
237
238
|
amount: purchase.amount,
|
|
238
239
|
channel: purchase.channel,
|
|
239
240
|
date: purchase.date,
|
|
241
|
+
country: purchase.country,
|
|
240
242
|
}, {
|
|
241
243
|
filters: ALL_PERSONAL_CARDS,
|
|
242
244
|
memberships: options.members.length > 0 ? options.members : saved.memberships,
|
package/dist/cli/wallet.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
|
|
|
5
5
|
import { isValuationProfile, profileText, VALUATION_PROFILES, } from "../engine/profiles.js";
|
|
6
6
|
import { membershipName } from "../engine/memberships.js";
|
|
7
7
|
import { knownMemberships, searchCards } from "../engine/setup.js";
|
|
8
|
+
import { currencyChanges } from "../engine/wallet-change.js";
|
|
8
9
|
import { formatCardInfo, formatNotes } from "../format/cards.js";
|
|
9
10
|
import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
|
|
10
11
|
const CARD_OPTIONS = `Options:
|
|
@@ -195,13 +196,16 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
195
196
|
return usage(`"${arg}" isn't <currency>=<cents>, like UR=1.5.`);
|
|
196
197
|
changes[id] = cents;
|
|
197
198
|
}
|
|
199
|
+
const given = currencyChanges(catalog, changes);
|
|
200
|
+
if (!given.ok)
|
|
201
|
+
return usage(given.error);
|
|
198
202
|
if (Object.keys(changes).length === 0) {
|
|
199
203
|
const held = await heldWallet(io);
|
|
200
204
|
const set = Object.entries(held?.valuations ?? {});
|
|
201
205
|
io.stdout(`${set.length > 0 ? `Yours: ${set.map(([id, v]) => `${id}=${typeof v === 'number' ? String(v) : JSON.stringify(v)}`).join(' ')}` : "Yours: earnrate's defaults"}\nCurrencies: ${catalog.currencies.map((c) => c.id).join(', ')}\n`);
|
|
202
206
|
return 0;
|
|
203
207
|
}
|
|
204
|
-
return report(await store.setValuations(changes), `Point values updated in ${where}: ${Object.entries(changes)
|
|
208
|
+
return report(await store.setValuations(given.changes), `Point values updated in ${where}: ${Object.entries(given.changes)
|
|
205
209
|
.map(([id, c]) => `${id} ${c === null ? 'back to the default' : `${String(c)}¢`}`)
|
|
206
210
|
.join(', ')}.`, null, io);
|
|
207
211
|
}
|
package/dist/data/file-store.js
CHANGED
|
@@ -87,8 +87,8 @@ export class FileWalletStore {
|
|
|
87
87
|
return failed(`${purchase.cardId} isn't in the wallet.`);
|
|
88
88
|
const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
|
|
89
89
|
try {
|
|
90
|
-
if (booked.cap || booked.bonus)
|
|
91
|
-
await writeProgress(this.where, booked.cap ? [booked.cap] : [], booked.bonus ? [booked.bonus] : []);
|
|
90
|
+
if (booked.cap || booked.bonus || booked.milestones.length > 0)
|
|
91
|
+
await writeProgress(this.where, booked.cap ? [booked.cap] : [], booked.bonus ? [booked.bonus] : [], booked.milestones);
|
|
92
92
|
}
|
|
93
93
|
catch (e) {
|
|
94
94
|
return failed(errorText(e));
|
package/dist/data/integrity.js
CHANGED
|
@@ -86,12 +86,23 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
86
86
|
['rule', card.rules.map((r) => r.id)],
|
|
87
87
|
['cap', card.caps.map((c) => c.id)],
|
|
88
88
|
['choice', card.choices.map((c) => c.id)],
|
|
89
|
+
['milestone', card.milestones.map((m) => m.id)],
|
|
89
90
|
]) {
|
|
90
91
|
for (const id of duplicates(ids))
|
|
91
92
|
problems.push(`${where}: duplicate ${kind} id '${id}'`);
|
|
92
93
|
}
|
|
94
|
+
// Evidence names rules and milestones alike, so their ids can't overlap.
|
|
95
|
+
for (const m of card.milestones)
|
|
96
|
+
if (card.rules.some((r) => r.id === m.id))
|
|
97
|
+
problems.push(`${where}: milestone '${m.id}' has a rule's id`);
|
|
98
|
+
if (card.foreign_transaction_fee === null &&
|
|
99
|
+
!card.todo.some((t) => /foreign transaction fee/i.test(t)))
|
|
100
|
+
problems.push(`${where}: foreign_transaction_fee is null without a todo saying why`);
|
|
93
101
|
const ruleIds = new Set([
|
|
94
102
|
'base_rate',
|
|
103
|
+
...(card.foreign_transaction_fee !== null
|
|
104
|
+
? ['foreign_transaction_fee']
|
|
105
|
+
: []),
|
|
95
106
|
...(card.pools_into ? ['pools_into'] : []),
|
|
96
107
|
...(card.annual_fee !== undefined ? ['annual_fee'] : []),
|
|
97
108
|
...(card.first_year_fee !== undefined ? ['first_year_fee'] : []),
|
|
@@ -99,6 +110,7 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
99
110
|
...(card.welcome_offer?.not_with ? ['not_with'] : []),
|
|
100
111
|
...(card.requires_membership ? ['requires_membership'] : []),
|
|
101
112
|
...card.rules.map((r) => r.id),
|
|
113
|
+
...card.milestones.map((m) => m.id),
|
|
102
114
|
]);
|
|
103
115
|
const sourcePages = new Set(card.source_urls.map((u) => u.split('#')[0]));
|
|
104
116
|
const backed = new Set(card.evidence.map((e) => e.rule));
|
|
@@ -248,6 +260,11 @@ export function walletProblems(wallet, catalog) {
|
|
|
248
260
|
if (!capKeys.has(key))
|
|
249
261
|
problems.push(`${where} cap_usage: '${key}' is not a cap on this card`);
|
|
250
262
|
}
|
|
263
|
+
const milestoneIds = new Set(card.milestones.map((m) => m.id));
|
|
264
|
+
for (const key of Object.keys(held.milestone_usage ?? {})) {
|
|
265
|
+
if (!milestoneIds.has(key))
|
|
266
|
+
problems.push(`${where} milestone_usage: '${key}' is not a spend milestone on this card`);
|
|
267
|
+
}
|
|
251
268
|
if (held.chosen) {
|
|
252
269
|
for (const [choiceId, picks] of Object.entries(held.chosen.picks)) {
|
|
253
270
|
const choice = card.choices.find((c) => c.id === choiceId);
|
package/dist/data/store.js
CHANGED
|
@@ -86,7 +86,7 @@ export class JsonWalletStore {
|
|
|
86
86
|
if (!wallet.cards.some((c) => c.id === purchase.cardId))
|
|
87
87
|
return failed(`${purchase.cardId} isn't in the wallet.`);
|
|
88
88
|
const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
|
|
89
|
-
if (booked.cap || booked.bonus)
|
|
89
|
+
if (booked.cap || booked.bonus || booked.milestones.length > 0)
|
|
90
90
|
await this.backend.write(JSON.stringify(booked.wallet));
|
|
91
91
|
return { ok: true, ...booked };
|
|
92
92
|
});
|
package/dist/data/wallet-edit.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { writeFileAtomic } from "./atomic-write.js";
|
|
3
3
|
import { isDeepStrictEqual } from 'node:util';
|
|
4
4
|
import { isMap, isSeq, parseDocument, stringify } from 'yaml';
|
|
5
|
-
import { applyBonusBooking, applyBooking, } from "../engine/record.js";
|
|
5
|
+
import { applyBonusBooking, applyBooking, applyMilestoneBooking, } from "../engine/record.js";
|
|
6
6
|
import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
|
|
7
7
|
const NEW_WALLET = `# Your earnrate wallet: the cards you hold, by id. Never card numbers.
|
|
8
8
|
# Format: https://github.com/cwasley/earnrate#your-wallet
|
|
@@ -47,6 +47,14 @@ function fieldLines(field, f, indent) {
|
|
|
47
47
|
.join('');
|
|
48
48
|
return caps === '' ? '' : `${indent}cap_usage:\n${caps}`;
|
|
49
49
|
}
|
|
50
|
+
case 'milestone_usage': {
|
|
51
|
+
if (f.milestone_usage === undefined)
|
|
52
|
+
return '';
|
|
53
|
+
const entries = Object.entries(f.milestone_usage)
|
|
54
|
+
.map(([key, u]) => `${indent} ${key}: { period: ${scalar(u.period)}, spent: ${String(u.spent)} }\n`)
|
|
55
|
+
.join('');
|
|
56
|
+
return entries === '' ? '' : `${indent}milestone_usage:\n${entries}`;
|
|
57
|
+
}
|
|
50
58
|
case 'bonus': {
|
|
51
59
|
if (!f.bonus)
|
|
52
60
|
return '';
|
|
@@ -81,6 +89,7 @@ const FIELDS = [
|
|
|
81
89
|
'chosen',
|
|
82
90
|
'activated',
|
|
83
91
|
'cap_usage',
|
|
92
|
+
'milestone_usage',
|
|
84
93
|
'bonus',
|
|
85
94
|
];
|
|
86
95
|
function cardsSeq(text) {
|
|
@@ -270,31 +279,30 @@ function setValuationText(text, id, cents) {
|
|
|
270
279
|
const indent = ' '.repeat(column(text, range(first?.key)[0]));
|
|
271
280
|
return insertLines(text, afterLine(text, range(last?.value ?? last?.key)[1] - 1), `${indent}${line}\n`);
|
|
272
281
|
}
|
|
273
|
-
function flowUsage(
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
: booking.period;
|
|
277
|
-
return `{ period: ${period}, spent: ${String(booking.after)} }`;
|
|
282
|
+
function flowUsage(period, spent) {
|
|
283
|
+
const key = /^\d+$/.test(period) ? `"${period}"` : period;
|
|
284
|
+
return `{ period: ${key}, spent: ${String(spent)} }`;
|
|
278
285
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
286
|
+
// Sets one entry of a card's cap_usage or milestone_usage, adding the map
|
|
287
|
+
// when the card has none.
|
|
288
|
+
function usageText(text, cardId, field, key, value) {
|
|
289
|
+
const card = findCard(text, cardId);
|
|
290
|
+
const usage = card.get(field, true);
|
|
283
291
|
if (isMap(usage) && !usage.flow && usage.items.length > 0) {
|
|
284
|
-
const existing = usage.items.find((p) => keyOf(p) ===
|
|
292
|
+
const existing = usage.items.find((p) => keyOf(p) === key);
|
|
285
293
|
if (existing) {
|
|
286
294
|
const [start, end] = range(existing.value);
|
|
287
295
|
return text.slice(0, start) + value + text.slice(end);
|
|
288
296
|
}
|
|
289
297
|
const indent = ' '.repeat(column(text, range(usage.items[0]?.key)[0]));
|
|
290
298
|
const at = afterLine(text, range(usage)[1] - 1);
|
|
291
|
-
return insertLines(text, at, `${indent}${
|
|
299
|
+
return insertLines(text, at, `${indent}${key}: ${value}\n`);
|
|
292
300
|
}
|
|
293
301
|
if (usage !== undefined)
|
|
294
|
-
throw new Error(`card '${
|
|
302
|
+
throw new Error(`card '${cardId}' has a ${field} that isn't a block map; ${BY_HAND}`);
|
|
295
303
|
const indent = ' '.repeat(column(text, range(card.items[0]?.key)[0]));
|
|
296
304
|
const at = afterLine(text, range(card)[1] - 1);
|
|
297
|
-
return insertLines(text, at, `${indent}
|
|
305
|
+
return insertLines(text, at, `${indent}${field}:\n${indent} ${key}: ${value}\n`);
|
|
298
306
|
}
|
|
299
307
|
function bonusSpentText(text, cardId, spent) {
|
|
300
308
|
const bonus = findCard(text, cardId).get('bonus', true);
|
|
@@ -314,24 +322,30 @@ function bonusSpentText(text, cardId, spent) {
|
|
|
314
322
|
const at = afterLine(text, range(bonus)[1] - 1);
|
|
315
323
|
return insertLines(text, at, `${indent}spent: ${value}\n`);
|
|
316
324
|
}
|
|
317
|
-
// Writes spending toward caps
|
|
318
|
-
|
|
325
|
+
// Writes spending toward caps, welcome bonuses and spend milestones in one
|
|
326
|
+
// checked write.
|
|
327
|
+
export async function writeProgress(file, caps, bonuses, milestones = []) {
|
|
319
328
|
const { text, wallet } = await readWalletText(file);
|
|
320
329
|
if (text === null || wallet === null)
|
|
321
330
|
throw new Error(`No wallet at ${file}.`);
|
|
322
331
|
let after = text;
|
|
323
332
|
let expected = wallet;
|
|
324
333
|
for (const b of caps) {
|
|
325
|
-
after =
|
|
334
|
+
after = usageText(after, b.cardId, 'cap_usage', b.capKey, flowUsage(b.period, b.after));
|
|
326
335
|
expected = applyBooking(expected, b);
|
|
327
336
|
}
|
|
337
|
+
for (const m of milestones) {
|
|
338
|
+
after = usageText(after, m.cardId, 'milestone_usage', m.milestoneId, flowUsage(m.period, m.after));
|
|
339
|
+
expected = applyMilestoneBooking(expected, m);
|
|
340
|
+
}
|
|
328
341
|
for (const b of bonuses) {
|
|
329
342
|
after = bonusSpentText(after, b.cardId, b.after);
|
|
330
343
|
expected = applyBonusBooking(expected, b);
|
|
331
344
|
}
|
|
332
345
|
const byHand = [
|
|
333
|
-
...caps.map((b) => `${b.cardId} cap_usage ${b.capKey}: ${flowUsage(b)}`),
|
|
346
|
+
...caps.map((b) => `${b.cardId} cap_usage ${b.capKey}: ${flowUsage(b.period, b.after)}`),
|
|
334
347
|
...bonuses.map((b) => `${b.cardId} bonus spent: ${String(b.after)}`),
|
|
348
|
+
...milestones.map((m) => `${m.cardId} milestone_usage ${m.milestoneId}: ${flowUsage(m.period, m.after)}`),
|
|
335
349
|
].join('; ');
|
|
336
350
|
await commitWalletText(file, after, expected, `add it by hand: ${byHand}`);
|
|
337
351
|
}
|
|
@@ -3,7 +3,7 @@ import { exploreCards } from "./explore.js";
|
|
|
3
3
|
import { bonusStatus } from "./bonus.js";
|
|
4
4
|
import { EPSILON, isClose } from "./compare.js";
|
|
5
5
|
import { periodKey } from "./periods.js";
|
|
6
|
-
import { recommend } from "./recommend.js";
|
|
6
|
+
import { ABROAD, recommend } from "./recommend.js";
|
|
7
7
|
import { checklist } from "./checklist.js";
|
|
8
8
|
import { setupNotes } from "./setup.js";
|
|
9
9
|
import { capitalize, feeText, listed, money, shortDate } from "./text.js";
|
|
@@ -16,7 +16,7 @@ export const EVERYDAY_PURCHASES = [
|
|
|
16
16
|
{ label: 'Amazon', merchant: 'amazon' },
|
|
17
17
|
{ label: 'Costco', merchant: 'costco' },
|
|
18
18
|
{ label: 'Travel', category: 'travel' },
|
|
19
|
-
{ label: 'Streaming',
|
|
19
|
+
{ label: 'Streaming (Netflix)', merchant: 'netflix' },
|
|
20
20
|
{ label: 'Drugstores', category: 'drugstore' },
|
|
21
21
|
{ label: 'Everything else', category: 'other' },
|
|
22
22
|
];
|
|
@@ -50,6 +50,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
|
50
50
|
note: top?.warnings[0] ?? null,
|
|
51
51
|
};
|
|
52
52
|
});
|
|
53
|
+
rows.push(abroadRow(catalog, wallet, date));
|
|
53
54
|
const bonuses = wallet.cards.flatMap((held) => {
|
|
54
55
|
const status = bonusStatus(catalog, wallet, held, date);
|
|
55
56
|
if (status?.state !== 'open')
|
|
@@ -106,6 +107,31 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
|
106
107
|
notes,
|
|
107
108
|
};
|
|
108
109
|
}
|
|
110
|
+
const ABROAD_LABEL = 'Abroad';
|
|
111
|
+
// Everyday spending outside the U.S., after each card's foreign transaction
|
|
112
|
+
// fee.
|
|
113
|
+
function abroadRow(catalog, wallet, date) {
|
|
114
|
+
const merchant = categoryResolution(catalog, 'other', null);
|
|
115
|
+
const rec = recommend(catalog, wallet, {
|
|
116
|
+
merchant: merchant.input,
|
|
117
|
+
amount: null,
|
|
118
|
+
channel: null,
|
|
119
|
+
date,
|
|
120
|
+
country: ABROAD,
|
|
121
|
+
}, merchant);
|
|
122
|
+
const top = rec.decision ? rec.ranked[0] : undefined;
|
|
123
|
+
const allCharge = rec.ranked.length > 0 && rec.ranked.every((r) => r.foreignFee !== null);
|
|
124
|
+
return {
|
|
125
|
+
label: ABROAD_LABEL,
|
|
126
|
+
cardId: top?.cardId ?? null,
|
|
127
|
+
cardName: top ? (top.checkoutLabel ?? top.cardName) : null,
|
|
128
|
+
effectiveReturnPct: top?.effectiveReturnPct ?? null,
|
|
129
|
+
matchedRule: top?.matchedRule ?? null,
|
|
130
|
+
note: allCharge
|
|
131
|
+
? `Every card here charges a foreign transaction fee abroad; this one costs the least. ${top?.warnings[0] ?? ''}`.trim()
|
|
132
|
+
: (top?.warnings[0] ?? null),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
109
135
|
// The best card earnrate knows for each everyday kind of purchase, for
|
|
110
136
|
// someone who hasn't named any cards. Each row's note says what the card
|
|
111
137
|
// costs or needs.
|
|
@@ -139,6 +165,24 @@ export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCH
|
|
|
139
165
|
: null,
|
|
140
166
|
};
|
|
141
167
|
});
|
|
168
|
+
const abroad = exploreCards(catalog, {
|
|
169
|
+
merchant: null,
|
|
170
|
+
category: 'other',
|
|
171
|
+
amount: null,
|
|
172
|
+
channel: null,
|
|
173
|
+
date,
|
|
174
|
+
country: ABROAD,
|
|
175
|
+
}, options).ranked[0];
|
|
176
|
+
rows.push({
|
|
177
|
+
label: ABROAD_LABEL,
|
|
178
|
+
cardId: abroad?.cardId ?? null,
|
|
179
|
+
cardName: abroad?.cardName ?? null,
|
|
180
|
+
effectiveReturnPct: abroad?.effectiveReturnPct ?? null,
|
|
181
|
+
matchedRule: abroad?.matchedRule ?? null,
|
|
182
|
+
note: abroad
|
|
183
|
+
? [`${capitalize(feeText(abroad))}.`, ...abroad.conditions].join(' ')
|
|
184
|
+
: null,
|
|
185
|
+
});
|
|
142
186
|
const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
|
|
143
187
|
return {
|
|
144
188
|
scope: 'all cards',
|
package/dist/engine/checklist.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { currentPeriodEnd, daysBetween } from "./periods.js";
|
|
2
2
|
import { CAP_PERIODS, choiceName, listed, money, shortDate, wholeDollars, } from "./text.js";
|
|
3
|
+
import { milestonePays } from "./milestones.js";
|
|
3
4
|
import { optionName } from "./rule-text.js";
|
|
4
5
|
import { activationsDue, walletSummary, } from "./wallet-summary.js";
|
|
5
6
|
// What the wallet's cards need this period, soonest first: bonuses to
|
|
@@ -33,24 +34,23 @@ shortName) {
|
|
|
33
34
|
c.period !== 'until_changed');
|
|
34
35
|
const [first] = unpicked;
|
|
35
36
|
if (first) {
|
|
36
|
-
const
|
|
37
|
+
const due = first.period === 'until_changed'
|
|
37
38
|
? null
|
|
38
|
-
:
|
|
39
|
+
: currentPeriodEnd(first.period, today, null);
|
|
39
40
|
items.push({
|
|
40
41
|
kind: 'pick',
|
|
41
42
|
cardId: card.id,
|
|
42
43
|
text: `Pick ${name}'s ${listed(unpicked.map((c) => choiceName(c.id)))} categories for this ${first.period.replace('_', ' ')}; until then they earn the base rate.`,
|
|
43
|
-
due
|
|
44
|
+
due,
|
|
44
45
|
});
|
|
45
46
|
}
|
|
46
47
|
else {
|
|
47
48
|
// Picks made for this period: near its end, a reminder to make the
|
|
48
49
|
// next period's (Cash+ asks each quarter).
|
|
49
50
|
const ending = card.choices.find((c) => c.period !== 'until_changed' && !c.default && current);
|
|
50
|
-
const
|
|
51
|
+
const end = ending === undefined || ending.period === 'until_changed'
|
|
51
52
|
? null
|
|
52
|
-
:
|
|
53
|
-
const end = period === null ? null : periodEnd(period);
|
|
53
|
+
: currentPeriodEnd(ending.period, today, null);
|
|
54
54
|
if (ending && end !== null && daysBetween(today, end) <= 14)
|
|
55
55
|
items.push({
|
|
56
56
|
kind: 'pick',
|
|
@@ -72,7 +72,7 @@ shortName) {
|
|
|
72
72
|
// Only caps nearly used up: the rest aren't something to do.
|
|
73
73
|
if (cap.remaining === null || cap.remaining > cap.amount * 0.25)
|
|
74
74
|
continue;
|
|
75
|
-
const resets = cap.
|
|
75
|
+
const resets = cap.until;
|
|
76
76
|
items.push({
|
|
77
77
|
kind: 'cap',
|
|
78
78
|
cardId: card.id,
|
|
@@ -82,6 +82,17 @@ shortName) {
|
|
|
82
82
|
due: resets,
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
|
+
for (const m of s.milestones) {
|
|
86
|
+
// Like caps: only once at least 75% of the way there.
|
|
87
|
+
if (!m.tracked || m.spent < m.spend * 0.75 || m.spent >= m.spend)
|
|
88
|
+
continue;
|
|
89
|
+
items.push({
|
|
90
|
+
kind: 'milestone',
|
|
91
|
+
cardId: card.id,
|
|
92
|
+
text: `${name}: ${money(m.spent)} of ${wholeDollars(m.spend)} spent toward ${milestonePays(m)}; ${money(m.spend - m.spent)} to go${m.until === null ? '' : ` by ${shortDate(m.until)}`}.`,
|
|
93
|
+
due: m.until,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
85
96
|
const b = s.bonus;
|
|
86
97
|
if (b?.state === 'open' && b.remaining !== null && b.deadline !== null) {
|
|
87
98
|
const weeks = Math.max(1, (b.daysLeft ?? 7) / 7);
|
package/dist/engine/evaluate.js
CHANGED
|
@@ -110,6 +110,8 @@ function applicability(card, rule, ctx, held, picked) {
|
|
|
110
110
|
return 'no';
|
|
111
111
|
if (rule.geography === 'us_only' && ctx.country !== 'US')
|
|
112
112
|
return 'no';
|
|
113
|
+
if (rule.geography === 'foreign_only' && ctx.country === 'US')
|
|
114
|
+
return 'no';
|
|
113
115
|
if (!inEffect(rule, ctx.date))
|
|
114
116
|
return 'no';
|
|
115
117
|
const membership = rule.requires?.membership;
|
package/dist/engine/explore.js
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { periodEnd, periodKey } from "./periods.js";
|
|
2
|
+
import { listed, MILESTONE_PERIODS, money, roundCents, wholeDollars, } from "./text.js";
|
|
3
|
+
export function milestoneProgress(card, held, date) {
|
|
4
|
+
return card.milestones.map((milestone) => {
|
|
5
|
+
const period = periodKey(milestone.period, date, held.anniversary ?? null);
|
|
6
|
+
const usage = held.milestone_usage?.[milestone.id];
|
|
7
|
+
return {
|
|
8
|
+
milestone,
|
|
9
|
+
period,
|
|
10
|
+
spent: usage !== undefined && usage.period === period ? usage.spent : 0,
|
|
11
|
+
tracked: usage !== undefined,
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
// The milestones this purchase reaches: tracked spending before it is under
|
|
16
|
+
// the total, and with it the total is met.
|
|
17
|
+
export function milestonesReached(card, held, date, amount) {
|
|
18
|
+
if (amount === null || amount <= 0)
|
|
19
|
+
return [];
|
|
20
|
+
return milestoneProgress(card, held, date).flatMap(({ milestone: m, period, spent, tracked }) => tracked &&
|
|
21
|
+
period !== null &&
|
|
22
|
+
spent < m.spend &&
|
|
23
|
+
m.spend <= roundCents(spent + amount)
|
|
24
|
+
? [
|
|
25
|
+
{
|
|
26
|
+
id: m.id,
|
|
27
|
+
spend: m.spend,
|
|
28
|
+
period,
|
|
29
|
+
before: spent,
|
|
30
|
+
points: m.points ?? null,
|
|
31
|
+
rewards: m.rewards ?? [],
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
: []);
|
|
35
|
+
}
|
|
36
|
+
export const milestonePoints = (hits) => hits.reduce((sum, h) => sum + (h.points ?? 0), 0);
|
|
37
|
+
// "10,000 bonus points and Gold Elite status"
|
|
38
|
+
export function milestonePays(m) {
|
|
39
|
+
return listed([
|
|
40
|
+
...(m.points === undefined || m.points === null
|
|
41
|
+
? []
|
|
42
|
+
: [`${m.points.toLocaleString('en-US')} bonus points`]),
|
|
43
|
+
...(m.rewards ?? []),
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
// "the $15,000 calendar-year spend for 10,000 bonus points"
|
|
47
|
+
export const milestoneWhat = (m) => `the ${wholeDollars(m.spend)} ${MILESTONE_PERIODS[m.period]} spend for ${milestonePays(m)}`;
|
|
48
|
+
export function reachedText(card, hit) {
|
|
49
|
+
const m = card.milestones.find((x) => x.id === hit.id);
|
|
50
|
+
if (!m)
|
|
51
|
+
return '';
|
|
52
|
+
return `This purchase reaches ${milestoneWhat(m)} (${money(hit.before)} tracked on this card in ${hit.period} before it).`;
|
|
53
|
+
}
|
|
54
|
+
const THIS_PERIOD = {
|
|
55
|
+
calendar_year: 'this year',
|
|
56
|
+
cardmember_year: 'this cardmember year',
|
|
57
|
+
};
|
|
58
|
+
export function milestoneSummaries(card, held, today) {
|
|
59
|
+
return milestoneProgress(card, held, today).map(({ milestone: m, period, spent, tracked }) => {
|
|
60
|
+
const pays = milestonePays(m);
|
|
61
|
+
const text = period === null
|
|
62
|
+
? `${wholeDollars(m.spend)} a cardmember year for ${pays}: give the card's anniversary to track it.`
|
|
63
|
+
: !tracked
|
|
64
|
+
? `${wholeDollars(m.spend)} ${THIS_PERIOD[m.period]} for ${pays}; spending toward it isn't tracked yet.`
|
|
65
|
+
: spent >= m.spend
|
|
66
|
+
? `${wholeDollars(m.spend)} reached ${THIS_PERIOD[m.period]}: ${pays}.`
|
|
67
|
+
: `${money(spent)} of ${wholeDollars(m.spend)} ${THIS_PERIOD[m.period]} for ${pays}.`;
|
|
68
|
+
return {
|
|
69
|
+
id: m.id,
|
|
70
|
+
spend: m.spend,
|
|
71
|
+
period: m.period,
|
|
72
|
+
currentPeriod: period,
|
|
73
|
+
until: period === null
|
|
74
|
+
? null
|
|
75
|
+
: periodEnd(m.period, period, held.anniversary ?? null),
|
|
76
|
+
spent,
|
|
77
|
+
tracked,
|
|
78
|
+
points: m.points ?? null,
|
|
79
|
+
rewards: m.rewards ?? [],
|
|
80
|
+
text,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
}
|
package/dist/engine/periods.js
CHANGED
|
@@ -42,17 +42,34 @@ export function choicePeriod(card) {
|
|
|
42
42
|
// limit on that side.
|
|
43
43
|
export const inEffect = (window, date) => (window.valid_from === undefined || window.valid_from <= date) &&
|
|
44
44
|
(window.valid_to === undefined || window.valid_to >= date);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
45
|
+
const utcDay = (year, month, day) => new Date(Date.UTC(year, month - 1, day)).toISOString().slice(0, 10);
|
|
46
|
+
// The last day of the period a key from periodKey names. A calendar year and
|
|
47
|
+
// a cardmember year share the key shape ("2026"), so the kind is needed: a
|
|
48
|
+
// cardmember year ends the day before the next anniversary. null for a
|
|
49
|
+
// cardmember year without an anniversary, or a key that doesn't fit the kind.
|
|
50
|
+
export function periodEnd(period, key, anniversary) {
|
|
51
|
+
switch (period) {
|
|
52
|
+
case 'month': {
|
|
53
|
+
const m = /^(\d{4})-(\d{2})$/.exec(key);
|
|
54
|
+
return m ? utcDay(Number(m[1]), Number(m[2]) + 1, 0) : null;
|
|
55
|
+
}
|
|
56
|
+
case 'quarter': {
|
|
57
|
+
const q = /^(\d{4})-Q([1-4])$/.exec(key);
|
|
58
|
+
return q ? utcDay(Number(q[1]), Number(q[2]) * 3 + 1, 0) : null;
|
|
59
|
+
}
|
|
60
|
+
case 'calendar_year':
|
|
61
|
+
return /^\d{4}$/.test(key) ? `${key}-12-31` : null;
|
|
62
|
+
case 'cardmember_year': {
|
|
63
|
+
const a = anniversary === null ? null : /^(\d{2})-(\d{2})$/.exec(anniversary);
|
|
64
|
+
if (!a || !/^\d{4}$/.test(key))
|
|
65
|
+
return null;
|
|
66
|
+
// Date.UTC rolls over; a parsed "2027-02-29" would be invalid.
|
|
67
|
+
return utcDay(Number(key) + 1, Number(a[1]), Number(a[2]) - 1);
|
|
68
|
+
}
|
|
56
69
|
}
|
|
57
|
-
|
|
70
|
+
}
|
|
71
|
+
// The last day of the period `date` falls in.
|
|
72
|
+
export function currentPeriodEnd(period, date, anniversary) {
|
|
73
|
+
const key = periodKey(period, date, anniversary);
|
|
74
|
+
return key === null ? null : periodEnd(period, key, anniversary);
|
|
58
75
|
}
|