earnrate 0.2.10 → 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 +31 -11
- package/data/CHANGELOG.md +154 -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 +122 -0
- package/data/cards/amex-delta-skymiles-platinum.yaml +120 -0
- package/data/cards/amex-delta-skymiles-reserve.yaml +82 -0
- package/data/cards/amex-gold.yaml +1 -0
- package/data/cards/amex-hilton-honors-aspire.yaml +142 -0
- package/data/cards/amex-hilton-honors-business.yaml +93 -0
- package/data/cards/amex-hilton-honors-surpass.yaml +153 -0
- package/data/cards/amex-hilton-honors.yaml +119 -0
- package/data/cards/amex-marriott-bonvoy-bevy.yaml +118 -0
- package/data/cards/amex-marriott-bonvoy-brilliant.yaml +93 -0
- package/data/cards/amex-marriott-bonvoy-business.yaml +116 -0
- package/data/cards/amex-platinum.yaml +15 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +14 -0
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +15 -0
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +17 -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 +82 -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 +30 -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 +124 -0
- package/data/cards/chase-marriott-bonvoy-boundless.yaml +78 -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 +30 -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 +11 -0
- package/data/cards/chase-united-club-business.yaml +20 -0
- package/data/cards/chase-united-club.yaml +32 -0
- package/data/cards/chase-united-explorer.yaml +20 -0
- package/data/cards/chase-united-gateway.yaml +19 -0
- package/data/cards/chase-united-quest.yaml +20 -0
- package/data/cards/citi-aadvantage-business.yaml +14 -1
- 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 +14 -1
- 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 +188 -0
- 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 +60 -0
- package/data/issuers.yaml +25 -1
- package/data/memberships.yaml +10 -0
- package/data/merchants/overrides.yaml +515 -1
- package/data/merchants/seed.yaml +883 -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/cheat-sheet.js +3 -0
- 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 +41 -10
- 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/prompts.js +90 -0
- package/dist/mcp/schemas.js +17 -0
- package/dist/mcp/server.js +2 -0
- package/dist/mcp/tools/account.js +8 -5
- package/dist/mcp/tools/answers.js +7 -0
- package/dist/mcp/tools/wallet.js +7 -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',
|
|
@@ -20,6 +20,9 @@ export const EVERYDAY_PURCHASES = [
|
|
|
20
20
|
{ label: 'Drugstores', category: 'drugstore' },
|
|
21
21
|
{ label: 'Everything else', category: 'other' },
|
|
22
22
|
];
|
|
23
|
+
// What a quick pick searches for: "Streaming (Netflix)" searches "Netflix",
|
|
24
|
+
// since streaming bonuses follow each issuer's list of services.
|
|
25
|
+
export const quickPickText = (item) => /\((.+)\)$/.exec(item.label)?.[1] ?? item.label;
|
|
23
26
|
// The best card in the wallet for each everyday kind of purchase on `date`,
|
|
24
27
|
// with caps, picks and activations as the wallet has them.
|
|
25
28
|
export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
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) {
|
|
@@ -133,7 +152,7 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
133
152
|
const { best, nominal } = evaluation;
|
|
134
153
|
const warnings = [];
|
|
135
154
|
if (entry.fee > 0)
|
|
136
|
-
warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.
|
|
155
|
+
warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
|
|
137
156
|
if (isForeign(purchase) && card.foreign_transaction_fee === null)
|
|
138
157
|
warnings.push("Its foreign transaction fee isn't on file, so none is subtracted. Check the issuer's terms before using it abroad.");
|
|
139
158
|
const capLabel = (e) => e.cap
|
|
@@ -220,7 +239,7 @@ function tieNote(entries) {
|
|
|
220
239
|
tied.some((e) => e.evaluation.best.cap !== null)
|
|
221
240
|
? 'because its rate has no cap'
|
|
222
241
|
: 'by a fixed order; either works';
|
|
223
|
-
return `${names} also ${verb} ${pct(winner.
|
|
242
|
+
return `${names} also ${verb} ${pct(winner.rateReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
|
|
224
243
|
}
|
|
225
244
|
// Finds the closest point inside a currency's value range where the winner
|
|
226
245
|
// stops winning, varying one currency at a time. Returns are linear in the
|
|
@@ -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.`);
|
|
@@ -338,9 +367,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
338
367
|
const twin = alts.find((e) => e.card.id !== winner.card.id &&
|
|
339
368
|
alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
|
|
340
369
|
if (alt.card.id !== winner.card.id)
|
|
341
|
-
warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.
|
|
370
|
+
warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.rateReturnPct)}).`);
|
|
342
371
|
else if (twin)
|
|
343
|
-
warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.
|
|
372
|
+
warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.rateReturnPct)}).`);
|
|
344
373
|
}
|
|
345
374
|
}
|
|
346
375
|
if (!winner) {
|
|
@@ -359,7 +388,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
359
388
|
? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
|
|
360
389
|
: undefined;
|
|
361
390
|
if (alt)
|
|
362
|
-
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.
|
|
391
|
+
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.rateReturnPct)}).`);
|
|
363
392
|
}
|
|
364
393
|
else if (merchant.accepts === null &&
|
|
365
394
|
(winner.card.network === 'amex' || winner.card.network === 'discover')) {
|
|
@@ -384,7 +413,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
384
413
|
const alt = rankEntries(catalog, wallet, purchase, via, via.category)
|
|
385
414
|
.entries[0];
|
|
386
415
|
if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
|
|
387
|
-
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.
|
|
416
|
+
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.rateReturnPct)}.`);
|
|
388
417
|
}
|
|
389
418
|
}
|
|
390
419
|
const open = bonusPick(catalog, wallet, entries, purchase.date);
|
|
@@ -400,7 +429,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
400
429
|
const bonus = open && (open.behind || ties) ? open : null;
|
|
401
430
|
const best = winner?.effectiveReturnPct ?? 0;
|
|
402
431
|
if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
|
|
403
|
-
notes.push(`${displayName(open.entry)} earns ${pct(open.entry.
|
|
432
|
+
notes.push(`${displayName(open.entry)} earns ${pct(open.entry.rateReturnPct)} 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.`);
|
|
404
433
|
}
|
|
405
434
|
const chosen = bonus?.entry ?? winner;
|
|
406
435
|
// A tie among the top cards doesn't matter when a welcome bonus decides.
|
|
@@ -417,7 +446,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
417
446
|
const daysLeft = status.daysLeft ?? 0;
|
|
418
447
|
const costHere = ties || top.earned === null || first.earned === null
|
|
419
448
|
? null
|
|
420
|
-
: roundCents(first.earned
|
|
449
|
+
: roundCents(first.earned +
|
|
450
|
+
(first.milestoneBonus?.dollars ?? 0) -
|
|
451
|
+
(top.earned + (top.milestoneBonus?.dollars ?? 0)));
|
|
421
452
|
bonusResult = {
|
|
422
453
|
cardId: top.cardId,
|
|
423
454
|
checkoutLabel: top.checkoutLabel,
|
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 }
|