earnrate 0.2.7 → 0.2.8
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/data/CHANGELOG.md +9 -0
- package/data/VERSION +1 -1
- package/data/cards/chase-prime-visa.yaml +12 -1
- package/dist/cli/import.js +3 -5
- package/dist/data/atomic-write.js +13 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +3 -6
- package/dist/engine/recommend.js +4 -0
- package/dist/engine/setup.js +1 -0
- package/dist/engine/wallet-change.js +3 -3
- package/dist/format/wallet-yaml.js +5 -1
- 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/wallet.js +10 -2
- package/package.json +1 -1
package/data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Every change to card rules, merchants, categories or currencies under `data/`. Bump `data/VERSION` (`YYYY.MM.DD`, plus `.N` for a second release on the same day) and add a section here in the same commit. Each row cites the issuer page the new value came from.
|
|
4
4
|
|
|
5
|
+
## 2026.11.02.5
|
|
6
|
+
|
|
7
|
+
Prime Visa's 3% without-Prime rate no longer applies at Whole Foods stores outside the U.S. Chase's terms exclude "purchases made at international Whole Foods Markets" from both the 5% and the 3% rate; only the 5% rule was U.S.-only.
|
|
8
|
+
|
|
9
|
+
| Card | Field | Old → new | Source |
|
|
10
|
+
| ---------------- | ------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
11
|
+
| chase-prime-visa | amazon-without-prime | Whole Foods moved to its own rule | [Chase Prime Visa page](https://creditcards.chase.com/cash-back-credit-cards/amazon-prime-rewards) |
|
|
12
|
+
| chase-prime-visa | whole-foods-without-prime | new rule: 3% at Whole Foods without Prime, U.S. only (abroad: 3% → 1%) | [Chase Prime Visa page](https://creditcards.chase.com/cash-back-credit-cards/amazon-prime-rewards) |
|
|
13
|
+
|
|
5
14
|
## 2026.11.02.4
|
|
6
15
|
|
|
7
16
|
Customized Cash's todo about purchases that cross the $2,500 quarterly cap is resolved: past the cap, a BofA Rewards tier's boosted base rate now applies. No rates changed.
|
package/data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.11.02.
|
|
1
|
+
2026.11.02.5
|
|
@@ -38,9 +38,14 @@ rules:
|
|
|
38
38
|
rate: 5
|
|
39
39
|
requires: { membership: amazon-prime }
|
|
40
40
|
- id: amazon-without-prime
|
|
41
|
-
match: { merchants: [amazon, amazon-fresh,
|
|
41
|
+
match: { merchants: [amazon, amazon-fresh, audible] }
|
|
42
42
|
rate: 3
|
|
43
43
|
note: Without an eligible Prime membership these earn 3% instead of 5%.
|
|
44
|
+
- id: whole-foods-without-prime
|
|
45
|
+
match: { merchants: [whole-foods] }
|
|
46
|
+
rate: 3
|
|
47
|
+
geography: us_only
|
|
48
|
+
note: Without an eligible Prime membership, Whole Foods earns 3% instead of 5%, in the U.S. only.
|
|
44
49
|
- id: chase-travel
|
|
45
50
|
match: { merchants: [chase-travel] }
|
|
46
51
|
rate: 5
|
|
@@ -93,6 +98,12 @@ evidence:
|
|
|
93
98
|
- rule: amazon-without-prime
|
|
94
99
|
url: https://creditcards.chase.com/cash-back-credit-cards/amazon-prime-rewards
|
|
95
100
|
quote: '3% back without an eligible Prime membership for each $1 of eligible purchases'
|
|
101
|
+
- rule: whole-foods-without-prime
|
|
102
|
+
url: https://creditcards.chase.com/cash-back-credit-cards/amazon-prime-rewards
|
|
103
|
+
quote: '3% back without an eligible Prime membership for each $1 of eligible purchases'
|
|
104
|
+
- rule: whole-foods-without-prime
|
|
105
|
+
url: https://creditcards.chase.com/cash-back-credit-cards/amazon-prime-rewards
|
|
106
|
+
quote: 'purchases made at international Whole Foods Markets'
|
|
96
107
|
- rule: chase-travel
|
|
97
108
|
url: https://creditcards.chase.com/cash-back-credit-cards/amazon-prime-rewards
|
|
98
109
|
quote: '5% back or 3% back through Chase Travel'
|
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';
|
|
@@ -247,10 +248,7 @@ export async function runImport(argv, io) {
|
|
|
247
248
|
recorded.add(fingerprint(entry));
|
|
248
249
|
for (const entry of bonusEntries)
|
|
249
250
|
recorded.add(fingerprint(entry, 'bonus'));
|
|
250
|
-
await
|
|
251
|
-
const tmp = `${ledgerFile}.${String(process.pid)}.tmp`;
|
|
252
|
-
await writeFile(tmp, `${JSON.stringify({ version: 1, recorded: [...recorded] }, null, 2)}\n`);
|
|
253
|
-
await rename(tmp, ledgerFile);
|
|
251
|
+
await writeFileAtomic(ledgerFile, `${JSON.stringify({ version: 1, recorded: [...recorded] }, null, 2)}\n`);
|
|
254
252
|
io.stdout(`Recorded ${toRecord.join(' and ')} in ${displayPath(walletFile)}.\n`);
|
|
255
253
|
return 0;
|
|
256
254
|
}
|
|
@@ -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/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);
|
package/dist/data/wallet-edit.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
5
|
import { applyBonusBooking, applyBooking, } from "../engine/record.js";
|
|
@@ -359,8 +359,5 @@ export async function commitWalletText(file, after, expected, fallback = BY_HAND
|
|
|
359
359
|
if (!parsed.success || !isDeepStrictEqual(parsed.data, expected)) {
|
|
360
360
|
throw new Error(`couldn't update ${file} safely; ${fallback}`);
|
|
361
361
|
}
|
|
362
|
-
await
|
|
363
|
-
const tmp = `${file}.${String(process.pid)}.tmp`;
|
|
364
|
-
await writeFile(tmp, after);
|
|
365
|
-
await rename(tmp, file);
|
|
362
|
+
await writeFileAtomic(file, after);
|
|
366
363
|
}
|
package/dist/engine/recommend.js
CHANGED
|
@@ -146,6 +146,10 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
146
146
|
warnings.push(`Your chosen categories are set for ${evaluation.staleChoice}, not the current period, so chosen-category bonuses don't apply until you update them.`);
|
|
147
147
|
}
|
|
148
148
|
for (const rule of evaluation.needsActivation) {
|
|
149
|
+
if (rule.activate_by !== undefined && rule.activate_by < purchase.date) {
|
|
150
|
+
warnings.push(`The bonus here (${earnText(catalog, rule.rate, valuation.currency)}) needed activating by ${shortDate(rule.activate_by)}.`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
149
153
|
warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
|
|
150
154
|
}
|
|
151
155
|
return warnings;
|
package/dist/engine/setup.js
CHANGED
|
@@ -45,6 +45,7 @@ export function activationRules(card, today) {
|
|
|
45
45
|
return card.rules
|
|
46
46
|
.filter((r) => r.requires?.activation === true &&
|
|
47
47
|
(r.valid_to === undefined || r.valid_to >= today) &&
|
|
48
|
+
(r.activate_by === undefined || r.activate_by >= today) &&
|
|
48
49
|
(r.valid_from === undefined ||
|
|
49
50
|
daysBetween(today, r.valid_from) <= ACTIVATION_LOOKAHEAD_DAYS))
|
|
50
51
|
.map((r) => r.id);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CARD_NUMBER_LABEL, labelHasCardNumber } from "../schema/card-number.js";
|
|
1
2
|
import { WALLET_VERSION, } from "../schema/wallet.js";
|
|
2
3
|
import { addMonths } from "./bonus.js";
|
|
3
4
|
import { capByKey } from "./evaluate.js";
|
|
@@ -33,9 +34,8 @@ export function cardFields(card, held, input, today) {
|
|
|
33
34
|
else if (input.checkoutLabel !== undefined) {
|
|
34
35
|
if (input.checkoutLabel.trim() === '')
|
|
35
36
|
problems.push('The checkout label is empty.');
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
problems.push('The checkout label looks like it has a card number in it. earnrate never stores card numbers; use a name like "Amex Gold".');
|
|
37
|
+
if (labelHasCardNumber(input.checkoutLabel))
|
|
38
|
+
problems.push(CARD_NUMBER_LABEL);
|
|
39
39
|
fields.checkout_label = input.checkoutLabel.trim();
|
|
40
40
|
}
|
|
41
41
|
if (input.anniversary !== undefined) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Document, parse, visit } from 'yaml';
|
|
2
2
|
import { walletProblems } from "../data/integrity.js";
|
|
3
|
+
import { CARD_NUMBER_LABEL, CARD_NUMBER_REDEMPTION, } from "../schema/card-number.js";
|
|
3
4
|
import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
|
|
4
5
|
// A wallet as the YAML file the CLI reads, for moving it between the
|
|
5
6
|
// website, the remote server and the command line. Node-free.
|
|
@@ -54,7 +55,10 @@ export function parseWalletYaml(text, catalog) {
|
|
|
54
55
|
}
|
|
55
56
|
const result = WalletSchema.safeParse(raw);
|
|
56
57
|
if (!result.success)
|
|
57
|
-
return
|
|
58
|
+
return (result.error.issues
|
|
59
|
+
.map((i) => i.message)
|
|
60
|
+
.find((m) => m === CARD_NUMBER_LABEL || m === CARD_NUMBER_REDEMPTION) ??
|
|
61
|
+
"That isn't an earnrate wallet.");
|
|
58
62
|
const problems = walletProblems(result.data, catalog);
|
|
59
63
|
return problems.length > 0 ? problems.join(' ') : result.data;
|
|
60
64
|
}
|
package/dist/merchants/cache.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { writeFileAtomic } from "../data/atomic-write.js";
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { z } from 'zod';
|
|
@@ -71,10 +72,7 @@ export async function openMerchantCache(file) {
|
|
|
71
72
|
merchants = { ...merchants, [key]: toEntry(value) };
|
|
72
73
|
if (!writable)
|
|
73
74
|
return;
|
|
74
|
-
await
|
|
75
|
-
const tmp = `${file}.${String(process.pid)}.tmp`;
|
|
76
|
-
await writeFile(tmp, `${JSON.stringify({ version: 1, merchants }, null, 2)}\n`);
|
|
77
|
-
await rename(tmp, file);
|
|
75
|
+
await writeFileAtomic(file, `${JSON.stringify({ version: 1, merchants }, null, 2)}\n`);
|
|
78
76
|
},
|
|
79
77
|
},
|
|
80
78
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LLM_MAX_CALLS_ENV_VAR } from "../brand.js";
|
|
2
|
+
import { hasCardNumber } from "../schema/card-number.js";
|
|
2
3
|
import { describeClassifierError } from "./anthropic.js";
|
|
3
4
|
import { normalizeMerchantName, resolveMerchant } from "./resolve.js";
|
|
4
5
|
export const DEFAULT_MAX_CALLS = 20;
|
|
@@ -42,6 +43,13 @@ export function createLlmSession(options) {
|
|
|
42
43
|
const known = resolveMerchant(catalog, input, channel);
|
|
43
44
|
if (known.source !== 'none')
|
|
44
45
|
return { merchant: known, notices: [] };
|
|
46
|
+
if (hasCardNumber(input))
|
|
47
|
+
return {
|
|
48
|
+
merchant: known,
|
|
49
|
+
notices: [
|
|
50
|
+
'Skipped the Claude lookup: that looks like a card number, and earnrate never sends one anywhere.',
|
|
51
|
+
],
|
|
52
|
+
};
|
|
45
53
|
const key = normalizeMerchantName(input);
|
|
46
54
|
const saved = options.cache.get(key);
|
|
47
55
|
if (saved && usable(catalog, saved)) {
|
package/dist/merchants/missed.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { writeFileAtomic } from "../data/atomic-write.js";
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { z } from 'zod';
|
|
5
6
|
import { CONFIG_DIR, MISSED_ENV_VAR } from "../brand.js";
|
|
7
|
+
import { hasCardNumber } from "../schema/card-number.js";
|
|
6
8
|
import { normalizeMerchantName } from "./resolve.js";
|
|
7
9
|
const MissedFileSchema = z.object({
|
|
8
10
|
version: z.literal(1),
|
|
@@ -37,7 +39,7 @@ export function missLogAt(file) {
|
|
|
37
39
|
return {
|
|
38
40
|
note(name, category, today) {
|
|
39
41
|
const key = normalizeMerchantName(name);
|
|
40
|
-
if (key === '')
|
|
42
|
+
if (key === '' || hasCardNumber(name))
|
|
41
43
|
return Promise.resolve();
|
|
42
44
|
const run = queue.then(async () => {
|
|
43
45
|
let merchants = {};
|
|
@@ -66,10 +68,7 @@ export function missLogAt(file) {
|
|
|
66
68
|
const kept = Object.entries(merchants)
|
|
67
69
|
.sort(([, a], [, b]) => b.last_seen.localeCompare(a.last_seen))
|
|
68
70
|
.slice(0, MAX);
|
|
69
|
-
await
|
|
70
|
-
const tmp = `${file}.${String(process.pid)}.tmp`;
|
|
71
|
-
await writeFile(tmp, `${JSON.stringify({ version: 1, merchants: Object.fromEntries(kept) }, null, 2)}\n`);
|
|
72
|
-
await rename(tmp, file);
|
|
71
|
+
await writeFileAtomic(file, `${JSON.stringify({ version: 1, merchants: Object.fromEntries(kept) }, null, 2)}\n`);
|
|
73
72
|
});
|
|
74
73
|
queue = run.catch(() => undefined);
|
|
75
74
|
return run.catch(() => undefined);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// earnrate never stores card numbers, on any surface. A card number is 13 to
|
|
2
|
+
// 19 digits; a phone number in a store's name is 10 or 11, so free text with
|
|
3
|
+
// a longer run is refused or not kept.
|
|
4
|
+
const digitsOnly = (text) => text.replace(/[\s.-]/g, '');
|
|
5
|
+
export const hasCardNumber = (text) => /\d{13,}/.test(digitsOnly(text));
|
|
6
|
+
// A checkout label may carry a last four, but never 8 or more digits.
|
|
7
|
+
export const labelHasCardNumber = (text) => /\d{8,}/.test(digitsOnly(text));
|
|
8
|
+
export const CARD_NUMBER_LABEL = 'The checkout label looks like it has a card number in it. earnrate never stores card numbers; use a name like "Amex Gold".';
|
|
9
|
+
export const CARD_NUMBER_REDEMPTION = 'The redemption note looks like it has a card number in it. earnrate never stores card numbers.';
|
|
10
|
+
// For wallets saved before the schema refused card numbers: drop the field
|
|
11
|
+
// instead of failing to load, since a wallet that can't load can't be fixed.
|
|
12
|
+
export function withoutCardNumbers(raw) {
|
|
13
|
+
if (!isRecord(raw))
|
|
14
|
+
return raw;
|
|
15
|
+
const wallet = { ...raw };
|
|
16
|
+
if (Array.isArray(wallet.cards))
|
|
17
|
+
wallet.cards = wallet.cards.map((card) => isRecord(card) && isCardNumber(card.checkout_label, labelHasCardNumber)
|
|
18
|
+
? omit(card, 'checkout_label')
|
|
19
|
+
: card);
|
|
20
|
+
if (isRecord(wallet.valuations))
|
|
21
|
+
wallet.valuations = Object.fromEntries(Object.entries(wallet.valuations).map(([currency, value]) => [
|
|
22
|
+
currency,
|
|
23
|
+
isRecord(value) && isCardNumber(value.redemption, hasCardNumber)
|
|
24
|
+
? omit(value, 'redemption')
|
|
25
|
+
: value,
|
|
26
|
+
]));
|
|
27
|
+
return wallet;
|
|
28
|
+
}
|
|
29
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
const isCardNumber = (value, test) => typeof value === 'string' && test(value);
|
|
31
|
+
const omit = (record, key) => Object.fromEntries(Object.entries(record).filter(([k]) => k !== key));
|
package/dist/schema/wallet.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { CurrencyId, IsoDate, KebabId, PeriodKey, ValueRange, } from "./common.js";
|
|
3
|
+
import { CARD_NUMBER_LABEL, CARD_NUMBER_REDEMPTION, hasCardNumber, labelHasCardNumber, } from "./card-number.js";
|
|
3
4
|
const Valuation = z.union([
|
|
4
5
|
z.number().positive(),
|
|
5
6
|
z.strictObject({
|
|
6
7
|
value: z.number().positive().optional(),
|
|
7
8
|
range: ValueRange.optional(),
|
|
8
|
-
redemption: z
|
|
9
|
+
redemption: z
|
|
10
|
+
.string()
|
|
11
|
+
.refine((t) => !hasCardNumber(t), CARD_NUMBER_REDEMPTION)
|
|
12
|
+
.optional(),
|
|
9
13
|
}),
|
|
10
14
|
]);
|
|
11
15
|
const CapUsage = z.strictObject({
|
|
@@ -14,7 +18,11 @@ const CapUsage = z.strictObject({
|
|
|
14
18
|
});
|
|
15
19
|
export const WalletCardSchema = z.strictObject({
|
|
16
20
|
id: KebabId,
|
|
17
|
-
checkout_label: z
|
|
21
|
+
checkout_label: z
|
|
22
|
+
.string()
|
|
23
|
+
.min(1)
|
|
24
|
+
.refine((t) => !labelHasCardNumber(t), CARD_NUMBER_LABEL)
|
|
25
|
+
.optional(),
|
|
18
26
|
// MM-DD, needed only for cardmember_year caps.
|
|
19
27
|
anniversary: z
|
|
20
28
|
.string()
|