earnrate 0.2.5 → 0.2.6
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 +14 -11
- package/data/CHANGELOG.md +40 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +14 -6
- package/data/cards/amex-blue-cash-everyday.yaml +12 -3
- package/data/cards/amex-blue-cash-preferred.yaml +17 -3
- package/data/cards/amex-gold.yaml +19 -3
- package/data/cards/amex-platinum.yaml +19 -3
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +10 -2
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +14 -3
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +4 -1
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -4
- package/data/cards/bilt-obsidian.yaml +4 -3
- package/data/cards/capital-one-kohls-card.yaml +11 -2
- package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
- package/data/cards/capital-one-savor.yaml +32 -10
- package/data/cards/capital-one-venture-x.yaml +19 -4
- package/data/cards/capital-one-venture.yaml +19 -5
- package/data/cards/chase-aeroplan.yaml +15 -3
- package/data/cards/chase-freedom-flex.yaml +50 -10
- package/data/cards/chase-freedom-unlimited.yaml +6 -2
- package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
- package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -2
- package/data/cards/chase-ihg-one-rewards.yaml +8 -2
- package/data/cards/chase-ink-business-cash.yaml +7 -3
- package/data/cards/chase-ink-business-preferred.yaml +9 -2
- package/data/cards/chase-prime-visa.yaml +24 -5
- package/data/cards/chase-sapphire-preferred.yaml +59 -6
- package/data/cards/chase-sapphire-reserve.yaml +12 -2
- package/data/cards/chase-southwest-performance-business.yaml +22 -4
- package/data/cards/chase-southwest-plus.yaml +13 -3
- package/data/cards/chase-southwest-premier-business.yaml +22 -5
- package/data/cards/chase-southwest-premier.yaml +13 -3
- package/data/cards/chase-southwest-priority.yaml +18 -2
- package/data/cards/chase-united-business.yaml +5 -2
- package/data/cards/chase-united-club-business.yaml +2 -2
- package/data/cards/chase-united-club.yaml +16 -3
- package/data/cards/chase-united-explorer.yaml +5 -2
- package/data/cards/chase-united-gateway.yaml +2 -2
- package/data/cards/chase-united-quest.yaml +12 -3
- package/data/cards/citi-aadvantage-business.yaml +10 -4
- package/data/cards/citi-aadvantage-executive.yaml +8 -8
- package/data/cards/citi-aadvantage-mileup.yaml +7 -4
- package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
- package/data/cards/citi-costco-anywhere-visa-business.yaml +5 -2
- package/data/cards/citi-costco-anywhere-visa.yaml +6 -3
- package/data/cards/citi-custom-cash.yaml +43 -12
- package/data/cards/citi-double-cash.yaml +9 -3
- package/data/cards/citi-my-best-buy-visa.yaml +6 -3
- package/data/cards/citi-strata-elite.yaml +12 -2
- package/data/cards/citi-strata-premier.yaml +24 -2
- package/data/cards/sofi-unlimited-2.yaml +17 -3
- package/data/cards/td-bank-nordstrom-credit-card.yaml +12 -3
- package/data/cards/td-bank-nordstrom-visa.yaml +19 -3
- package/data/cards/td-bank-target-circle-card.yaml +13 -4
- package/data/cards/usbank-cash-plus.yaml +29 -7
- package/data/cards/wells-fargo-active-cash.yaml +9 -2
- package/data/cards/wells-fargo-autograph.yaml +31 -7
- package/data/issuers.yaml +32 -0
- package/data/merchants/seed.yaml +7 -0
- package/dist/cli/bin.js +10 -1
- package/dist/cli/cheatsheet.js +10 -5
- package/dist/cli/explore.js +6 -13
- package/dist/cli/import.js +2 -2
- package/dist/cli/offers.js +17 -7
- package/dist/cli/run.js +14 -17
- package/dist/cli/wallet.js +8 -5
- package/dist/data/file-store.js +6 -7
- package/dist/data/integrity.js +4 -0
- package/dist/data/load.js +4 -1
- package/dist/data/store.js +6 -22
- package/dist/data/wallet-edit.js +38 -13
- package/dist/engine/cheat-sheet.js +4 -5
- package/dist/engine/compare.js +2 -0
- package/dist/engine/evaluate.js +2 -4
- package/dist/engine/explore.js +4 -36
- package/dist/engine/memberships.js +1 -1
- package/dist/engine/offers.js +2 -1
- package/dist/engine/periods.js +18 -0
- package/dist/engine/recommend.js +14 -15
- package/dist/engine/setup.js +14 -24
- package/dist/engine/simulate.js +7 -6
- package/dist/engine/text.js +13 -1
- package/dist/engine/wallet-change.js +10 -12
- package/dist/engine/wallet-summary.js +64 -28
- package/dist/format/card-text.js +7 -20
- package/dist/format/explore.js +2 -2
- package/dist/format/offers.js +2 -2
- package/dist/format/recommendation.js +2 -2
- package/dist/format/wallet.js +1 -1
- package/dist/import/analyze.js +2 -1
- package/dist/mcp/context.js +30 -9
- package/dist/mcp/inputs.js +1 -1
- package/dist/mcp/schemas.js +1 -0
- package/dist/mcp/server.js +7 -1
- package/dist/mcp/tools/account.js +44 -16
- package/dist/mcp/tools/answers.js +58 -19
- package/dist/mcp/tools/wallet.js +58 -24
- package/dist/merchants/cache.js +0 -10
- package/dist/merchants/resolve.js +81 -4
- package/dist/schema/card.js +4 -1
- package/dist/schema/issuer.js +8 -0
- package/dist/schema/wallet.js +1 -1
- package/package.json +1 -1
package/dist/data/load.js
CHANGED
|
@@ -8,6 +8,7 @@ import { CONFIG_DIR, WALLET_ENV_VAR } from "../brand.js";
|
|
|
8
8
|
import { CardSchema } from "../schema/card.js";
|
|
9
9
|
import { CategoriesFileSchema } from "../schema/category.js";
|
|
10
10
|
import { CurrenciesFileSchema } from "../schema/currency.js";
|
|
11
|
+
import { IssuersFileSchema } from "../schema/issuer.js";
|
|
11
12
|
import { MembershipsFileSchema } from "../schema/membership.js";
|
|
12
13
|
import { MerchantsFileSchema } from "../schema/merchant.js";
|
|
13
14
|
import { WalletSchema } from "../schema/wallet.js";
|
|
@@ -31,9 +32,10 @@ async function loadCards(dir) {
|
|
|
31
32
|
}));
|
|
32
33
|
}
|
|
33
34
|
export async function loadCatalog(dir = DATA_DIR) {
|
|
34
|
-
const [categories, currencies, memberships, cards, seed, overrides] = await Promise.all([
|
|
35
|
+
const [categories, currencies, issuers, memberships, cards, seed, overrides] = await Promise.all([
|
|
35
36
|
readYaml(path.join(dir, 'categories.yaml'), CategoriesFileSchema),
|
|
36
37
|
readYaml(path.join(dir, 'currencies.yaml'), CurrenciesFileSchema),
|
|
38
|
+
readYaml(path.join(dir, 'issuers.yaml'), IssuersFileSchema),
|
|
37
39
|
readYaml(path.join(dir, 'memberships.yaml'), MembershipsFileSchema),
|
|
38
40
|
loadCards(path.join(dir, 'cards')),
|
|
39
41
|
readYaml(path.join(dir, 'merchants', 'seed.yaml'), MerchantsFileSchema),
|
|
@@ -42,6 +44,7 @@ export async function loadCatalog(dir = DATA_DIR) {
|
|
|
42
44
|
return {
|
|
43
45
|
categories,
|
|
44
46
|
currencies,
|
|
47
|
+
issuers,
|
|
45
48
|
memberships,
|
|
46
49
|
cards,
|
|
47
50
|
merchants: { seed, overrides },
|
package/dist/data/store.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { bookPurchase } from "../engine/record.js";
|
|
2
2
|
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
|
|
3
|
-
import { WalletSchema } from "../schema/wallet.js";
|
|
3
|
+
import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
|
|
4
4
|
const failed = (error) => ({ ok: false, error });
|
|
5
|
+
// Memberships and point values can be saved before any card.
|
|
6
|
+
export const emptyWallet = () => WalletSchema.parse({ version: WALLET_VERSION });
|
|
5
7
|
// A wallet stored as JSON text. Writes are serialized within this instance;
|
|
6
8
|
// a backend shared across processes must serialize them itself (one Durable
|
|
7
9
|
// Object per wallet does).
|
|
@@ -30,11 +32,7 @@ export class JsonWalletStore {
|
|
|
30
32
|
const change = apply(before);
|
|
31
33
|
if (!change.ok)
|
|
32
34
|
return failed(change.error);
|
|
33
|
-
|
|
34
|
-
if (change.wallet.cards.length === 0)
|
|
35
|
-
await this.backend.remove();
|
|
36
|
-
else
|
|
37
|
-
await this.backend.write(JSON.stringify(change.wallet));
|
|
35
|
+
await this.backend.write(JSON.stringify(change.wallet));
|
|
38
36
|
return {
|
|
39
37
|
ok: true,
|
|
40
38
|
wallet: change.wallet,
|
|
@@ -58,10 +56,10 @@ export class JsonWalletStore {
|
|
|
58
56
|
return this.change(this.needsWallet((w) => removeFromWallet(w, cardId)));
|
|
59
57
|
}
|
|
60
58
|
setMemberships(memberships) {
|
|
61
|
-
return this.change(
|
|
59
|
+
return this.change((w) => withMemberships(this.catalog, w ?? emptyWallet(), memberships));
|
|
62
60
|
}
|
|
63
61
|
setValuations(changes) {
|
|
64
|
-
return this.change(
|
|
62
|
+
return this.change((w) => withValuations(this.catalog, w ?? emptyWallet(), changes));
|
|
65
63
|
}
|
|
66
64
|
replace(wallet) {
|
|
67
65
|
return this.change(() => ({ ok: true, wallet, fields: {}, notes: [] }));
|
|
@@ -91,17 +89,3 @@ export class JsonWalletStore {
|
|
|
91
89
|
});
|
|
92
90
|
}
|
|
93
91
|
}
|
|
94
|
-
export function memoryBackend(initial = null) {
|
|
95
|
-
let text = initial;
|
|
96
|
-
return {
|
|
97
|
-
read: () => Promise.resolve(text),
|
|
98
|
-
write: (next) => {
|
|
99
|
-
text = next;
|
|
100
|
-
return Promise.resolve();
|
|
101
|
-
},
|
|
102
|
-
remove: () => {
|
|
103
|
-
text = null;
|
|
104
|
-
return Promise.resolve();
|
|
105
|
-
},
|
|
106
|
-
};
|
|
107
|
-
}
|
package/dist/data/wallet-edit.js
CHANGED
|
@@ -93,6 +93,17 @@ function cardsSeq(text) {
|
|
|
93
93
|
}
|
|
94
94
|
return { doc, cards };
|
|
95
95
|
}
|
|
96
|
+
function rootMap(text) {
|
|
97
|
+
const doc = parseDocument(text);
|
|
98
|
+
if (doc.errors.length > 0)
|
|
99
|
+
throw new Error(doc.errors[0]?.message);
|
|
100
|
+
const root = doc.contents;
|
|
101
|
+
if (!isMap(root))
|
|
102
|
+
throw new Error(`the wallet isn't a map; ${BY_HAND}`);
|
|
103
|
+
return root;
|
|
104
|
+
}
|
|
105
|
+
// A wallet with no cards yet, for memberships or point values saved first.
|
|
106
|
+
export const EMPTY_WALLET_TEXT = `${NEW_WALLET}version: ${String(WALLET_VERSION)}\ncards: []\n`;
|
|
96
107
|
function findCard(text, cardId) {
|
|
97
108
|
const { cards } = cardsSeq(text);
|
|
98
109
|
const card = cards.items.find((item) => isMap(item) && item.get('id') === cardId);
|
|
@@ -108,6 +119,17 @@ export function addCardText(text, entry) {
|
|
|
108
119
|
if (text === null || text.trim() === '') {
|
|
109
120
|
return `${NEW_WALLET}version: ${String(WALLET_VERSION)}\ncards:\n${cardLines(entry, ' ')}`;
|
|
110
121
|
}
|
|
122
|
+
const pair = rootMap(text).items.find((p) => keyOf(p) === 'cards');
|
|
123
|
+
const value = pair?.value;
|
|
124
|
+
const none = value === null || (isSeq(value) && value.items.length === 0);
|
|
125
|
+
if (!pair || none) {
|
|
126
|
+
const block = `cards:\n${cardLines(entry, ' ')}`;
|
|
127
|
+
if (!pair)
|
|
128
|
+
return insertLines(text, text.length, block);
|
|
129
|
+
const start = lineStart(text, range(pair.key)[0]);
|
|
130
|
+
const end = afterLine(text, range(value ?? pair.key)[1] - 1);
|
|
131
|
+
return text.slice(0, start) + block + text.slice(end);
|
|
132
|
+
}
|
|
111
133
|
const { cards } = cardsSeq(text);
|
|
112
134
|
const first = cards.items[0];
|
|
113
135
|
if (!isMap(first))
|
|
@@ -125,8 +147,13 @@ function cardLines(entry, dashIndent) {
|
|
|
125
147
|
}
|
|
126
148
|
export function removeCardText(text, cardId) {
|
|
127
149
|
const card = findCard(text, cardId);
|
|
128
|
-
const start = lineStart(text, range(card)[0]);
|
|
129
150
|
const end = afterLine(text, range(card)[1] - 1);
|
|
151
|
+
if (cardsSeq(text).cards.items.length === 1) {
|
|
152
|
+
const pair = rootMap(text).items.find((p) => keyOf(p) === 'cards');
|
|
153
|
+
const start = lineStart(text, range(pair?.key)[0]);
|
|
154
|
+
return `${text.slice(0, start)}cards: []\n${text.slice(end)}`;
|
|
155
|
+
}
|
|
156
|
+
const start = lineStart(text, range(card)[0]);
|
|
130
157
|
return text.slice(0, start) + text.slice(end);
|
|
131
158
|
}
|
|
132
159
|
// Replaces each given field in place, or adds it after the card's last line.
|
|
@@ -156,10 +183,7 @@ export function updateCardText(text, cardId, fields) {
|
|
|
156
183
|
return out;
|
|
157
184
|
}
|
|
158
185
|
export function setMembershipsText(text, memberships) {
|
|
159
|
-
const
|
|
160
|
-
const root = doc.contents;
|
|
161
|
-
if (!isMap(root))
|
|
162
|
-
throw new Error(`the wallet isn't a map; ${BY_HAND}`);
|
|
186
|
+
const root = rootMap(text);
|
|
163
187
|
const line = `memberships: ${flowList(memberships)}\n`;
|
|
164
188
|
const pair = root.items.find((p) => keyOf(p) === 'memberships');
|
|
165
189
|
if (pair) {
|
|
@@ -168,7 +192,9 @@ export function setMembershipsText(text, memberships) {
|
|
|
168
192
|
return text.slice(0, start) + line + text.slice(end);
|
|
169
193
|
}
|
|
170
194
|
const cards = root.items.find((p) => keyOf(p) === 'cards');
|
|
171
|
-
return
|
|
195
|
+
return cards
|
|
196
|
+
? insertLines(text, lineStart(text, range(cards.key)[0]), line)
|
|
197
|
+
: insertLines(text, text.length, line);
|
|
172
198
|
}
|
|
173
199
|
// Point values, one currency at a time so the rest of the file, comments
|
|
174
200
|
// included, stays as it was: a value is replaced in place, a new one goes
|
|
@@ -192,10 +218,7 @@ function aboveComments(text, at) {
|
|
|
192
218
|
return start;
|
|
193
219
|
}
|
|
194
220
|
function setValuationText(text, id, cents) {
|
|
195
|
-
const
|
|
196
|
-
const root = doc.contents;
|
|
197
|
-
if (!isMap(root))
|
|
198
|
-
throw new Error(`the wallet isn't a map; ${BY_HAND}`);
|
|
221
|
+
const root = rootMap(text);
|
|
199
222
|
const pair = root.items.find((p) => keyOf(p) === 'valuations');
|
|
200
223
|
const map = pair?.value;
|
|
201
224
|
const line = cents === null ? '' : `${id}: ${String(cents)}`;
|
|
@@ -209,7 +232,9 @@ function setValuationText(text, id, cents) {
|
|
|
209
232
|
return text.slice(0, start) + block + text.slice(end);
|
|
210
233
|
}
|
|
211
234
|
const before = root.items.find((p) => keyOf(p) === 'memberships' || keyOf(p) === 'cards');
|
|
212
|
-
return
|
|
235
|
+
return before
|
|
236
|
+
? insertLines(text, aboveComments(text, range(before.key)[0]), block)
|
|
237
|
+
: insertLines(text, text.length, block);
|
|
213
238
|
}
|
|
214
239
|
if (!isMap(map))
|
|
215
240
|
throw new Error(`"valuations" isn't a map; ${BY_HAND}`);
|
|
@@ -251,7 +276,7 @@ function flowUsage(booking) {
|
|
|
251
276
|
: booking.period;
|
|
252
277
|
return `{ period: ${period}, spent: ${String(booking.after)} }`;
|
|
253
278
|
}
|
|
254
|
-
|
|
279
|
+
function capUsageText(text, booking) {
|
|
255
280
|
const card = findCard(text, booking.cardId);
|
|
256
281
|
const value = flowUsage(booking);
|
|
257
282
|
const usage = card.get('cap_usage', true);
|
|
@@ -271,7 +296,7 @@ export function capUsageText(text, booking) {
|
|
|
271
296
|
const at = afterLine(text, range(card)[1] - 1);
|
|
272
297
|
return insertLines(text, at, `${indent}cap_usage:\n${indent} ${booking.capKey}: ${value}\n`);
|
|
273
298
|
}
|
|
274
|
-
|
|
299
|
+
function bonusSpentText(text, cardId, spent) {
|
|
275
300
|
const bonus = findCard(text, cardId).get('bonus', true);
|
|
276
301
|
if (!isMap(bonus))
|
|
277
302
|
throw new Error(`card '${cardId}' has no bonus`);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { resolveMerchantById } from "../merchants/resolve.js";
|
|
2
|
-
import {
|
|
1
|
+
import { categoryResolution, resolveMerchantById, } from "../merchants/resolve.js";
|
|
2
|
+
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
6
|
import { recommend } from "./recommend.js";
|
|
7
7
|
import { setupNotes } from "./setup.js";
|
|
8
|
-
import { feeText, listed, money, shortDate } from "./text.js";
|
|
8
|
+
import { capitalize, feeText, listed, money, shortDate } from "./text.js";
|
|
9
9
|
// The everyday kinds of purchase: the cheat sheet's rows and the site's
|
|
10
10
|
// quick picks.
|
|
11
11
|
export const EVERYDAY_PURCHASES = [
|
|
@@ -100,7 +100,6 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
|
100
100
|
// The best card earnrate knows for each everyday kind of purchase, for
|
|
101
101
|
// someone who hasn't named any cards. Each row's note says what the card
|
|
102
102
|
// costs or needs.
|
|
103
|
-
const capitalized = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
|
104
103
|
export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCHASES) {
|
|
105
104
|
const rows = items.map((item) => {
|
|
106
105
|
const top = exploreCards(catalog, 'merchant' in item
|
|
@@ -119,7 +118,7 @@ export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCH
|
|
|
119
118
|
channel: null,
|
|
120
119
|
date,
|
|
121
120
|
}, options).ranked[0];
|
|
122
|
-
const fee = top ? `${
|
|
121
|
+
const fee = top ? `${capitalize(feeText(top))}.` : null;
|
|
123
122
|
return {
|
|
124
123
|
label: item.label,
|
|
125
124
|
cardId: top?.cardId ?? null,
|
package/dist/engine/compare.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Returns closer than this are equal: floating-point noise, not a difference.
|
|
2
2
|
export const EPSILON = 1e-9;
|
|
3
|
+
// Dollar amounts closer than half a cent print the same.
|
|
4
|
+
export const HALF_CENT = 0.005;
|
|
3
5
|
// A card close to the best one: within a percentage point, and at least
|
|
4
6
|
// three quarters of its rate. Answers and cheat sheets mention a welcome
|
|
5
7
|
// bonus card when it's this close.
|
package/dist/engine/evaluate.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { periodKey } from "./periods.js";
|
|
1
|
+
import { inEffect, periodKey } from "./periods.js";
|
|
2
2
|
export function purchaseContext(catalog, wallet, merchant, category, purchase) {
|
|
3
3
|
return {
|
|
4
4
|
category,
|
|
@@ -110,9 +110,7 @@ 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
|
|
114
|
-
return 'no';
|
|
115
|
-
if (rule.valid_to !== undefined && ctx.date > rule.valid_to)
|
|
113
|
+
if (!inEffect(rule, ctx.date))
|
|
116
114
|
return 'no';
|
|
117
115
|
const membership = rule.requires?.membership;
|
|
118
116
|
if (membership !== undefined && !ctx.memberships.has(membership))
|
package/dist/engine/explore.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { categoryResolution, resolvePlace } from "../merchants/resolve.js";
|
|
2
2
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
3
|
import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
|
|
4
4
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
@@ -6,7 +6,7 @@ import { recommend } from "./recommend.js";
|
|
|
6
6
|
import { rotationGap } from "./rotation.js";
|
|
7
7
|
import { membershipsUsed } from "./setup.js";
|
|
8
8
|
import { optionName } from "./wallet-summary.js";
|
|
9
|
-
import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, money, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
9
|
+
import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, listed, money, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
10
10
|
import { cardValuation } from "./valuation.js";
|
|
11
11
|
import { EPSILON } from "./compare.js";
|
|
12
12
|
export const ALL_PERSONAL_CARDS = {
|
|
@@ -49,38 +49,6 @@ export function idealHolding(card, date, picks = {}) {
|
|
|
49
49
|
: {}),
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
-
const plainWords = (s) => s
|
|
53
|
-
.toLowerCase()
|
|
54
|
-
.replace(/[^a-z0-9]+/g, ' ')
|
|
55
|
-
.trim();
|
|
56
|
-
// A category from everyday words: an alias ("groceries") first, then the
|
|
57
|
-
// name, then the id, so "grocery" means supermarkets rather than the parent.
|
|
58
|
-
export function findCategory(catalog, text) {
|
|
59
|
-
const wanted = plainWords(text);
|
|
60
|
-
if (wanted === '')
|
|
61
|
-
return null;
|
|
62
|
-
const by = (key) => catalog.categories.find((c) => key(c).map(plainWords).includes(wanted))?.id;
|
|
63
|
-
return (by((c) => c.aliases) ?? by((c) => [c.name]) ?? by((c) => [c.id]) ?? null);
|
|
64
|
-
}
|
|
65
|
-
export function categoryResolution(catalog, category, channel) {
|
|
66
|
-
const found = catalog.categories.find((c) => c.id === category);
|
|
67
|
-
if (!found)
|
|
68
|
-
throw new Error(`unknown category '${category}'`);
|
|
69
|
-
return {
|
|
70
|
-
input: found.name,
|
|
71
|
-
merchantId: null,
|
|
72
|
-
resolvedName: found.name,
|
|
73
|
-
category,
|
|
74
|
-
likelyMcc: null,
|
|
75
|
-
accepts: null,
|
|
76
|
-
ambiguity: null,
|
|
77
|
-
channel,
|
|
78
|
-
confidence: 'high',
|
|
79
|
-
source: 'category',
|
|
80
|
-
bookedVia: [],
|
|
81
|
-
inference: null,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
52
|
function walletOf(held, options) {
|
|
85
53
|
return {
|
|
86
54
|
valuations: options.valuations,
|
|
@@ -185,7 +153,7 @@ function compareExplored(a, b) {
|
|
|
185
153
|
export function exploreCards(catalog, query, options, resolved) {
|
|
186
154
|
const merchant = resolved ??
|
|
187
155
|
(query.merchant !== null
|
|
188
|
-
?
|
|
156
|
+
? resolvePlace(catalog, query.merchant, query.channel)
|
|
189
157
|
: categoryResolution(catalog, query.category ?? 'other', query.channel));
|
|
190
158
|
const purchase = {
|
|
191
159
|
merchant: merchant.input,
|
|
@@ -259,7 +227,7 @@ function exploreAssumptions(catalog, merchant, ranked, options) {
|
|
|
259
227
|
assumptions.push('Assumes each card is set up for this purchase: the right category pick, any activation done, and none of its cap used yet. A card you hold may differ.');
|
|
260
228
|
assumptions.push(options.memberships.length === 0
|
|
261
229
|
? 'Assumes no memberships (like Amazon Prime). Rates that need one are listed separately.'
|
|
262
|
-
: `Assumes these memberships: ${options.memberships.
|
|
230
|
+
: `Assumes these memberships: ${listed(options.memberships.map((m) => membershipName(catalog, m)))}.`);
|
|
263
231
|
const values = pointValuesText(catalog, ranked.slice(0, 10));
|
|
264
232
|
if (values !== null)
|
|
265
233
|
assumptions.push(values);
|
|
@@ -6,7 +6,7 @@ export function membershipName(catalog, id) {
|
|
|
6
6
|
return membership(catalog, id)?.name ?? words(id);
|
|
7
7
|
}
|
|
8
8
|
// "a Costco membership", for a sentence.
|
|
9
|
-
|
|
9
|
+
function withArticle(name) {
|
|
10
10
|
return `${/^[aeiou]/i.test(name) ? 'an' : 'a'} ${name}`;
|
|
11
11
|
}
|
|
12
12
|
// Tiers of one membership: holding one, the others in its group aren't
|
package/dist/engine/offers.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { bonusDollars } from "./bonus.js";
|
|
2
2
|
import { matchesFilters } from "./explore.js";
|
|
3
3
|
import { membershipCosts, paidMemberships } from "./memberships.js";
|
|
4
|
+
import { inEffect } from "./periods.js";
|
|
4
5
|
import { money, roundCents } from "./text.js";
|
|
5
6
|
import { cardValuation } from "./valuation.js";
|
|
6
7
|
function ineligibleText(catalog, wallet, notWith) {
|
|
@@ -41,7 +42,7 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
41
42
|
if (!matchesFilters(card, { ...filters, issuer: null, includeClosed: true }))
|
|
42
43
|
continue;
|
|
43
44
|
const offer = card.welcome_offer;
|
|
44
|
-
if (!offer || (offer
|
|
45
|
+
if (!offer || !inEffect(offer, today)) {
|
|
45
46
|
withoutOffer.push(card.id);
|
|
46
47
|
continue;
|
|
47
48
|
}
|
package/dist/engine/periods.js
CHANGED
|
@@ -38,3 +38,21 @@ export function choicePeriod(card) {
|
|
|
38
38
|
];
|
|
39
39
|
return periods.length > 1 ? null : (periods[0] ?? 'calendar_year');
|
|
40
40
|
}
|
|
41
|
+
// Whether a dated rule or offer is in effect on `date`; an open end has no
|
|
42
|
+
// limit on that side.
|
|
43
|
+
export const inEffect = (window, date) => (window.valid_from === undefined || window.valid_from <= date) &&
|
|
44
|
+
(window.valid_to === undefined || window.valid_to >= date);
|
|
45
|
+
// The last day of a period key ("2026-Q4", "2026-10", "2026").
|
|
46
|
+
export function periodEnd(period) {
|
|
47
|
+
const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
|
|
48
|
+
if (quarter) {
|
|
49
|
+
const end = ['03-31', '06-30', '09-30', '12-31'][Number(quarter[2]) - 1];
|
|
50
|
+
return `${quarter[1] ?? ''}-${end ?? ''}`;
|
|
51
|
+
}
|
|
52
|
+
const month = /^(\d{4})-(\d{2})$/.exec(period);
|
|
53
|
+
if (month) {
|
|
54
|
+
const last = new Date(Date.UTC(Number(month[1]), Number(month[2]), 0));
|
|
55
|
+
return last.toISOString().slice(0, 10);
|
|
56
|
+
}
|
|
57
|
+
return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
|
|
58
|
+
}
|
package/dist/engine/recommend.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { resolveMerchant, resolveMerchantById } from "../merchants/resolve.js";
|
|
1
|
+
import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
|
|
2
2
|
import { bonusStatus } from "./bonus.js";
|
|
3
3
|
import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
5
|
import { rotationGap } from "./rotation.js";
|
|
6
6
|
import { EPSILON, isClose } from "./compare.js";
|
|
7
|
-
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
7
|
+
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
8
8
|
import { cardValuation } from "./valuation.js";
|
|
9
9
|
export const STALE_AFTER_DAYS = 180;
|
|
10
10
|
// The warning when no card works; the CLI shows it as the answer instead.
|
|
@@ -104,7 +104,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
104
104
|
lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
|
|
105
105
|
}
|
|
106
106
|
if (currency !== 'cash') {
|
|
107
|
-
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${
|
|
107
|
+
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.effectiveReturnPct)} back.`);
|
|
108
108
|
}
|
|
109
109
|
return lines;
|
|
110
110
|
}
|
|
@@ -144,7 +144,7 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
144
144
|
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.`);
|
|
145
145
|
}
|
|
146
146
|
for (const rule of evaluation.needsActivation) {
|
|
147
|
-
warnings.push(`Activate this bonus with the issuer (it's free) to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
|
|
147
|
+
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)}`}.`);
|
|
148
148
|
}
|
|
149
149
|
return warnings;
|
|
150
150
|
}
|
|
@@ -183,7 +183,7 @@ function tieNote(entries) {
|
|
|
183
183
|
tied.some((e) => e.evaluation.best.cap !== null)
|
|
184
184
|
? 'because its rate has no cap'
|
|
185
185
|
: 'by a fixed order; either works';
|
|
186
|
-
return `${names} also ${verb} ${
|
|
186
|
+
return `${names} also ${verb} ${pct(winner.effectiveReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
|
|
187
187
|
}
|
|
188
188
|
// Finds the closest point inside a currency's value range where the winner
|
|
189
189
|
// stops winning, varying one currency at a time. Returns are linear in the
|
|
@@ -278,19 +278,18 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
278
278
|
const twin = alts.find((e) => e.card.id !== winner.card.id &&
|
|
279
279
|
alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
|
|
280
280
|
if (alt.card.id !== winner.card.id)
|
|
281
|
-
warnings.push(`${as}, ${displayName(alt)} wins (${
|
|
281
|
+
warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.effectiveReturnPct)}).`);
|
|
282
282
|
else if (twin)
|
|
283
|
-
warnings.push(`${as}, ${displayName(twin)} earns as much (${
|
|
283
|
+
warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.effectiveReturnPct)}).`);
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
if (!winner) {
|
|
287
287
|
warnings.push(noCardText(merchant.resolvedName));
|
|
288
288
|
// Only where the merchant's data says what its website takes: without
|
|
289
289
|
// that, every network is assumed, and a gas pump has no website.
|
|
290
|
-
const record =
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
].find((m) => m.id === merchant.merchantId);
|
|
290
|
+
const record = merchant.merchantId === null
|
|
291
|
+
? undefined
|
|
292
|
+
: findMerchant(catalog, merchant.merchantId);
|
|
294
293
|
const online = merchant.channel === 'in_store' &&
|
|
295
294
|
merchant.merchantId !== null &&
|
|
296
295
|
record?.online?.accepts !== undefined
|
|
@@ -300,7 +299,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
300
299
|
? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
|
|
301
300
|
: undefined;
|
|
302
301
|
if (alt)
|
|
303
|
-
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${
|
|
302
|
+
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.effectiveReturnPct)}).`);
|
|
304
303
|
}
|
|
305
304
|
else if (merchant.accepts === null &&
|
|
306
305
|
(winner.card.network === 'amex' || winner.card.network === 'discover')) {
|
|
@@ -325,7 +324,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
325
324
|
const alt = rankEntries(catalog, wallet, purchase, via, via.category)
|
|
326
325
|
.entries[0];
|
|
327
326
|
if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
|
|
328
|
-
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${
|
|
327
|
+
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.effectiveReturnPct)}.`);
|
|
329
328
|
}
|
|
330
329
|
}
|
|
331
330
|
const open = bonusPick(catalog, wallet, entries, purchase.date);
|
|
@@ -341,7 +340,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
341
340
|
const bonus = open && (open.behind || ties) ? open : null;
|
|
342
341
|
const best = winner?.effectiveReturnPct ?? 0;
|
|
343
342
|
if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
|
|
344
|
-
notes.push(`${displayName(open.entry)} earns ${
|
|
343
|
+
notes.push(`${displayName(open.entry)} earns ${pct(open.entry.effectiveReturnPct)} here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${pct(best)}, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
|
|
345
344
|
}
|
|
346
345
|
const chosen = bonus?.entry ?? winner;
|
|
347
346
|
// A tie among the top cards doesn't matter when a welcome bonus decides.
|
|
@@ -377,7 +376,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
377
376
|
: `You're behind pace on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
|
|
378
377
|
? ''
|
|
379
378
|
: costHere === null
|
|
380
|
-
? ` It earns ${
|
|
379
|
+
? ` It earns ${pct(top.effectiveReturnPct)} here against ${pct(first.effectiveReturnPct)} on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
|
|
381
380
|
: ` Using it here earns ${money(costHere)} less than ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`}`);
|
|
382
381
|
}
|
|
383
382
|
let confidence = merchant.confidence;
|
package/dist/engine/setup.js
CHANGED
|
@@ -2,19 +2,10 @@ import { isActivated, pickedCategories } from "./evaluate.js";
|
|
|
2
2
|
import { membership, membershipName, sameGroup } from "./memberships.js";
|
|
3
3
|
import { daysBetween } from "./periods.js";
|
|
4
4
|
import { rotationGap } from "./rotation.js";
|
|
5
|
+
import { idList, listed } from "./text.js";
|
|
5
6
|
// Rules that start within this many days are worth activating now.
|
|
6
7
|
const ACTIVATION_LOOKAHEAD_DAYS = 45;
|
|
7
|
-
const
|
|
8
|
-
amex: 'american express',
|
|
9
|
-
'bank-of-america': 'bofa boa bankofamerica',
|
|
10
|
-
'capital-one': 'capitalone',
|
|
11
|
-
citi: 'citibank',
|
|
12
|
-
usbank: 'us bank',
|
|
13
|
-
'wells-fargo': 'wf wellsfargo',
|
|
14
|
-
'td-bank': 'td',
|
|
15
|
-
column: 'bilt',
|
|
16
|
-
};
|
|
17
|
-
const words = (s) => s
|
|
8
|
+
const searchWords = (s) => s
|
|
18
9
|
.toLowerCase()
|
|
19
10
|
.replace(/\+/g, ' plus ')
|
|
20
11
|
.replace(/&/g, ' and ')
|
|
@@ -33,12 +24,12 @@ function editDistance(a, b) {
|
|
|
33
24
|
}
|
|
34
25
|
return row[b.length] ?? 0;
|
|
35
26
|
}
|
|
36
|
-
function matcher(card) {
|
|
27
|
+
function matcher(catalog, card) {
|
|
37
28
|
const haystack = [
|
|
38
|
-
...
|
|
39
|
-
...
|
|
40
|
-
...
|
|
41
|
-
...
|
|
29
|
+
...searchWords(card.id),
|
|
30
|
+
...searchWords(card.name),
|
|
31
|
+
...searchWords(card.issuer),
|
|
32
|
+
...(catalog.issuers.find((i) => i.id === card.issuer)?.search ?? []).flatMap(searchWords),
|
|
42
33
|
];
|
|
43
34
|
const compact = haystack.join('');
|
|
44
35
|
return (token) => haystack.some((w) => w.startsWith(token)) ||
|
|
@@ -108,12 +99,12 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
108
99
|
// issuer (as a prefix, or with a typo or two in a long word). With no full
|
|
109
100
|
// match, the cards matching the most words come back instead.
|
|
110
101
|
export function searchCards(catalog, query, wallet, today) {
|
|
111
|
-
const tokens =
|
|
102
|
+
const tokens = searchWords(query);
|
|
112
103
|
const scored = catalog.cards.map((card) => {
|
|
113
|
-
const matches = matcher(card);
|
|
104
|
+
const matches = matcher(catalog, card);
|
|
114
105
|
return { card, score: tokens.filter((t) => matches(t)).length };
|
|
115
106
|
});
|
|
116
|
-
const byShortestId = (a, b) =>
|
|
107
|
+
const byShortestId = (a, b) => searchWords(a.card.id).length - searchWords(b.card.id).length ||
|
|
117
108
|
a.card.id.localeCompare(b.card.id);
|
|
118
109
|
const full = scored.filter((s) => s.score === tokens.length);
|
|
119
110
|
const best = Math.max(0, ...scored.map((s) => s.score));
|
|
@@ -131,7 +122,6 @@ export function searchCards(catalog, query, wallet, today) {
|
|
|
131
122
|
exact,
|
|
132
123
|
};
|
|
133
124
|
}
|
|
134
|
-
const list = (ids) => ids.join(', ');
|
|
135
125
|
// What a card in the wallet still needs before its answers are right.
|
|
136
126
|
export function setupNotes(catalog, wallet, held, today) {
|
|
137
127
|
const card = catalog.cards.find((c) => c.id === held.id);
|
|
@@ -145,11 +135,11 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
145
135
|
(staleChoice === null || choice.period === 'until_changed');
|
|
146
136
|
if (current)
|
|
147
137
|
continue;
|
|
148
|
-
const options =
|
|
138
|
+
const options = idList(Object.keys(choice.options));
|
|
149
139
|
notes.push({
|
|
150
140
|
kind: 'pick',
|
|
151
141
|
text: choice.default && picked.has(choice.id)
|
|
152
|
-
? `${name}'s ${choice.id} pick is assumed to be ${
|
|
142
|
+
? `${name}'s ${choice.id} pick is assumed to be ${idList(choice.default)}, the default. Set it if a different one was picked (${String(choice.pick)} of: ${options}).`
|
|
153
143
|
: `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
|
|
154
144
|
});
|
|
155
145
|
}
|
|
@@ -158,14 +148,14 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
158
148
|
if (missing.length > 0) {
|
|
159
149
|
notes.push({
|
|
160
150
|
kind: 'membership',
|
|
161
|
-
text: `Some of ${name}'s rates need
|
|
151
|
+
text: `Some of ${name}'s rates need ${listed(missing.map((m) => `${membershipName(catalog, m)} (${m})`), 'or')}; they're counted without it.`,
|
|
162
152
|
});
|
|
163
153
|
}
|
|
164
154
|
const toActivate = activationRules(card, today).filter((id) => !isActivated(card, held, id));
|
|
165
155
|
if (toActivate.length > 0) {
|
|
166
156
|
notes.push({
|
|
167
157
|
kind: 'activation',
|
|
168
|
-
text: `${name} has rates that apply only once activated with the issuer: ${
|
|
158
|
+
text: `${name} has rates that apply only once activated with the issuer: ${idList(toActivate)}. Mark the ones already activated.`,
|
|
169
159
|
});
|
|
170
160
|
}
|
|
171
161
|
if (needsAnniversary(card) && held.anniversary === undefined) {
|
package/dist/engine/simulate.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { categoryResolution, findCategory, resolvePlace, } from "../merchants/resolve.js";
|
|
2
2
|
import { evaluateCard, purchaseContext, usableAt } from "./evaluate.js";
|
|
3
|
-
import {
|
|
3
|
+
import { idealHolding, matchesFilters, } from "./explore.js";
|
|
4
4
|
import { membershipCosts, membershipNote, paidMemberships, } from "./memberships.js";
|
|
5
5
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
6
6
|
import { applyBooking, capBooking } from "./record.js";
|
|
7
7
|
import { rotationGap } from "./rotation.js";
|
|
8
8
|
import { money, roundCents } from "./text.js";
|
|
9
9
|
import { cardValuation } from "./valuation.js";
|
|
10
|
-
import { EPSILON } from "./compare.js";
|
|
10
|
+
import { EPSILON, HALF_CENT } from "./compare.js";
|
|
11
11
|
const MONTHS = 12;
|
|
12
12
|
const SHORTLIST = 8; // best single cards considered for pairs
|
|
13
13
|
const PER_LINE = 2; // plus the best cards for each spending line
|
|
@@ -16,7 +16,7 @@ export function resolveSpendLines(catalog, lines) {
|
|
|
16
16
|
.filter((l) => l.monthly > 0)
|
|
17
17
|
.map((l) => {
|
|
18
18
|
const merchant = l.merchant !== null
|
|
19
|
-
?
|
|
19
|
+
? resolvePlace(catalog, l.merchant, null)
|
|
20
20
|
: categoryResolution(catalog, l.category ?? 'other', null);
|
|
21
21
|
return { label: merchant.resolvedName, monthly: l.monthly, merchant };
|
|
22
22
|
});
|
|
@@ -224,7 +224,7 @@ function foldTies(pairs) {
|
|
|
224
224
|
const folded = [];
|
|
225
225
|
for (const pair of pairs) {
|
|
226
226
|
const [a, b] = pair.cards;
|
|
227
|
-
const twin = folded.find((f) => Math.abs(f.net - pair.net) <
|
|
227
|
+
const twin = folded.find((f) => Math.abs(f.net - pair.net) < HALF_CENT &&
|
|
228
228
|
costs(f) === costs(pair) &&
|
|
229
229
|
f.cards.some((c) => c.cardId === a?.cardId || c.cardId === b?.cardId));
|
|
230
230
|
if (!twin) {
|
|
@@ -339,7 +339,8 @@ export function spendLineFor(catalog, what, monthly) {
|
|
|
339
339
|
const category = findCategory(catalog, what);
|
|
340
340
|
return { merchant: category === null ? what : null, category, monthly };
|
|
341
341
|
}
|
|
342
|
+
export const typicalSpend = (catalog) => TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly));
|
|
342
343
|
export function typicalSpendLines(catalog) {
|
|
343
|
-
return resolveSpendLines(catalog,
|
|
344
|
+
return resolveSpendLines(catalog, typicalSpend(catalog));
|
|
344
345
|
}
|
|
345
346
|
export const TYPICAL_SPEND_NOTE = `No spending was given, so this uses rough typical monthly amounts for a U.S. household: ${TYPICAL_MONTHLY_SPEND.map((t) => `${t.what} ${money(t.monthly)}`).join(', ')}. Give your own amounts for a closer answer.`;
|
package/dist/engine/text.js
CHANGED
|
@@ -2,6 +2,18 @@ import { singleMcc } from "./evaluate.js";
|
|
|
2
2
|
export const num = (n) => String(Number(n.toFixed(2)));
|
|
3
3
|
export const pct = (n) => `${num(n)}%`;
|
|
4
4
|
export const roundCents = (n) => Math.round(n * 100) / 100;
|
|
5
|
+
// Ids for a message: "amex-gold, citi-double-cash".
|
|
6
|
+
export const idList = (ids) => ids.join(', ');
|
|
7
|
+
export const capitalize = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
|
8
|
+
// "usbank.com" from "https://www.usbank.com/credit-cards/...".
|
|
9
|
+
export function hostname(url) {
|
|
10
|
+
try {
|
|
11
|
+
return new URL(url).hostname.replace(/^www\./, '');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return url;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
5
17
|
// An id in words: "gas-ev-charging" reads "gas ev charging".
|
|
6
18
|
export const words = (id) => id.replace(/[-_.]/g, ' ');
|
|
7
19
|
const dateWords = (date, options) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
|
|
@@ -47,7 +59,7 @@ export function categoryPhrase(catalog, id) {
|
|
|
47
59
|
}
|
|
48
60
|
export function earnText(catalog, rate, currency) {
|
|
49
61
|
return currency === 'cash'
|
|
50
|
-
? `${
|
|
62
|
+
? `${pct(rate)} cash back`
|
|
51
63
|
: `${num(rate)}x ${currencyName(catalog, currency)}`;
|
|
52
64
|
}
|
|
53
65
|
export function codesAs(catalog, merchant) {
|