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 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.4
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, whole-foods, audible] }
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'
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
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 mkdir(path.dirname(ledgerFile), { recursive: true });
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
+ }
@@ -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 ? null : WalletSchema.parse(JSON.parse(text));
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);
@@ -1,5 +1,5 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
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 mkdir(path.dirname(file), { recursive: true });
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
  }
@@ -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;
@@ -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
- // earnrate never stores card numbers; a last four is fine.
37
- if (/\d{8,}/.test(input.checkoutLabel.replace(/[\s-]/g, '')))
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 "That isn't an earnrate wallet.";
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
  }
@@ -1,4 +1,5 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
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 mkdir(path.dirname(file), { recursive: true });
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)) {
@@ -1,8 +1,10 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
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 mkdir(path.dirname(file), { recursive: true });
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));
@@ -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.string().optional(),
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.string().min(1).optional(),
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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Which credit card earns the most at a merchant, from your cards or every card, and why",
5
5
  "keywords": [
6
6
  "credit cards",