earnrate 0.2.11 → 0.2.12
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 +21 -9
- package/data/CHANGELOG.md +110 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +1 -0
- package/data/cards/amex-blue-cash-everyday.yaml +1 -0
- package/data/cards/amex-blue-cash-preferred.yaml +7 -0
- package/data/cards/amex-business-gold.yaml +134 -0
- package/data/cards/amex-business-platinum.yaml +115 -0
- package/data/cards/amex-delta-skymiles-blue.yaml +91 -0
- package/data/cards/amex-delta-skymiles-gold.yaml +2 -0
- package/data/cards/amex-delta-skymiles-platinum.yaml +2 -0
- package/data/cards/amex-delta-skymiles-reserve.yaml +2 -0
- package/data/cards/amex-gold.yaml +1 -0
- package/data/cards/amex-hilton-honors-aspire.yaml +2 -0
- package/data/cards/amex-hilton-honors-business.yaml +93 -0
- package/data/cards/amex-hilton-honors-surpass.yaml +2 -0
- package/data/cards/amex-hilton-honors.yaml +2 -0
- package/data/cards/amex-marriott-bonvoy-bevy.yaml +2 -0
- package/data/cards/amex-marriott-bonvoy-brilliant.yaml +2 -0
- package/data/cards/amex-marriott-bonvoy-business.yaml +116 -0
- package/data/cards/amex-platinum.yaml +1 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +1 -0
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +2 -0
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +1 -0
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +34 -1
- package/data/cards/bank-of-america-premium-rewards.yaml +134 -0
- package/data/cards/bank-of-america-travel-rewards.yaml +123 -0
- package/data/cards/bank-of-america-unlimited-cash-rewards.yaml +83 -0
- package/data/cards/barclays-jetblue-plus.yaml +86 -0
- package/data/cards/bilt-blue.yaml +60 -0
- package/data/cards/bilt-obsidian.yaml +1 -0
- package/data/cards/bilt-palladium.yaml +76 -0
- package/data/cards/capital-one-kohls-card.yaml +1 -0
- package/data/cards/capital-one-kohls-rewards-visa.yaml +1 -0
- package/data/cards/capital-one-quicksilver.yaml +2 -0
- package/data/cards/capital-one-savor.yaml +2 -0
- package/data/cards/capital-one-spark-cash-plus.yaml +80 -0
- package/data/cards/capital-one-venture-x-business.yaml +97 -0
- package/data/cards/capital-one-venture-x.yaml +2 -0
- package/data/cards/capital-one-venture.yaml +2 -0
- package/data/cards/chase-aeroplan.yaml +2 -0
- package/data/cards/chase-freedom-flex.yaml +1 -0
- package/data/cards/chase-freedom-rise.yaml +29 -0
- package/data/cards/chase-freedom-unlimited.yaml +1 -0
- package/data/cards/chase-ihg-one-rewards-business.yaml +1 -0
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +2 -0
- package/data/cards/chase-ihg-one-rewards-premier.yaml +1 -0
- package/data/cards/chase-ihg-one-rewards.yaml +1 -0
- package/data/cards/chase-ink-business-cash.yaml +1 -0
- package/data/cards/chase-ink-business-preferred.yaml +1 -0
- package/data/cards/chase-ink-business-unlimited.yaml +69 -0
- package/data/cards/chase-marriott-bonvoy-bold.yaml +1 -0
- package/data/cards/chase-marriott-bonvoy-boundless.yaml +2 -0
- package/data/cards/chase-prime-visa.yaml +1 -0
- package/data/cards/chase-sapphire-preferred.yaml +1 -0
- package/data/cards/chase-sapphire-reserve.yaml +1 -0
- package/data/cards/chase-southwest-performance-business.yaml +2 -0
- package/data/cards/chase-southwest-plus.yaml +2 -0
- package/data/cards/chase-southwest-premier-business.yaml +2 -0
- package/data/cards/chase-southwest-premier.yaml +2 -0
- package/data/cards/chase-southwest-priority.yaml +2 -0
- package/data/cards/chase-united-business.yaml +1 -0
- package/data/cards/chase-united-club-business.yaml +1 -0
- package/data/cards/chase-united-club.yaml +1 -0
- package/data/cards/chase-united-explorer.yaml +1 -0
- package/data/cards/chase-united-gateway.yaml +1 -0
- package/data/cards/chase-united-quest.yaml +1 -0
- package/data/cards/citi-aadvantage-business.yaml +2 -0
- package/data/cards/citi-aadvantage-executive.yaml +2 -0
- package/data/cards/citi-aadvantage-globe.yaml +2 -0
- package/data/cards/citi-aadvantage-mileup.yaml +2 -0
- package/data/cards/citi-aadvantage-platinum-select.yaml +2 -0
- package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
- package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
- package/data/cards/citi-custom-cash.yaml +1 -0
- package/data/cards/citi-double-cash.yaml +1 -0
- package/data/cards/citi-my-best-buy-visa.yaml +1 -0
- package/data/cards/citi-strata-elite.yaml +1 -0
- package/data/cards/citi-strata-premier.yaml +1 -0
- package/data/cards/citi-strata.yaml +164 -0
- package/data/cards/discover-it-cash-back.yaml +22 -3
- package/data/cards/paypal-cashback-mastercard.yaml +41 -0
- package/data/cards/sams-club-mastercard.yaml +68 -0
- package/data/cards/sofi-unlimited-2.yaml +1 -0
- package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
- package/data/cards/td-bank-target-circle-card.yaml +1 -0
- package/data/cards/usbank-altitude-connect.yaml +150 -0
- package/data/cards/usbank-altitude-go.yaml +118 -0
- package/data/cards/usbank-cash-plus.yaml +1 -0
- package/data/cards/wells-fargo-active-cash.yaml +1 -0
- package/data/cards/wells-fargo-autograph-journey.yaml +73 -0
- package/data/cards/wells-fargo-autograph.yaml +1 -0
- package/data/categories.yaml +3 -1
- package/data/currencies.yaml +34 -0
- package/data/issuers.yaml +22 -1
- package/data/memberships.yaml +10 -0
- package/data/merchants/overrides.yaml +499 -1
- package/data/merchants/seed.yaml +843 -6
- package/dist/cli/import.js +2 -2
- package/dist/cli/wallet.js +8 -1
- package/dist/data/integrity.js +37 -0
- package/dist/data/wallet-edit.js +3 -1
- package/dist/engine/checklist.js +14 -3
- package/dist/engine/evaluate.js +19 -7
- package/dist/engine/intro.js +36 -0
- package/dist/engine/recommend.js +31 -2
- package/dist/engine/record.js +2 -0
- package/dist/engine/rule-text.js +15 -1
- package/dist/engine/setup.js +6 -0
- package/dist/engine/text.js +23 -0
- package/dist/engine/wallet-change.js +20 -3
- package/dist/engine/wallet-summary.js +24 -4
- package/dist/format/card-art.js +21 -0
- package/dist/format/cards.js +1 -0
- package/dist/format/wallet-yaml.js +1 -0
- package/dist/format/wallet.js +10 -4
- package/dist/import/analyze.js +1 -0
- package/dist/mcp/inputs.js +3 -0
- package/dist/mcp/schemas.js +17 -0
- package/dist/mcp/tools/account.js +3 -1
- package/dist/mcp/tools/wallet.js +6 -2
- package/dist/schema/card.js +23 -0
- package/dist/schema/issuer.js +4 -0
- package/dist/schema/wallet.js +3 -0
- package/package.json +12 -3
package/dist/cli/import.js
CHANGED
|
@@ -13,7 +13,7 @@ import { cardValuation } from "../engine/valuation.js";
|
|
|
13
13
|
import { analyzeStatement, } from "../import/analyze.js";
|
|
14
14
|
import { parseStatement } from "../import/statement.js";
|
|
15
15
|
import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
|
|
16
|
-
import { currencyName, dollarsAndCents } from "../engine/text.js";
|
|
16
|
+
import { currencyName, dollarsAndCents, periodPhrase } from "../engine/text.js";
|
|
17
17
|
const IMPORT_HELP = `Usage: ${CLI_NAME} import <statement.csv> --card <card-id> [--apply]
|
|
18
18
|
${CLI_NAME} import <statement.csv>:<card-id> [<statement.csv>:<card-id> ...]
|
|
19
19
|
|
|
@@ -133,7 +133,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
|
|
|
133
133
|
out.push(`Welcome bonus on ${labelOf(b.cardId)}: ${dollarsAndCents(b.before)} → ${dollarsAndCents(b.after)} of ${dollarsAndCents(b.spend)}${b.deadline ? ` by ${b.deadline}` : ''}.`);
|
|
134
134
|
}
|
|
135
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)}
|
|
136
|
+
out.push(`Spend bonus on ${labelOf(m.cardId)}: ${dollarsAndCents(m.before)} → ${dollarsAndCents(m.after)} of ${dollarsAndCents(m.spend)} ${periodPhrase(m.periodKind, m.period)}${m.before < m.spend && m.after >= m.spend ? ', reached' : ''}.`);
|
|
137
137
|
}
|
|
138
138
|
return `${out.join('\n')}\n`;
|
|
139
139
|
}
|
package/dist/cli/wallet.js
CHANGED
|
@@ -14,6 +14,9 @@ const CARD_OPTIONS = `Options:
|
|
|
14
14
|
--pick five-percent=fast-food,movie-theaters.
|
|
15
15
|
Repeat for each choice.
|
|
16
16
|
--anniversary <MM-DD> Account anniversary, for caps that reset on it.
|
|
17
|
+
--opened <date> Optional: the day the account opened (YYYY-MM-DD),
|
|
18
|
+
for cards with a first-year rate. Without it the
|
|
19
|
+
card is ranked at its standard rates.
|
|
17
20
|
--activated <rules> Rules you've activated with the issuer, comma
|
|
18
21
|
separated. Replaces the current list.
|
|
19
22
|
--bonus-opened <date> Track a welcome bonus: the day the account opened
|
|
@@ -44,7 +47,7 @@ ${CARD_OPTIONS}
|
|
|
44
47
|
update: `Usage: ${CLI_NAME} update <card-id> [options]
|
|
45
48
|
|
|
46
49
|
Changes a card in your wallet: picks for this period, label, anniversary,
|
|
47
|
-
activations or welcome bonus. Picks for the current period are merged with
|
|
50
|
+
opening date, activations or welcome bonus. Picks for the current period are merged with
|
|
48
51
|
ones already set.
|
|
49
52
|
|
|
50
53
|
${CARD_OPTIONS}
|
|
@@ -130,6 +133,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
130
133
|
label: { type: 'string' },
|
|
131
134
|
pick: { type: 'string', multiple: true },
|
|
132
135
|
anniversary: { type: 'string' },
|
|
136
|
+
opened: { type: 'string' },
|
|
133
137
|
activated: { type: 'string' },
|
|
134
138
|
'bonus-opened': { type: 'string' },
|
|
135
139
|
'bonus-spent': { type: 'string' },
|
|
@@ -158,6 +162,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
158
162
|
const cardOptionsGiven = values.label !== undefined ||
|
|
159
163
|
values.pick !== undefined ||
|
|
160
164
|
values.anniversary !== undefined ||
|
|
165
|
+
values.opened !== undefined ||
|
|
161
166
|
values.activated !== undefined;
|
|
162
167
|
if (command !== 'add' && command !== 'update' && cardOptionsGiven) {
|
|
163
168
|
return usage(`${CLI_NAME} ${command} doesn't take card options.`);
|
|
@@ -236,6 +241,8 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
236
241
|
input.checkoutLabel = values.label;
|
|
237
242
|
if (values.anniversary !== undefined)
|
|
238
243
|
input.anniversary = values.anniversary;
|
|
244
|
+
if (values.opened !== undefined)
|
|
245
|
+
input.opened = values.opened;
|
|
239
246
|
if (values.activated !== undefined) {
|
|
240
247
|
input.activated = values.activated
|
|
241
248
|
.split(',')
|
package/dist/data/integrity.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveMerchant } from "../merchants/resolve.js";
|
|
1
2
|
import { WALLET_VERSION } from "../schema/wallet.js";
|
|
2
3
|
function duplicates(ids) {
|
|
3
4
|
const seen = new Set();
|
|
@@ -38,6 +39,31 @@ function merchantProblems(file, merchants, categories) {
|
|
|
38
39
|
}
|
|
39
40
|
return problems;
|
|
40
41
|
}
|
|
42
|
+
// Overrides are searched before seed, and a known name followed by more words
|
|
43
|
+
// still matches it, so a name can be taken by another merchant without ever
|
|
44
|
+
// being listed twice.
|
|
45
|
+
function lookupProblems(catalog) {
|
|
46
|
+
const problems = [];
|
|
47
|
+
const { seed, overrides } = catalog.merchants;
|
|
48
|
+
for (const m of [...overrides, ...seed]) {
|
|
49
|
+
for (const key of [m.name, ...m.aliases, ...m.domains]) {
|
|
50
|
+
const found = resolveMerchant(catalog, key, null).merchantId;
|
|
51
|
+
if (found !== m.id)
|
|
52
|
+
problems.push(`merchant ${m.id}: '${key}' resolves to ${found ?? 'no merchant'}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const c of catalog.categories)
|
|
56
|
+
for (const word of new Set([
|
|
57
|
+
c.name,
|
|
58
|
+
...c.aliases,
|
|
59
|
+
c.id.replace(/[._]/g, ' '),
|
|
60
|
+
])) {
|
|
61
|
+
const found = resolveMerchant(catalog, word, null).merchantId;
|
|
62
|
+
if (found !== null)
|
|
63
|
+
problems.push(`category ${c.id}: '${word}' resolves to ${found}`);
|
|
64
|
+
}
|
|
65
|
+
return problems;
|
|
66
|
+
}
|
|
41
67
|
function cardProblems(card, catalog, merchantIds) {
|
|
42
68
|
const where = `card ${card.id}`;
|
|
43
69
|
const problems = [];
|
|
@@ -87,6 +113,7 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
87
113
|
['cap', card.caps.map((c) => c.id)],
|
|
88
114
|
['choice', card.choices.map((c) => c.id)],
|
|
89
115
|
['milestone', card.milestones.map((m) => m.id)],
|
|
116
|
+
['intro rate', card.intro_rates.map((i) => i.id)],
|
|
90
117
|
]) {
|
|
91
118
|
for (const id of duplicates(ids))
|
|
92
119
|
problems.push(`${where}: duplicate ${kind} id '${id}'`);
|
|
@@ -95,6 +122,14 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
95
122
|
for (const m of card.milestones)
|
|
96
123
|
if (card.rules.some((r) => r.id === m.id))
|
|
97
124
|
problems.push(`${where}: milestone '${m.id}' has a rule's id`);
|
|
125
|
+
for (const intro of card.intro_rates) {
|
|
126
|
+
if (card.rules.some((r) => r.id === intro.id) ||
|
|
127
|
+
card.milestones.some((m) => m.id === intro.id))
|
|
128
|
+
problems.push(`${where}: intro rate '${intro.id}' has a rule's id`);
|
|
129
|
+
for (const id of intro.rules ?? [])
|
|
130
|
+
if (!card.rules.some((r) => r.id === id))
|
|
131
|
+
problems.push(`${where} intro rate ${intro.id}: unknown rule '${id}'`);
|
|
132
|
+
}
|
|
98
133
|
if (card.foreign_transaction_fee === null &&
|
|
99
134
|
!card.todo.some((t) => /foreign transaction fee/i.test(t)))
|
|
100
135
|
problems.push(`${where}: foreign_transaction_fee is null without a todo saying why`);
|
|
@@ -111,6 +146,7 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
111
146
|
...(card.requires_membership ? ['requires_membership'] : []),
|
|
112
147
|
...card.rules.map((r) => r.id),
|
|
113
148
|
...card.milestones.map((m) => m.id),
|
|
149
|
+
...card.intro_rates.map((i) => i.id),
|
|
114
150
|
]);
|
|
115
151
|
const sourcePages = new Set(card.source_urls.map((u) => u.split('#')[0]));
|
|
116
152
|
const backed = new Set(card.evidence.map((e) => e.rule));
|
|
@@ -205,6 +241,7 @@ export function catalogProblems(catalog) {
|
|
|
205
241
|
const { seed, overrides } = catalog.merchants;
|
|
206
242
|
problems.push(...merchantProblems('seed', seed, categories));
|
|
207
243
|
problems.push(...merchantProblems('overrides', overrides, categories));
|
|
244
|
+
problems.push(...lookupProblems(catalog));
|
|
208
245
|
const merchantIds = new Set([...seed, ...overrides].map((m) => m.id));
|
|
209
246
|
for (const m of [...seed, ...overrides]) {
|
|
210
247
|
for (const via of m.booked_via) {
|
package/dist/data/wallet-edit.js
CHANGED
|
@@ -31,7 +31,8 @@ const flowList = (ids) => `[${ids.join(', ')}]`;
|
|
|
31
31
|
function fieldLines(field, f, indent) {
|
|
32
32
|
switch (field) {
|
|
33
33
|
case 'checkout_label':
|
|
34
|
-
case 'anniversary':
|
|
34
|
+
case 'anniversary':
|
|
35
|
+
case 'opened': {
|
|
35
36
|
const value = f[field];
|
|
36
37
|
return value == null ? '' : `${indent}${field}: ${scalar(value)}\n`;
|
|
37
38
|
}
|
|
@@ -86,6 +87,7 @@ function fieldLines(field, f, indent) {
|
|
|
86
87
|
const FIELDS = [
|
|
87
88
|
'checkout_label',
|
|
88
89
|
'anniversary',
|
|
90
|
+
'opened',
|
|
89
91
|
'chosen',
|
|
90
92
|
'activated',
|
|
91
93
|
'cap_usage',
|
package/dist/engine/checklist.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { currentPeriodEnd, daysBetween } from "./periods.js";
|
|
2
|
-
import { CAP_PERIODS, choiceName, listed, money, shortDate, wholeDollars, } from "./text.js";
|
|
2
|
+
import { CAP_PERIODS, choiceName, introEndsText, listed, money, shortDate, wholeDollars, } from "./text.js";
|
|
3
3
|
import { milestonePays } from "./milestones.js";
|
|
4
4
|
import { optionName } from "./rule-text.js";
|
|
5
5
|
import { activationsDue, walletSummary, } from "./wallet-summary.js";
|
|
@@ -18,12 +18,12 @@ shortName) {
|
|
|
18
18
|
const name = s.checkoutLabel ?? shortName?.(card.id) ?? s.cardName;
|
|
19
19
|
for (const a of activationsDue(catalog, card, held, today)) {
|
|
20
20
|
const starts = a.from !== null && a.from > today
|
|
21
|
-
?
|
|
21
|
+
? ` (it starts ${shortDate(a.from)})`
|
|
22
22
|
: '';
|
|
23
23
|
items.push({
|
|
24
24
|
kind: 'activate',
|
|
25
25
|
cardId: card.id,
|
|
26
|
-
text: `Activate ${name}'s bonus
|
|
26
|
+
text: `Activate ${name}'s bonus ${a.preposition} ${a.what}${a.activateBy === null ? '' : ` by ${shortDate(a.activateBy)}`}${starts}.`,
|
|
27
27
|
due: a.activateBy ?? (a.from !== null && a.from > today ? a.from : a.to),
|
|
28
28
|
});
|
|
29
29
|
}
|
|
@@ -103,6 +103,17 @@ shortName) {
|
|
|
103
103
|
due: b.deadline,
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
|
+
const quarterEnd = currentPeriodEnd('quarter', today, null);
|
|
107
|
+
if (s.intro?.state === 'active' &&
|
|
108
|
+
s.intro.until !== null &&
|
|
109
|
+
quarterEnd !== null &&
|
|
110
|
+
s.intro.until <= quarterEnd)
|
|
111
|
+
items.push({
|
|
112
|
+
kind: 'intro',
|
|
113
|
+
cardId: card.id,
|
|
114
|
+
text: introEndsText(name, s.intro.days, s.intro.until),
|
|
115
|
+
due: s.intro.until,
|
|
116
|
+
});
|
|
106
117
|
if (s.rotationGap !== null) {
|
|
107
118
|
items.push({
|
|
108
119
|
kind: 'rotation',
|
package/dist/engine/evaluate.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { introCovers, introWindows, withIntro, } from "./intro.js";
|
|
1
2
|
import { inEffect, periodKey } from "./periods.js";
|
|
2
3
|
export function purchaseContext(catalog, wallet, merchant, category, purchase) {
|
|
3
4
|
return {
|
|
@@ -167,10 +168,16 @@ function beats(a, aRate, b, bRate) {
|
|
|
167
168
|
}
|
|
168
169
|
export function evaluateCard(card, held, ctx) {
|
|
169
170
|
const { picked, staleChoice } = pickedCategories(card, held, ctx.date);
|
|
171
|
+
const windows = introWindows(card, held, ctx.date);
|
|
172
|
+
const introFor = (ruleId) => windows.filter((w) => introCovers(w.intro, ruleId));
|
|
173
|
+
const rateOf = (rule) => withIntro(rule.rate, rule.id, windows);
|
|
174
|
+
const baseRate = withIntro(card.base_rate, null, windows);
|
|
170
175
|
const base = {
|
|
171
176
|
rule: null,
|
|
172
|
-
nominalRate:
|
|
173
|
-
|
|
177
|
+
nominalRate: baseRate,
|
|
178
|
+
standardRate: card.base_rate,
|
|
179
|
+
intro: introFor(null),
|
|
180
|
+
rate: baseRate,
|
|
174
181
|
cap: null,
|
|
175
182
|
afterCap: null,
|
|
176
183
|
};
|
|
@@ -187,14 +194,19 @@ export function evaluateCard(card, held, ctx) {
|
|
|
187
194
|
else
|
|
188
195
|
applying.push(rule);
|
|
189
196
|
}
|
|
190
|
-
const uncapped = Math.max(
|
|
197
|
+
const uncapped = Math.max(baseRate, ...applying.filter((r) => r.cap === undefined).map(rateOf));
|
|
191
198
|
for (const rule of applying) {
|
|
192
199
|
const cap = capState(rule, card, held, ctx.date);
|
|
193
|
-
const
|
|
200
|
+
const rate = rateOf(rule);
|
|
201
|
+
const afterCap = cap
|
|
202
|
+
? Math.max(withIntro(cap.cap.then_rate, rule.id, windows, true), uncapped)
|
|
203
|
+
: null;
|
|
194
204
|
const earning = {
|
|
195
205
|
rule,
|
|
196
|
-
nominalRate:
|
|
197
|
-
|
|
206
|
+
nominalRate: rate,
|
|
207
|
+
standardRate: rule.rate,
|
|
208
|
+
intro: introFor(rule.id),
|
|
209
|
+
rate: rateAfterCap(rate, cap, afterCap ?? rate, ctx.amount),
|
|
198
210
|
cap,
|
|
199
211
|
afterCap,
|
|
200
212
|
};
|
|
@@ -206,7 +218,7 @@ export function evaluateCard(card, held, ctx) {
|
|
|
206
218
|
return {
|
|
207
219
|
best,
|
|
208
220
|
nominal,
|
|
209
|
-
needsActivation: blocked.filter((r) => r
|
|
221
|
+
needsActivation: blocked.filter((r) => rateOf(r) > best.rate),
|
|
210
222
|
staleChoice,
|
|
211
223
|
};
|
|
212
224
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function addDays(date, days) {
|
|
2
|
+
const t = new Date(`${date}T00:00:00Z`);
|
|
3
|
+
t.setUTCDate(t.getUTCDate() + days);
|
|
4
|
+
return t.toISOString().slice(0, 10);
|
|
5
|
+
}
|
|
6
|
+
// Never assumed: a card without a date is ranked at its standard rates.
|
|
7
|
+
export const openedOn = (held) => held.opened ?? held.bonus?.opened ?? null;
|
|
8
|
+
// The opening day is day 1, so 365 days run through the day before the first
|
|
9
|
+
// anniversary.
|
|
10
|
+
export const introUntil = (intro, opened) => addDays(opened, intro.days - 1);
|
|
11
|
+
// The card's intro rates in effect on `date`.
|
|
12
|
+
export function introWindows(card, held, date) {
|
|
13
|
+
const opened = openedOn(held);
|
|
14
|
+
if (opened === null || date < opened)
|
|
15
|
+
return [];
|
|
16
|
+
return card.intro_rates.flatMap((intro) => {
|
|
17
|
+
const until = introUntil(intro, opened);
|
|
18
|
+
return date <= until ? [{ intro, until }] : [];
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
// Whether an intro rate changes this rule's rate (null: the base rate). Past
|
|
22
|
+
// a cap only a whole-card intro applies.
|
|
23
|
+
export const introCovers = (intro, ruleId, pastCap = false) => intro.rules === undefined ||
|
|
24
|
+
(!pastCap && ruleId !== null && intro.rules.includes(ruleId));
|
|
25
|
+
export function withIntro(rate, ruleId, windows, pastCap = false) {
|
|
26
|
+
let out = rate;
|
|
27
|
+
for (const { intro } of windows) {
|
|
28
|
+
if (!introCovers(intro, ruleId, pastCap))
|
|
29
|
+
continue;
|
|
30
|
+
out =
|
|
31
|
+
intro.multiply !== undefined
|
|
32
|
+
? out * intro.multiply
|
|
33
|
+
: out + (intro.add ?? 0);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
package/dist/engine/recommend.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
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
|
+
import { openedOn } from "./intro.js";
|
|
4
5
|
import { membershipName } from "./memberships.js";
|
|
5
6
|
import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
|
|
6
7
|
import { daysBetween } from "./periods.js";
|
|
7
8
|
import { ruleWhat } from "./rule-text.js";
|
|
8
9
|
import { rotationGap } from "./rotation.js";
|
|
9
10
|
import { EPSILON, isClose } from "./compare.js";
|
|
10
|
-
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, listed, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
11
|
+
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
11
12
|
import { cardValuation } from "./valuation.js";
|
|
12
13
|
export const STALE_AFTER_DAYS = 180;
|
|
13
14
|
// The engine only tells the U.S. from everywhere else.
|
|
@@ -79,6 +80,18 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
79
80
|
(bonusPoints > 0 && purchase.amount ? bonusPoints / purchase.amount : 0);
|
|
80
81
|
// An unknown fee isn't guessed: it's warned about instead.
|
|
81
82
|
const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
|
|
83
|
+
const fresh = card.intro_rates.length > 0 && openedOn(held) === null
|
|
84
|
+
? evaluateCard(card, { ...held, opened: purchase.date }, ctx).best
|
|
85
|
+
: null;
|
|
86
|
+
const ifNew = fresh && fresh.rate > evaluation.best.rate + EPSILON
|
|
87
|
+
? {
|
|
88
|
+
rate: fresh.nominalRate,
|
|
89
|
+
returnPct: (perDollar - evaluation.best.rate + fresh.rate) *
|
|
90
|
+
valuation.centsPerUnit -
|
|
91
|
+
fee,
|
|
92
|
+
days: (fresh.intro[0]?.intro ?? card.intro_rates[0])?.days ?? 365,
|
|
93
|
+
}
|
|
94
|
+
: null;
|
|
82
95
|
entries.push({
|
|
83
96
|
card,
|
|
84
97
|
held,
|
|
@@ -90,6 +103,7 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
90
103
|
effectiveReturnPct: perDollar * valuation.centsPerUnit - fee,
|
|
91
104
|
rateReturnPct: evaluation.best.rate * valuation.centsPerUnit - fee,
|
|
92
105
|
upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) - fee,
|
|
106
|
+
ifNew,
|
|
93
107
|
});
|
|
94
108
|
}
|
|
95
109
|
entries.sort(compareEntries);
|
|
@@ -105,7 +119,12 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
105
119
|
lines.push(`${earnText(catalog, best.nominalRate, currency)} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
|
|
106
120
|
}
|
|
107
121
|
else {
|
|
108
|
-
lines.push(`${earnText(catalog,
|
|
122
|
+
lines.push(`${earnText(catalog, best.nominalRate, currency)}, its base rate. No bonus category applies here.`);
|
|
123
|
+
}
|
|
124
|
+
const [intro] = best.intro;
|
|
125
|
+
if (intro) {
|
|
126
|
+
const note = intro.intro.note ? ` ${intro.intro.note}` : '';
|
|
127
|
+
lines.push(`${introRateText(earnText(catalog, best.nominalRate, currency), earnText(catalog, best.standardRate, currency), intro.intro.days, intro.until)}${note}`);
|
|
109
128
|
}
|
|
110
129
|
const cap = best.cap;
|
|
111
130
|
if (cap && amount !== null && amount > cap.remaining) {
|
|
@@ -299,6 +318,16 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
299
318
|
assumptions.push(values);
|
|
300
319
|
if (isForeign(purchase))
|
|
301
320
|
assumptions.push(FOREIGN_ASSUMPTION);
|
|
321
|
+
const winner = entries[0];
|
|
322
|
+
for (const e of entries) {
|
|
323
|
+
if (!e.ifNew || !winner)
|
|
324
|
+
continue;
|
|
325
|
+
if (e !== winner &&
|
|
326
|
+
e.ifNew.returnPct <= winner.effectiveReturnPct + EPSILON)
|
|
327
|
+
continue;
|
|
328
|
+
const currency = e.valuation.currency;
|
|
329
|
+
assumptions.push(introUnknownText(displayName(e), earnText(catalog, e.ifNew.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency), e.ifNew.days));
|
|
330
|
+
}
|
|
302
331
|
for (const e of entries)
|
|
303
332
|
for (const hit of e.milestones)
|
|
304
333
|
assumptions.push(`Counts the ${money(hit.before)} tracked on ${displayName(e)} in ${hit.period} toward its ${wholeDollars(hit.spend)} spend bonus; spending earnrate wasn't told about isn't counted.`);
|
package/dist/engine/record.js
CHANGED
|
@@ -29,6 +29,7 @@ export function capBooking(catalog, wallet, purchase, merchant = resolveMerchant
|
|
|
29
29
|
capKey: cap.key,
|
|
30
30
|
ruleId: rule.id,
|
|
31
31
|
period: cap.period,
|
|
32
|
+
periodKind: cap.cap.period,
|
|
32
33
|
capAmount: cap.cap.amount,
|
|
33
34
|
before,
|
|
34
35
|
after: roundCents(before + purchase.amount),
|
|
@@ -90,6 +91,7 @@ export function milestoneBookings(catalog, wallet, purchase) {
|
|
|
90
91
|
cardId: card.id,
|
|
91
92
|
milestoneId: p.milestone.id,
|
|
92
93
|
period: p.period,
|
|
94
|
+
periodKind: p.milestone.period,
|
|
93
95
|
spend: p.milestone.spend,
|
|
94
96
|
before: p.spent,
|
|
95
97
|
after: roundCents(Math.max(0, p.spent + purchase.amount)),
|
package/dist/engine/rule-text.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { findMerchant } from "../merchants/resolve.js";
|
|
2
|
-
import { categoryPhrase, choiceName, listed, words } from "./text.js";
|
|
2
|
+
import { categoryPhrase, choiceName, earnText, introPeriod, listed, num, words, } from "./text.js";
|
|
3
3
|
// What a rule or a pick covers, in words, for answers and wallet lists.
|
|
4
4
|
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
5
5
|
// lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
|
|
@@ -57,3 +57,17 @@ export function ruleParts(catalog, rule) {
|
|
|
57
57
|
export function ruleWhat(catalog, rule) {
|
|
58
58
|
return listed(ruleParts(catalog, rule));
|
|
59
59
|
}
|
|
60
|
+
// "double every rate for the first year", "an extra 3% cash back on your 3%
|
|
61
|
+
// picks for the first year".
|
|
62
|
+
export function introWhat(catalog, card, intro) {
|
|
63
|
+
const covered = card.rules.filter((r) => intro.rules?.includes(r.id));
|
|
64
|
+
const on = intro.rules === undefined
|
|
65
|
+
? 'every rate'
|
|
66
|
+
: [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join('; ');
|
|
67
|
+
const change = intro.multiply === undefined
|
|
68
|
+
? `an extra ${earnText(catalog, intro.add ?? 0, card.currency)} on ${on}`
|
|
69
|
+
: intro.multiply === 2
|
|
70
|
+
? `double ${on}`
|
|
71
|
+
: `${num(intro.multiply)} times ${on}`;
|
|
72
|
+
return `${change} for the ${introPeriod(intro.days)}`;
|
|
73
|
+
}
|
package/dist/engine/setup.js
CHANGED
|
@@ -3,6 +3,7 @@ import { membership, membershipName, sameGroup } from "./memberships.js";
|
|
|
3
3
|
import { milestoneWhat } from "./milestones.js";
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
5
|
import { rotationGap } from "./rotation.js";
|
|
6
|
+
import { introWhat } from "./rule-text.js";
|
|
6
7
|
import { idList, listed } from "./text.js";
|
|
7
8
|
// Rules that start within this many days are worth activating now.
|
|
8
9
|
const ACTIVATION_LOOKAHEAD_DAYS = 45;
|
|
@@ -105,6 +106,11 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
105
106
|
rewards: m.rewards ?? [],
|
|
106
107
|
text: milestoneWhat(m).replace(/^the /, ''),
|
|
107
108
|
})),
|
|
109
|
+
introRates: card.intro_rates.map((i) => ({
|
|
110
|
+
id: i.id,
|
|
111
|
+
days: i.days,
|
|
112
|
+
text: introWhat(catalog, card, i),
|
|
113
|
+
})),
|
|
108
114
|
};
|
|
109
115
|
}
|
|
110
116
|
// Every word of the query has to match a word of the card's id, name or
|
package/dist/engine/text.js
CHANGED
|
@@ -63,6 +63,15 @@ export function categoryPhrase(catalog, id) {
|
|
|
63
63
|
? name
|
|
64
64
|
: name.charAt(0).toLowerCase() + name.slice(1);
|
|
65
65
|
}
|
|
66
|
+
// A category bought as a service reads "on" (streaming services); its own
|
|
67
|
+
// preposition or a parent's says so. Everything else reads "at".
|
|
68
|
+
export function categoryReadsOn(catalog, id) {
|
|
69
|
+
const category = catalog.categories.find((c) => c.id === id);
|
|
70
|
+
if (category?.preposition === 'on')
|
|
71
|
+
return true;
|
|
72
|
+
const dot = id.lastIndexOf('.');
|
|
73
|
+
return dot !== -1 && categoryReadsOn(catalog, id.slice(0, dot));
|
|
74
|
+
}
|
|
66
75
|
export function earnText(catalog, rate, currency) {
|
|
67
76
|
return currency === 'cash'
|
|
68
77
|
? `${pct(rate)} cash back`
|
|
@@ -122,6 +131,9 @@ export const CAP_PERIODS = {
|
|
|
122
131
|
calendar_year: 'a calendar year',
|
|
123
132
|
cardmember_year: 'a cardmember year',
|
|
124
133
|
};
|
|
134
|
+
// "for 2026-Q4", or "this cardmember year": a cardmember year's key is the
|
|
135
|
+
// year it started, which reads as the calendar year.
|
|
136
|
+
export const periodPhrase = (kind, key) => kind === 'cardmember_year' ? 'this cardmember year' : `for ${key}`;
|
|
125
137
|
export const CAP_ADJECTIVES = {
|
|
126
138
|
month: 'monthly',
|
|
127
139
|
quarter: 'quarterly',
|
|
@@ -139,3 +151,14 @@ export const PICK_PERIODS = {
|
|
|
139
151
|
calendar_year: 'each year',
|
|
140
152
|
until_changed: 'until you change it',
|
|
141
153
|
};
|
|
154
|
+
// Intro rates (a card's first 365 days): "first year", "first 90 days".
|
|
155
|
+
export const introPeriod = (days) => days === 365 ? 'first year' : `first ${String(days)} days`;
|
|
156
|
+
export const introAdjective = (days) => days === 365 ? 'first-year' : `${String(days)}-day`;
|
|
157
|
+
// Asking for an opening date: "if it opened in the last year".
|
|
158
|
+
export const introSince = (days) => days === 365 ? 'in the last year' : `in the last ${String(days)} days`;
|
|
159
|
+
// "6% cash back in your first year, through Mar 3, 2027; 3% cash back after
|
|
160
|
+
// that."
|
|
161
|
+
export const introRateText = (earn, standard, days, until) => `${earn} in your ${introPeriod(days)}, through ${shortDate(until)}; ${standard} after that.`;
|
|
162
|
+
// For a card whose opening date isn't known: it's never assumed to be new.
|
|
163
|
+
export const introUnknownText = (name, earn, standard, days) => `A ${introAdjective(days)} rate may apply on ${name}: ${earn} here in an account's ${introPeriod(days)}, instead of the ${standard} it's ranked at. Give the card's opening date if it opened ${introSince(days)}.`;
|
|
164
|
+
export const introEndsText = (name, days, until) => `${name}'s ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
|
|
@@ -22,6 +22,8 @@ function isAnniversary(text) {
|
|
|
22
22
|
const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
|
|
23
23
|
return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
|
|
24
24
|
}
|
|
25
|
+
const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d) &&
|
|
26
|
+
new Date(`${d}T00:00:00Z`).toISOString().startsWith(d);
|
|
25
27
|
function pickKey(card, today) {
|
|
26
28
|
const period = choicePeriod(card);
|
|
27
29
|
return period === null ? null : periodKey(period, today, null);
|
|
@@ -44,6 +46,15 @@ export function cardFields(card, held, input, today) {
|
|
|
44
46
|
}
|
|
45
47
|
fields.anniversary = input.anniversary;
|
|
46
48
|
}
|
|
49
|
+
if (input.opened === null)
|
|
50
|
+
fields.opened = null;
|
|
51
|
+
else if (input.opened !== undefined) {
|
|
52
|
+
if (!isDate(input.opened))
|
|
53
|
+
problems.push(`"${input.opened}" isn't a date. Use YYYY-MM-DD.`);
|
|
54
|
+
else if (input.opened > today)
|
|
55
|
+
problems.push("The account's opening date is in the future.");
|
|
56
|
+
fields.opened = input.opened;
|
|
57
|
+
}
|
|
47
58
|
if (input.activated !== undefined) {
|
|
48
59
|
const needing = card.rules
|
|
49
60
|
.filter((r) => r.requires?.activation)
|
|
@@ -142,8 +153,6 @@ export function cardFields(card, held, input, today) {
|
|
|
142
153
|
fields.bonus = null;
|
|
143
154
|
else if (input.bonus !== undefined) {
|
|
144
155
|
const b = input.bonus;
|
|
145
|
-
const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d) &&
|
|
146
|
-
new Date(`${d}T00:00:00Z`).toISOString().startsWith(d);
|
|
147
156
|
if (!isDate(b.opened))
|
|
148
157
|
problems.push(`"${b.opened}" isn't a date. Use YYYY-MM-DD.`);
|
|
149
158
|
else if (b.opened > today)
|
|
@@ -177,6 +186,12 @@ export function cardFields(card, held, input, today) {
|
|
|
177
186
|
...(dollars === undefined ? {} : { dollars }),
|
|
178
187
|
};
|
|
179
188
|
}
|
|
189
|
+
const opened = fields.opened === undefined ? (held?.opened ?? null) : fields.opened;
|
|
190
|
+
const bonusOpened = fields.bonus === undefined
|
|
191
|
+
? (held?.bonus?.opened ?? null)
|
|
192
|
+
: fields.bonus?.opened;
|
|
193
|
+
if (opened !== null && bonusOpened != null && opened !== bonusOpened)
|
|
194
|
+
problems.push(`The card's opening date (${opened}) and its welcome bonus's (${bonusOpened}) differ; give the same date for both.`);
|
|
180
195
|
return problems.length > 0 ? problems.join('\n') : fields;
|
|
181
196
|
}
|
|
182
197
|
const newHolding = (id) => ({
|
|
@@ -184,8 +199,10 @@ const newHolding = (id) => ({
|
|
|
184
199
|
cap_usage: {},
|
|
185
200
|
activated: [],
|
|
186
201
|
});
|
|
187
|
-
const withFields = ({ bonus, checkout_label, ...held }, f) => ({
|
|
202
|
+
const withFields = ({ bonus, checkout_label, opened, ...held }, f) => ({
|
|
188
203
|
...held,
|
|
204
|
+
...(opened !== undefined && f.opened === undefined ? { opened } : {}),
|
|
205
|
+
...(f.opened ? { opened: f.opened } : {}),
|
|
189
206
|
...(bonus && f.bonus === undefined ? { bonus } : {}),
|
|
190
207
|
...(checkout_label !== undefined && f.checkout_label === undefined
|
|
191
208
|
? { checkout_label }
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { bonusStatus } from "./bonus.js";
|
|
2
2
|
import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
|
|
3
|
+
import { introUntil, introWindows, openedOn } from "./intro.js";
|
|
3
4
|
import { membershipName } from "./memberships.js";
|
|
4
5
|
import { milestoneSummaries } from "./milestones.js";
|
|
5
|
-
import { optionName, ruleParts
|
|
6
|
+
import { optionName, ruleParts } from "./rule-text.js";
|
|
6
7
|
import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
|
|
7
8
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
8
9
|
import { rotationGap } from "./rotation.js";
|
|
9
10
|
import { activationRules } from "./setup.js";
|
|
10
|
-
import { CAP_PERIODS, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
|
|
11
|
+
import { CAP_PERIODS, categoryReadsOn, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
|
|
11
12
|
import { cardValuation, currencyValuation } from "./valuation.js";
|
|
12
13
|
const matchKeys = (rule) => rule.match.choice !== undefined
|
|
13
14
|
? []
|
|
@@ -98,8 +99,13 @@ export function activationsDue(catalog, card, held, today) {
|
|
|
98
99
|
seen.add(g);
|
|
99
100
|
const rules = card.rules.filter((r) => group.includes(r.id));
|
|
100
101
|
const first = rules[0];
|
|
102
|
+
const categories = rules.flatMap((r) => r.match.categories ?? []);
|
|
103
|
+
const servicesOnly = categories.length > 0 &&
|
|
104
|
+
rules.every((r) => (r.match.merchants ?? []).length === 0) &&
|
|
105
|
+
categories.every((c) => categoryReadsOn(catalog, c));
|
|
101
106
|
out.push({
|
|
102
|
-
what: rules.
|
|
107
|
+
what: listed([...new Set(rules.flatMap((r) => ruleParts(catalog, r)))]),
|
|
108
|
+
preposition: servicesOnly ? 'on' : 'at',
|
|
103
109
|
from: first?.valid_from ?? null,
|
|
104
110
|
to: first?.valid_to ?? null,
|
|
105
111
|
activateBy: first?.activate_by ?? null,
|
|
@@ -144,7 +150,7 @@ function capSummaries(catalog, card, held, today) {
|
|
|
144
150
|
const shared = card.caps.find((c) => c.id === key);
|
|
145
151
|
const label = shared?.rotating
|
|
146
152
|
? 'the rotating bonus categories'
|
|
147
|
-
: [...new Set(covered.
|
|
153
|
+
: listed([...new Set(covered.flatMap((r) => ruleParts(catalog, r)))]) ||
|
|
148
154
|
words(key);
|
|
149
155
|
return {
|
|
150
156
|
key,
|
|
@@ -211,6 +217,8 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
211
217
|
activated: held.activated,
|
|
212
218
|
toActivate: toActivate(catalog, card, held, today),
|
|
213
219
|
anniversary: held.anniversary ?? null,
|
|
220
|
+
opened: openedOn(held),
|
|
221
|
+
intro: introSummary(card, held, today),
|
|
214
222
|
});
|
|
215
223
|
}
|
|
216
224
|
return {
|
|
@@ -221,6 +229,18 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
221
229
|
cards,
|
|
222
230
|
};
|
|
223
231
|
}
|
|
232
|
+
function introSummary(card, held, today) {
|
|
233
|
+
const [first] = card.intro_rates;
|
|
234
|
+
if (!first)
|
|
235
|
+
return null;
|
|
236
|
+
const opened = openedOn(held);
|
|
237
|
+
if (opened === null)
|
|
238
|
+
return { state: 'no-date', until: null, days: first.days };
|
|
239
|
+
const [active] = introWindows(card, held, today);
|
|
240
|
+
if (active)
|
|
241
|
+
return { state: 'active', until: active.until, days: active.intro.days };
|
|
242
|
+
return { state: 'ended', until: introUntil(first, opened), days: first.days };
|
|
243
|
+
}
|
|
224
244
|
function pointValues(catalog, wallet) {
|
|
225
245
|
return Object.keys(wallet.valuations)
|
|
226
246
|
.filter((id) => catalog.currencies.some((c) => c.id === id))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const FALLBACK = '#5c5650';
|
|
2
|
+
export function cardColor(catalog, cardId) {
|
|
3
|
+
const card = catalog.cards.find((c) => c.id === cardId);
|
|
4
|
+
if (card?.color)
|
|
5
|
+
return card.color;
|
|
6
|
+
return catalog.issuers.find((i) => i.id === card?.issuer)?.color ?? FALLBACK;
|
|
7
|
+
}
|
|
8
|
+
// Relative luminance, to keep the chip and edge visible on light cards.
|
|
9
|
+
function light(hex) {
|
|
10
|
+
const [r, g, b] = [1, 3, 5].map((i) => {
|
|
11
|
+
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
|
|
12
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
13
|
+
});
|
|
14
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.35;
|
|
15
|
+
}
|
|
16
|
+
// 86 x 54 is a card's 85.6 x 54 mm; `width` scales it.
|
|
17
|
+
export function cardArtSvg(color, width = 48) {
|
|
18
|
+
const height = Math.round((width * 54) / 86);
|
|
19
|
+
const edge = light(color) ? 'rgba(0,0,0,0.18)' : 'rgba(255,255,255,0.14)';
|
|
20
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 86 54" width="${String(width)}" height="${String(height)}" aria-hidden="true" focusable="false"><rect x="0.5" y="0.5" width="85" height="53" rx="5" fill="${color}" stroke="${edge}"/><path d="M0 40 L56 0 H80 Q85.5 0 85.5 5 V8 L20 54 H5 Q0 54 0 49 Z" fill="#fff" opacity="0.07"/><rect x="10" y="17" width="14" height="11" rx="2" fill="#d9c08a" stroke="rgba(0,0,0,0.25)" stroke-width="0.6"/><path d="M10 22.5 H24 M17 17 V28" stroke="rgba(0,0,0,0.25)" stroke-width="0.6"/><rect x="10" y="40" width="30" height="3" rx="1.5" fill="${light(color) ? '#000' : '#fff'}" opacity="0.28"/></svg>`;
|
|
21
|
+
}
|
package/dist/format/cards.js
CHANGED
|
@@ -20,6 +20,7 @@ export function formatCardInfo(card) {
|
|
|
20
20
|
: []),
|
|
21
21
|
...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
|
|
22
22
|
...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
|
|
23
|
+
...card.introRates.map((i) => `new accounts: ${i.text}; needs the opening date`),
|
|
23
24
|
...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
|
|
24
25
|
foreignFeeText(card.foreignTransactionFee).toLowerCase(),
|
|
25
26
|
];
|
|
@@ -19,6 +19,7 @@ function compact(wallet) {
|
|
|
19
19
|
? {}
|
|
20
20
|
: { checkout_label: c.checkout_label }),
|
|
21
21
|
...(c.anniversary === undefined ? {} : { anniversary: c.anniversary }),
|
|
22
|
+
...(c.opened === undefined ? {} : { opened: c.opened }),
|
|
22
23
|
...(Object.keys(c.cap_usage).length > 0
|
|
23
24
|
? { cap_usage: c.cap_usage }
|
|
24
25
|
: {}),
|