earnrate 0.2.7 → 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 +142 -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 +16 -1
- 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 +21 -9
- package/dist/cli/run.js +3 -1
- package/dist/cli/wallet.js +5 -1
- package/dist/data/atomic-write.js +13 -0
- package/dist/data/file-store.js +2 -2
- package/dist/data/integrity.js +17 -0
- package/dist/data/store.js +5 -2
- package/dist/data/wallet-edit.js +35 -24
- 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 +43 -9
- package/dist/engine/record.js +39 -1
- package/dist/engine/rule-text.js +5 -1
- package/dist/engine/setup.js +14 -2
- package/dist/engine/simulate.js +25 -3
- package/dist/engine/text.js +11 -0
- package/dist/engine/wallet-change.js +70 -3
- package/dist/engine/wallet-summary.js +17 -5
- package/dist/format/cards.js +3 -1
- package/dist/format/wallet-yaml.js +5 -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/merchants/cache.js +3 -5
- package/dist/merchants/fallback.js +8 -0
- package/dist/merchants/missed.js +5 -6
- package/dist/schema/card-number.js +31 -0
- package/dist/schema/card.js +22 -1
- package/dist/schema/common.js +4 -0
- package/dist/schema/wallet.js +12 -2
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { writeFileAtomic } from "../data/atomic-write.js";
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { parseArgs } from 'node:util';
|
|
5
6
|
import { z } from 'zod';
|
|
@@ -40,10 +41,10 @@ const LedgerSchema = z.strictObject({
|
|
|
40
41
|
// sha256 of card, date, amount and description. Not reversible.
|
|
41
42
|
recorded: z.array(z.string()),
|
|
42
43
|
});
|
|
43
|
-
// Caps
|
|
44
|
-
// 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.
|
|
45
46
|
const fingerprint = ({ cardId, line }, kind = 'cap') => createHash('sha256')
|
|
46
|
-
.update(`${kind === '
|
|
47
|
+
.update(`${kind === 'cap' ? '' : `${kind}|`}${cardId}|${line.date}|${String(line.amount)}|${line.description}`)
|
|
47
48
|
.digest('hex');
|
|
48
49
|
// "statement.csv:chase-sapphire-preferred" names the card inline.
|
|
49
50
|
function splitTarget(arg) {
|
|
@@ -131,6 +132,9 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
|
|
|
131
132
|
for (const b of a.bonuses) {
|
|
132
133
|
out.push(`Welcome bonus on ${labelOf(b.cardId)}: ${dollarsAndCents(b.before)} → ${dollarsAndCents(b.after)} of ${dollarsAndCents(b.spend)}${b.deadline ? ` by ${b.deadline}` : ''}.`);
|
|
133
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
|
+
}
|
|
134
138
|
return `${out.join('\n')}\n`;
|
|
135
139
|
}
|
|
136
140
|
export async function runImport(argv, io) {
|
|
@@ -228,6 +232,12 @@ export async function runImport(argv, io) {
|
|
|
228
232
|
], values.valuation);
|
|
229
233
|
io.stdout(formatAnalysis(analysis, labelOf, valuation, categoryName));
|
|
230
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
|
+
];
|
|
231
241
|
const toRecord = [
|
|
232
242
|
...(analysis.bookings.length > 0
|
|
233
243
|
? [`${String(analysis.bookings.length)} purchase(s) against your caps`]
|
|
@@ -235,6 +245,9 @@ export async function runImport(argv, io) {
|
|
|
235
245
|
...(bonusEntries.length > 0
|
|
236
246
|
? [`${String(bonusEntries.length)} toward welcome bonuses`]
|
|
237
247
|
: []),
|
|
248
|
+
...(milestoneEntries.length > 0
|
|
249
|
+
? [`${String(milestoneEntries.length)} toward spend bonuses`]
|
|
250
|
+
: []),
|
|
238
251
|
];
|
|
239
252
|
if (!values.apply || toRecord.length === 0) {
|
|
240
253
|
io.stdout(values.apply || toRecord.length === 0
|
|
@@ -242,15 +255,14 @@ export async function runImport(argv, io) {
|
|
|
242
255
|
: `Nothing was written. Run again with --apply to record ${toRecord.join(' and ')}.\n`);
|
|
243
256
|
return 0;
|
|
244
257
|
}
|
|
245
|
-
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);
|
|
246
259
|
for (const entry of analysis.bookings)
|
|
247
260
|
recorded.add(fingerprint(entry));
|
|
248
261
|
for (const entry of bonusEntries)
|
|
249
262
|
recorded.add(fingerprint(entry, 'bonus'));
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
await
|
|
253
|
-
await rename(tmp, ledgerFile);
|
|
263
|
+
for (const entry of milestoneEntries)
|
|
264
|
+
recorded.add(fingerprint(entry, 'milestone'));
|
|
265
|
+
await writeFileAtomic(ledgerFile, `${JSON.stringify({ version: 1, recorded: [...recorded] }, null, 2)}\n`);
|
|
254
266
|
io.stdout(`Recorded ${toRecord.join(' and ')} in ${displayPath(walletFile)}.\n`);
|
|
255
267
|
return 0;
|
|
256
268
|
}
|
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
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { chmod, mkdir, realpath, rename, stat, writeFile, } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
// Writes through a symlink rather than replacing it, and keeps the file's
|
|
4
|
+
// mode: a user's chmod 600 must survive every edit.
|
|
5
|
+
export async function writeFileAtomic(file, text) {
|
|
6
|
+
const target = await realpath(file).catch(() => file);
|
|
7
|
+
const mode = await stat(target).then((s) => s.mode & 0o777, () => 0o600);
|
|
8
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
9
|
+
const tmp = `${target}.${String(process.pid)}.tmp`;
|
|
10
|
+
await writeFile(tmp, text, { mode });
|
|
11
|
+
await chmod(tmp, mode); // writeFile's mode is narrowed by the umask
|
|
12
|
+
await rename(tmp, target);
|
|
13
|
+
}
|
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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { bookPurchase } from "../engine/record.js";
|
|
2
2
|
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
|
|
3
|
+
import { withoutCardNumbers } from "../schema/card-number.js";
|
|
3
4
|
import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
|
|
4
5
|
const failed = (error) => ({ ok: false, error });
|
|
5
6
|
// Memberships and point values can be saved before any card.
|
|
@@ -19,7 +20,9 @@ export class JsonWalletStore {
|
|
|
19
20
|
}
|
|
20
21
|
async load() {
|
|
21
22
|
const text = await this.backend.read();
|
|
22
|
-
return text === null
|
|
23
|
+
return text === null
|
|
24
|
+
? null
|
|
25
|
+
: WalletSchema.parse(withoutCardNumbers(JSON.parse(text)));
|
|
23
26
|
}
|
|
24
27
|
serialized(task) {
|
|
25
28
|
const run = this.queue.then(task, task);
|
|
@@ -83,7 +86,7 @@ export class JsonWalletStore {
|
|
|
83
86
|
if (!wallet.cards.some((c) => c.id === purchase.cardId))
|
|
84
87
|
return failed(`${purchase.cardId} isn't in the wallet.`);
|
|
85
88
|
const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
|
|
86
|
-
if (booked.cap || booked.bonus)
|
|
89
|
+
if (booked.cap || booked.bonus || booked.milestones.length > 0)
|
|
87
90
|
await this.backend.write(JSON.stringify(booked.wallet));
|
|
88
91
|
return { ok: true, ...booked };
|
|
89
92
|
});
|
package/dist/data/wallet-edit.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
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
|
}
|
|
@@ -359,8 +373,5 @@ export async function commitWalletText(file, after, expected, fallback = BY_HAND
|
|
|
359
373
|
if (!parsed.success || !isDeepStrictEqual(parsed.data, expected)) {
|
|
360
374
|
throw new Error(`couldn't update ${file} safely; ${fallback}`);
|
|
361
375
|
}
|
|
362
|
-
await
|
|
363
|
-
const tmp = `${file}.${String(process.pid)}.tmp`;
|
|
364
|
-
await writeFile(tmp, after);
|
|
365
|
-
await rename(tmp, file);
|
|
376
|
+
await writeFileAtomic(file, after);
|
|
366
377
|
}
|
|
@@ -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