earnrate 0.2.12 → 0.2.13
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 +20 -7
- package/data/CHANGELOG.md +181 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-cash-everyday.yaml +28 -2
- package/data/cards/amex-blue-cash-preferred.yaml +19 -0
- package/data/cards/amex-business-gold.yaml +38 -3
- package/data/cards/amex-business-platinum.yaml +72 -3
- package/data/cards/amex-delta-skymiles-gold.yaml +27 -1
- package/data/cards/amex-delta-skymiles-platinum.yaml +33 -1
- package/data/cards/amex-delta-skymiles-reserve.yaml +33 -1
- package/data/cards/amex-gold.yaml +52 -0
- package/data/cards/amex-hilton-honors-aspire.yaml +23 -1
- package/data/cards/amex-hilton-honors-business.yaml +14 -1
- package/data/cards/amex-hilton-honors-surpass.yaml +14 -1
- package/data/cards/amex-marriott-bonvoy-brilliant.yaml +15 -1
- package/data/cards/amex-platinum.yaml +110 -0
- package/data/cards/apple-card.yaml +76 -0
- package/data/cards/bilt-obsidian.yaml +12 -2
- package/data/cards/bilt-palladium.yaml +12 -2
- package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
- package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -0
- package/data/cards/capital-one-venture-x-business.yaml +28 -1
- package/data/cards/capital-one-venture-x.yaml +30 -2
- package/data/cards/chase-aeroplan.yaml +14 -0
- package/data/cards/chase-ihg-one-rewards-business.yaml +27 -1
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +16 -0
- package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -0
- package/data/cards/chase-ink-business-cash.yaml +13 -0
- package/data/cards/chase-ink-business-unlimited.yaml +13 -2
- package/data/cards/chase-sapphire-preferred.yaml +11 -0
- package/data/cards/chase-sapphire-reserve.yaml +44 -4
- package/data/cards/chase-united-business.yaml +32 -0
- package/data/cards/chase-united-club-business.yaml +40 -0
- package/data/cards/chase-united-club.yaml +40 -0
- package/data/cards/chase-united-explorer.yaml +32 -0
- package/data/cards/chase-united-quest.yaml +40 -0
- package/data/cards/citi-aadvantage-executive.yaml +26 -1
- package/data/cards/citi-strata-elite.yaml +20 -1
- package/data/cards/fidelity-rewards-visa-signature.yaml +60 -0
- package/data/cards/navy-federal-cash-rewards-plus.yaml +61 -0
- package/data/cards/navy-federal-cash-rewards.yaml +60 -0
- package/data/cards/navy-federal-more-rewards-amex.yaml +92 -0
- package/data/cards/paypal-cashback-mastercard.yaml +13 -2
- package/data/cards/penfed-power-cash-rewards.yaml +47 -0
- package/data/cards/usbank-altitude-connect.yaml +8 -2
- package/data/cards/usbank-altitude-go.yaml +8 -2
- package/data/cards/usbank-smartly.yaml +107 -0
- package/data/cards/venmo-credit-card.yaml +151 -0
- package/data/cards/verizon-visa-card.yaml +122 -0
- package/data/currencies.yaml +14 -0
- package/data/issuers.yaml +22 -0
- package/data/memberships.yaml +35 -0
- package/data/merchants/overrides.yaml +448 -0
- package/data/merchants/seed.yaml +1082 -65
- package/dist/cli/args.js +18 -0
- package/dist/cli/explore.js +10 -1
- package/dist/cli/run.js +4 -1
- package/dist/cli/wallet.js +30 -3
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +22 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +25 -6
- package/dist/engine/cheat-sheet.js +17 -5
- package/dist/engine/checklist.js +25 -4
- package/dist/engine/credits.js +65 -0
- package/dist/engine/evaluate.js +38 -11
- package/dist/engine/explore.js +70 -7
- package/dist/engine/offers.js +2 -1
- package/dist/engine/periods.js +10 -3
- package/dist/engine/recommend.js +72 -20
- package/dist/engine/record.js +41 -1
- package/dist/engine/rotation.js +3 -0
- package/dist/engine/rule-text.js +14 -3
- package/dist/engine/setup.js +15 -4
- package/dist/engine/simulate.js +10 -1
- package/dist/engine/text.js +84 -0
- package/dist/engine/wallet-change.js +37 -1
- package/dist/engine/wallet-summary.js +32 -4
- package/dist/format/card-text.js +4 -3
- package/dist/format/cheat-sheet.js +2 -0
- package/dist/format/explore.js +20 -4
- package/dist/format/recommendation.js +6 -2
- package/dist/format/wallet.js +6 -2
- package/dist/import/analyze.js +7 -2
- package/dist/mcp/inputs.js +10 -0
- package/dist/mcp/prompts.js +9 -1
- package/dist/mcp/schemas.js +70 -1
- package/dist/mcp/tools/answers.js +9 -4
- package/dist/mcp/tools/wallet.js +15 -7
- package/dist/merchants/resolve.js +8 -0
- package/dist/schema/card.js +37 -3
- package/dist/schema/common.js +1 -1
- package/dist/schema/merchant.js +3 -0
- package/dist/schema/wallet.js +8 -0
- package/package.json +1 -1
package/dist/cli/args.js
CHANGED
|
@@ -2,6 +2,7 @@ import { parseArgs } from 'node:util';
|
|
|
2
2
|
import { isValuationProfile, } from "../engine/profiles.js";
|
|
3
3
|
import { knownMemberships } from "../engine/setup.js";
|
|
4
4
|
import { COUNTRY_CODE, countryProblem } from "../schema/common.js";
|
|
5
|
+
import { PaymentMethod } from "../schema/card.js";
|
|
5
6
|
// Shared by every subcommand, so each flag means the same thing everywhere.
|
|
6
7
|
// A string result is the error to show.
|
|
7
8
|
const LOOKS_LIKE_AMOUNT = /^\$?[\d,]*\.?\d+$/;
|
|
@@ -50,6 +51,18 @@ export function membershipProblem(catalog, members) {
|
|
|
50
51
|
? null
|
|
51
52
|
: `Unknown membership "${unknown.join('", "')}". Use one of: ${known.join(', ')}.`;
|
|
52
53
|
}
|
|
54
|
+
export const PAYING_HELP = `--paying <how> card, apple-pay, google-pay or paypal. Rates that need
|
|
55
|
+
one (Apple Pay on Apple Card) count only when you say.`;
|
|
56
|
+
export function paymentOf(text) {
|
|
57
|
+
if (text === undefined)
|
|
58
|
+
return { payment: null };
|
|
59
|
+
const parsed = PaymentMethod.safeParse(text.toLowerCase());
|
|
60
|
+
return parsed.success
|
|
61
|
+
? { payment: parsed.data }
|
|
62
|
+
: {
|
|
63
|
+
problem: `Unknown way of paying "${text}". Use one of: ${PaymentMethod.options.join(', ')}.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
53
66
|
export const BOTH_CHANNELS = 'Pick --online or --in-store, not both.';
|
|
54
67
|
export function channelOf(values) {
|
|
55
68
|
return values.online ? 'online' : values['in-store'] ? 'in_store' : null;
|
|
@@ -75,6 +88,7 @@ export function parseCliArgs(argv) {
|
|
|
75
88
|
valuation: { type: 'string' },
|
|
76
89
|
date: { type: 'string' },
|
|
77
90
|
country: { type: 'string' },
|
|
91
|
+
paying: { type: 'string' },
|
|
78
92
|
json: { type: 'boolean' },
|
|
79
93
|
category: { type: 'string' },
|
|
80
94
|
all: { type: 'boolean' },
|
|
@@ -112,6 +126,9 @@ export function parseCliArgs(argv) {
|
|
|
112
126
|
const country = values.country ?? 'US';
|
|
113
127
|
if (!COUNTRY_CODE.test(country))
|
|
114
128
|
return { kind: 'error', message: countryProblem(country) };
|
|
129
|
+
const paying = paymentOf(values.paying);
|
|
130
|
+
if ('problem' in paying)
|
|
131
|
+
return { kind: 'error', message: paying.problem };
|
|
115
132
|
return {
|
|
116
133
|
kind: 'run',
|
|
117
134
|
options: {
|
|
@@ -121,6 +138,7 @@ export function parseCliArgs(argv) {
|
|
|
121
138
|
valuation,
|
|
122
139
|
date: values.date ?? null,
|
|
123
140
|
country: country.toUpperCase(),
|
|
141
|
+
payment: paying.payment,
|
|
124
142
|
json: values.json ?? false,
|
|
125
143
|
all: values.all ?? false,
|
|
126
144
|
category: values.category ?? null,
|
package/dist/cli/explore.js
CHANGED
|
@@ -5,7 +5,7 @@ import { ALL_PERSONAL_CARDS, exploreCards, } from "../engine/explore.js";
|
|
|
5
5
|
import { isValuationProfile, profileText, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
|
|
6
6
|
import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, typicalSpend, TYPICAL_SPEND_NOTE, } from "../engine/simulate.js";
|
|
7
7
|
import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../format/explore.js";
|
|
8
|
-
import { BOTH_CHANNELS, cardList, channelOf, dateProblem, membershipProblem, splitAmount, valuationProblem, } from "./args.js";
|
|
8
|
+
import { BOTH_CHANNELS, cardList, channelOf, dateProblem, membershipProblem, paymentOf, splitAmount, valuationProblem, } from "./args.js";
|
|
9
9
|
import { COUNTRY_CODE, countryProblem } from "../schema/common.js";
|
|
10
10
|
import { cardsToUse, RUNTIME_ERROR, savedSettings, USAGE_ERROR, } from "./run.js";
|
|
11
11
|
const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or points and miles cards only.
|
|
@@ -33,6 +33,8 @@ ${FILTER_OPTIONS}
|
|
|
33
33
|
--date <YYYY-MM-DD> Purchase date. Default: today.
|
|
34
34
|
--country <code> Where you're paying. Abroad, cards rank after their
|
|
35
35
|
foreign transaction fees. Default: US.
|
|
36
|
+
--paying <how> card, apple-pay, google-pay or paypal. Rates that need
|
|
37
|
+
one (Apple Pay on Apple Card) count only when you say.
|
|
36
38
|
--all List every card, not just the top 10.
|
|
37
39
|
--json Print the full result as JSON.
|
|
38
40
|
-h, --help Show this help.
|
|
@@ -108,6 +110,7 @@ export async function runBest(argv, io) {
|
|
|
108
110
|
'in-store': { type: 'boolean' },
|
|
109
111
|
date: { type: 'string' },
|
|
110
112
|
country: { type: 'string' },
|
|
113
|
+
paying: { type: 'string' },
|
|
111
114
|
},
|
|
112
115
|
});
|
|
113
116
|
}
|
|
@@ -144,6 +147,11 @@ export async function runBest(argv, io) {
|
|
|
144
147
|
io.stderr(`${countryProblem(country)}\n`);
|
|
145
148
|
return USAGE_ERROR;
|
|
146
149
|
}
|
|
150
|
+
const paying = paymentOf(values.paying);
|
|
151
|
+
if ('problem' in paying) {
|
|
152
|
+
io.stderr(`${paying.problem}\n`);
|
|
153
|
+
return USAGE_ERROR;
|
|
154
|
+
}
|
|
147
155
|
const catalog = await loadCatalog();
|
|
148
156
|
const settings = settingsFrom(values, catalog, await savedSettings(io));
|
|
149
157
|
if (typeof settings === 'string') {
|
|
@@ -157,6 +165,7 @@ export async function runBest(argv, io) {
|
|
|
157
165
|
channel: channelOf(values),
|
|
158
166
|
date: values.date ?? io.today,
|
|
159
167
|
country: country.toUpperCase(),
|
|
168
|
+
payment: paying.payment,
|
|
160
169
|
}, settings);
|
|
161
170
|
io.stdout(values.json
|
|
162
171
|
? `${JSON.stringify(result, null, 2)}\n`
|
package/dist/cli/run.js
CHANGED
|
@@ -17,7 +17,7 @@ import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
|
|
|
17
17
|
import { missedPath, missLogAt } from "../merchants/missed.js";
|
|
18
18
|
import { resolvePlace, withCallerCategory } from "../merchants/resolve.js";
|
|
19
19
|
import { packageVersion } from "../version.js";
|
|
20
|
-
import { membershipProblem, parseCliArgs } from "./args.js";
|
|
20
|
+
import { membershipProblem, PAYING_HELP, parseCliArgs } from "./args.js";
|
|
21
21
|
import { runCheatSheet } from "./cheatsheet.js";
|
|
22
22
|
import { runMissed } from "./missed.js";
|
|
23
23
|
import { runTodo } from "./todo.js";
|
|
@@ -67,6 +67,7 @@ Options:
|
|
|
67
67
|
--date <YYYY-MM-DD> Purchase date. Default: today.
|
|
68
68
|
--country <code> Where you're paying. Abroad, cards rank after their
|
|
69
69
|
foreign transaction fees. Default: US.
|
|
70
|
+
${PAYING_HELP}
|
|
70
71
|
--cards <ids> Answer for these cards instead of your wallet, comma
|
|
71
72
|
separated: --cards amex-gold,chase-prime-visa.
|
|
72
73
|
--member <name> A membership you have, in place of the saved ones.
|
|
@@ -201,6 +202,7 @@ export async function run(argv, io) {
|
|
|
201
202
|
channel: options.channel,
|
|
202
203
|
date: options.date ?? io.today,
|
|
203
204
|
country: options.country,
|
|
205
|
+
payment: options.payment,
|
|
204
206
|
};
|
|
205
207
|
if (options.category !== null &&
|
|
206
208
|
!catalog.categories.some((c) => c.id === options.category)) {
|
|
@@ -239,6 +241,7 @@ export async function run(argv, io) {
|
|
|
239
241
|
channel: purchase.channel,
|
|
240
242
|
date: purchase.date,
|
|
241
243
|
country: purchase.country,
|
|
244
|
+
payment: purchase.payment ?? null,
|
|
242
245
|
}, {
|
|
243
246
|
filters: ALL_PERSONAL_CARDS,
|
|
244
247
|
memberships: options.members.length > 0 ? options.members : saved.memberships,
|
package/dist/cli/wallet.js
CHANGED
|
@@ -19,6 +19,12 @@ const CARD_OPTIONS = `Options:
|
|
|
19
19
|
card is ranked at its standard rates.
|
|
20
20
|
--activated <rules> Rules you've activated with the issuer, comma
|
|
21
21
|
separated. Replaces the current list.
|
|
22
|
+
--enrolled <credits> Merchant credits you've enrolled in with the
|
|
23
|
+
issuer, comma separated, e.g. --enrolled lululemon.
|
|
24
|
+
Replaces the current list.
|
|
25
|
+
--credit-used <credit>=<dollars>
|
|
26
|
+
How much of a credit you've received this period,
|
|
27
|
+
e.g. --credit-used lululemon=40. Repeat for each.
|
|
22
28
|
--bonus-opened <date> Track a welcome bonus: the day the account opened
|
|
23
29
|
(YYYY-MM-DD). Spending needed and the deadline
|
|
24
30
|
default to the card's published offer.
|
|
@@ -47,8 +53,8 @@ ${CARD_OPTIONS}
|
|
|
47
53
|
update: `Usage: ${CLI_NAME} update <card-id> [options]
|
|
48
54
|
|
|
49
55
|
Changes a card in your wallet: picks for this period, label, anniversary,
|
|
50
|
-
opening date, activations or welcome bonus. Picks for the
|
|
51
|
-
ones already set.
|
|
56
|
+
opening date, activations, merchant credits or welcome bonus. Picks for the
|
|
57
|
+
current period are merged with ones already set.
|
|
52
58
|
|
|
53
59
|
${CARD_OPTIONS}
|
|
54
60
|
`,
|
|
@@ -135,6 +141,8 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
135
141
|
anniversary: { type: 'string' },
|
|
136
142
|
opened: { type: 'string' },
|
|
137
143
|
activated: { type: 'string' },
|
|
144
|
+
enrolled: { type: 'string' },
|
|
145
|
+
'credit-used': { type: 'string', multiple: true },
|
|
138
146
|
'bonus-opened': { type: 'string' },
|
|
139
147
|
'bonus-spent': { type: 'string' },
|
|
140
148
|
'bonus-spend': { type: 'string' },
|
|
@@ -163,7 +171,9 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
163
171
|
values.pick !== undefined ||
|
|
164
172
|
values.anniversary !== undefined ||
|
|
165
173
|
values.opened !== undefined ||
|
|
166
|
-
values.activated !== undefined
|
|
174
|
+
values.activated !== undefined ||
|
|
175
|
+
values.enrolled !== undefined ||
|
|
176
|
+
values['credit-used'] !== undefined;
|
|
167
177
|
if (command !== 'add' && command !== 'update' && cardOptionsGiven) {
|
|
168
178
|
return usage(`${CLI_NAME} ${command} doesn't take card options.`);
|
|
169
179
|
}
|
|
@@ -261,6 +271,23 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
261
271
|
const n = Number(text.replace(/[$,]/g, ''));
|
|
262
272
|
return Number.isFinite(n) ? n : `--${flag} takes dollars, like 1200.`;
|
|
263
273
|
};
|
|
274
|
+
if (values.enrolled !== undefined) {
|
|
275
|
+
input.enrolled = values.enrolled
|
|
276
|
+
.split(',')
|
|
277
|
+
.map((r) => r.trim())
|
|
278
|
+
.filter((r) => r !== '');
|
|
279
|
+
}
|
|
280
|
+
if (values['credit-used'] !== undefined) {
|
|
281
|
+
const used = {};
|
|
282
|
+
for (const pair of values['credit-used']) {
|
|
283
|
+
const [credit = '', dollars] = pair.split('=');
|
|
284
|
+
const n = dollarsFlag('credit-used', dollars);
|
|
285
|
+
if (credit.trim() === '' || n === undefined || typeof n === 'string')
|
|
286
|
+
return usage('--credit-used takes <credit>=<dollars>, like lululemon=40.');
|
|
287
|
+
used[credit.trim()] = n;
|
|
288
|
+
}
|
|
289
|
+
input.creditUsed = used;
|
|
290
|
+
}
|
|
264
291
|
if (values['no-bonus']) {
|
|
265
292
|
input.bonus = null;
|
|
266
293
|
}
|
package/dist/data/file-store.js
CHANGED
|
@@ -87,8 +87,11 @@ export class FileWalletStore {
|
|
|
87
87
|
return failed(`${purchase.cardId} isn't in the wallet.`);
|
|
88
88
|
const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
|
|
89
89
|
try {
|
|
90
|
-
if (booked.cap ||
|
|
91
|
-
|
|
90
|
+
if (booked.cap ||
|
|
91
|
+
booked.bonus ||
|
|
92
|
+
booked.milestones.length > 0 ||
|
|
93
|
+
booked.credit)
|
|
94
|
+
await writeProgress(this.where, booked.cap ? [booked.cap] : [], booked.bonus ? [booked.bonus] : [], booked.milestones, booked.credit ? [booked.credit] : []);
|
|
92
95
|
}
|
|
93
96
|
catch (e) {
|
|
94
97
|
return failed(errorText(e));
|
package/dist/data/integrity.js
CHANGED
|
@@ -114,6 +114,7 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
114
114
|
['choice', card.choices.map((c) => c.id)],
|
|
115
115
|
['milestone', card.milestones.map((m) => m.id)],
|
|
116
116
|
['intro rate', card.intro_rates.map((i) => i.id)],
|
|
117
|
+
['credit', card.credits.map((c) => c.id)],
|
|
117
118
|
]) {
|
|
118
119
|
for (const id of duplicates(ids))
|
|
119
120
|
problems.push(`${where}: duplicate ${kind} id '${id}'`);
|
|
@@ -130,6 +131,16 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
130
131
|
if (!card.rules.some((r) => r.id === id))
|
|
131
132
|
problems.push(`${where} intro rate ${intro.id}: unknown rule '${id}'`);
|
|
132
133
|
}
|
|
134
|
+
for (const credit of card.credits) {
|
|
135
|
+
if (card.rules.some((r) => r.id === credit.id) ||
|
|
136
|
+
card.milestones.some((m) => m.id === credit.id) ||
|
|
137
|
+
card.intro_rates.some((i) => i.id === credit.id))
|
|
138
|
+
problems.push(`${where}: credit '${credit.id}' has a rule's id`);
|
|
139
|
+
for (const c of credit.match.categories ?? [])
|
|
140
|
+
checkCategory(c, `credit ${credit.id}`);
|
|
141
|
+
for (const m of credit.match.merchants ?? [])
|
|
142
|
+
checkMerchant(m, `credit ${credit.id}`);
|
|
143
|
+
}
|
|
133
144
|
if (card.foreign_transaction_fee === null &&
|
|
134
145
|
!card.todo.some((t) => /foreign transaction fee/i.test(t)))
|
|
135
146
|
problems.push(`${where}: foreign_transaction_fee is null without a todo saying why`);
|
|
@@ -147,6 +158,7 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
147
158
|
...card.rules.map((r) => r.id),
|
|
148
159
|
...card.milestones.map((m) => m.id),
|
|
149
160
|
...card.intro_rates.map((i) => i.id),
|
|
161
|
+
...card.credits.map((c) => c.id),
|
|
150
162
|
]);
|
|
151
163
|
const sourcePages = new Set(card.source_urls.map((u) => u.split('#')[0]));
|
|
152
164
|
const backed = new Set(card.evidence.map((e) => e.rule));
|
|
@@ -249,6 +261,16 @@ export function catalogProblems(catalog) {
|
|
|
249
261
|
problems.push(`merchant ${m.id}: booked_via '${via}' is not a merchant`);
|
|
250
262
|
}
|
|
251
263
|
}
|
|
264
|
+
const all = new Map([...seed, ...overrides].map((m) => [m.id, m]));
|
|
265
|
+
for (const m of all.values()) {
|
|
266
|
+
if (m.brand_of === undefined)
|
|
267
|
+
continue;
|
|
268
|
+
const parent = all.get(m.brand_of);
|
|
269
|
+
if (!parent)
|
|
270
|
+
problems.push(`merchant ${m.id}: brand_of '${m.brand_of}' is not a merchant`);
|
|
271
|
+
else if (parent.brand_of !== undefined)
|
|
272
|
+
problems.push(`merchant ${m.id}: brand_of '${m.brand_of}' is itself a brand of '${parent.brand_of}'`);
|
|
273
|
+
}
|
|
252
274
|
for (const m of catalog.memberships)
|
|
253
275
|
for (const id of m.members_shop_at)
|
|
254
276
|
if (!merchantIds.has(id))
|
package/dist/data/store.js
CHANGED
|
@@ -86,7 +86,10 @@ export class JsonWalletStore {
|
|
|
86
86
|
if (!wallet.cards.some((c) => c.id === purchase.cardId))
|
|
87
87
|
return failed(`${purchase.cardId} isn't in the wallet.`);
|
|
88
88
|
const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
|
|
89
|
-
if (booked.cap ||
|
|
89
|
+
if (booked.cap ||
|
|
90
|
+
booked.bonus ||
|
|
91
|
+
booked.milestones.length > 0 ||
|
|
92
|
+
booked.credit)
|
|
90
93
|
await this.backend.write(JSON.stringify(booked.wallet));
|
|
91
94
|
return { ok: true, ...booked };
|
|
92
95
|
});
|
package/dist/data/wallet-edit.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { writeFileAtomic } from "./atomic-write.js";
|
|
3
3
|
import { isDeepStrictEqual } from 'node:util';
|
|
4
4
|
import { isMap, isSeq, parseDocument, stringify } from 'yaml';
|
|
5
|
-
import { applyBonusBooking, applyBooking, applyMilestoneBooking, } from "../engine/record.js";
|
|
5
|
+
import { applyBonusBooking, applyBooking, applyCreditBooking, applyMilestoneBooking, } from "../engine/record.js";
|
|
6
6
|
import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
|
|
7
7
|
const NEW_WALLET = `# Your earnrate wallet: the cards you hold, by id. Never card numbers.
|
|
8
8
|
# Format: https://github.com/cwasley/earnrate#your-wallet
|
|
@@ -40,6 +40,18 @@ function fieldLines(field, f, indent) {
|
|
|
40
40
|
return f.activated === undefined
|
|
41
41
|
? ''
|
|
42
42
|
: `${indent}activated: ${flowList(f.activated)}\n`;
|
|
43
|
+
case 'enrolled':
|
|
44
|
+
return f.enrolled === undefined
|
|
45
|
+
? ''
|
|
46
|
+
: `${indent}enrolled: ${flowList(f.enrolled)}\n`;
|
|
47
|
+
case 'credit_usage': {
|
|
48
|
+
if (f.credit_usage === undefined)
|
|
49
|
+
return '';
|
|
50
|
+
const entries = Object.entries(f.credit_usage)
|
|
51
|
+
.map(([key, u]) => `${indent} ${key}: { period: ${scalar(u.period)}, used: ${String(u.used)} }\n`)
|
|
52
|
+
.join('');
|
|
53
|
+
return entries === '' ? '' : `${indent}credit_usage:\n${entries}`;
|
|
54
|
+
}
|
|
43
55
|
case 'cap_usage': {
|
|
44
56
|
if (f.cap_usage === undefined)
|
|
45
57
|
return '';
|
|
@@ -92,6 +104,8 @@ const FIELDS = [
|
|
|
92
104
|
'activated',
|
|
93
105
|
'cap_usage',
|
|
94
106
|
'milestone_usage',
|
|
107
|
+
'enrolled',
|
|
108
|
+
'credit_usage',
|
|
95
109
|
'bonus',
|
|
96
110
|
];
|
|
97
111
|
function cardsSeq(text) {
|
|
@@ -281,9 +295,9 @@ function setValuationText(text, id, cents) {
|
|
|
281
295
|
const indent = ' '.repeat(column(text, range(first?.key)[0]));
|
|
282
296
|
return insertLines(text, afterLine(text, range(last?.value ?? last?.key)[1] - 1), `${indent}${line}\n`);
|
|
283
297
|
}
|
|
284
|
-
function flowUsage(period, spent) {
|
|
298
|
+
function flowUsage(period, spent, name = 'spent') {
|
|
285
299
|
const key = /^\d+$/.test(period) ? `"${period}"` : period;
|
|
286
|
-
return `{ period: ${key},
|
|
300
|
+
return `{ period: ${key}, ${name}: ${String(spent)} }`;
|
|
287
301
|
}
|
|
288
302
|
// Sets one entry of a card's cap_usage or milestone_usage, adding the map
|
|
289
303
|
// when the card has none.
|
|
@@ -324,9 +338,9 @@ function bonusSpentText(text, cardId, spent) {
|
|
|
324
338
|
const at = afterLine(text, range(bonus)[1] - 1);
|
|
325
339
|
return insertLines(text, at, `${indent}spent: ${value}\n`);
|
|
326
340
|
}
|
|
327
|
-
// Writes spending toward caps, welcome bonuses and spend milestones
|
|
328
|
-
// checked write.
|
|
329
|
-
export async function writeProgress(file, caps, bonuses, milestones = []) {
|
|
341
|
+
// Writes spending toward caps, welcome bonuses and spend milestones, and
|
|
342
|
+
// merchant credits used, in one checked write.
|
|
343
|
+
export async function writeProgress(file, caps, bonuses, milestones = [], credits = []) {
|
|
330
344
|
const { text, wallet } = await readWalletText(file);
|
|
331
345
|
if (text === null || wallet === null)
|
|
332
346
|
throw new Error(`No wallet at ${file}.`);
|
|
@@ -344,10 +358,15 @@ export async function writeProgress(file, caps, bonuses, milestones = []) {
|
|
|
344
358
|
after = bonusSpentText(after, b.cardId, b.after);
|
|
345
359
|
expected = applyBonusBooking(expected, b);
|
|
346
360
|
}
|
|
361
|
+
for (const c of credits) {
|
|
362
|
+
after = usageText(after, c.cardId, 'credit_usage', c.creditId, flowUsage(c.period, c.after, 'used'));
|
|
363
|
+
expected = applyCreditBooking(expected, c);
|
|
364
|
+
}
|
|
347
365
|
const byHand = [
|
|
348
366
|
...caps.map((b) => `${b.cardId} cap_usage ${b.capKey}: ${flowUsage(b.period, b.after)}`),
|
|
349
367
|
...bonuses.map((b) => `${b.cardId} bonus spent: ${String(b.after)}`),
|
|
350
368
|
...milestones.map((m) => `${m.cardId} milestone_usage ${m.milestoneId}: ${flowUsage(m.period, m.after)}`),
|
|
369
|
+
...credits.map((c) => `${c.cardId} credit_usage ${c.creditId}: ${flowUsage(c.period, c.after, 'used')}`),
|
|
351
370
|
].join('; ');
|
|
352
371
|
await commitWalletText(file, after, expected, `add it by hand: ${byHand}`);
|
|
353
372
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { categoryResolution, resolveMerchantById, } from "../merchants/resolve.js";
|
|
2
|
-
import { exploreCards } from "./explore.js";
|
|
2
|
+
import { exploreCards, matchesFilters } 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 { ABROAD, recommend } from "./recommend.js";
|
|
7
7
|
import { checklist } from "./checklist.js";
|
|
8
8
|
import { setupNotes } from "./setup.js";
|
|
9
|
-
import { capitalize, feeText, listed, money, shortDate } from "./text.js";
|
|
9
|
+
import { capitalize, CREDITS_LEFT_OUT, feeText, listed, money, paymentNote, shortDate, } from "./text.js";
|
|
10
10
|
// The everyday kinds of purchase: the cheat sheet's rows and the site's
|
|
11
11
|
// quick picks.
|
|
12
12
|
export const EVERYDAY_PURCHASES = [
|
|
@@ -39,7 +39,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
|
39
39
|
channel: null,
|
|
40
40
|
date,
|
|
41
41
|
country: 'US',
|
|
42
|
-
}, merchant);
|
|
42
|
+
}, merchant, { credits: false });
|
|
43
43
|
// The card that earns the most, even when a welcome bonus would take the
|
|
44
44
|
// pick for one purchase: the sheet is for the whole quarter.
|
|
45
45
|
const top = rec.decision ? rec.ranked[0] : undefined;
|
|
@@ -101,6 +101,12 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
|
101
101
|
: n.text)),
|
|
102
102
|
]),
|
|
103
103
|
];
|
|
104
|
+
const paying = paymentNote(wallet.cards.flatMap((held) => {
|
|
105
|
+
const card = catalog.cards.find((c) => c.id === held.id);
|
|
106
|
+
return card ? [{ name: held.checkout_label ?? card.name, card }] : [];
|
|
107
|
+
}));
|
|
108
|
+
if (paying !== null)
|
|
109
|
+
notes.push(paying);
|
|
104
110
|
const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
|
|
105
111
|
return {
|
|
106
112
|
scope: 'wallet',
|
|
@@ -108,6 +114,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
|
108
114
|
date,
|
|
109
115
|
rows,
|
|
110
116
|
notes,
|
|
117
|
+
assumptions: [CREDITS_LEFT_OUT],
|
|
111
118
|
};
|
|
112
119
|
}
|
|
113
120
|
const ABROAD_LABEL = 'Abroad';
|
|
@@ -121,7 +128,7 @@ function abroadRow(catalog, wallet, date) {
|
|
|
121
128
|
channel: null,
|
|
122
129
|
date,
|
|
123
130
|
country: ABROAD,
|
|
124
|
-
}, merchant);
|
|
131
|
+
}, merchant, { credits: false });
|
|
125
132
|
const top = rec.decision ? rec.ranked[0] : undefined;
|
|
126
133
|
const allCharge = rec.ranked.length > 0 && rec.ranked.every((r) => r.foreignFee !== null);
|
|
127
134
|
return {
|
|
@@ -192,6 +199,11 @@ export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCH
|
|
|
192
199
|
quarter: `${q ?? ''} ${year ?? ''}`,
|
|
193
200
|
date,
|
|
194
201
|
rows,
|
|
195
|
-
notes: [
|
|
202
|
+
notes: [
|
|
203
|
+
paymentNote(catalog.cards
|
|
204
|
+
.filter((card) => matchesFilters(card, options.filters))
|
|
205
|
+
.map((card) => ({ name: card.name, card }))),
|
|
206
|
+
].filter((n) => n !== null),
|
|
207
|
+
assumptions: [CREDITS_LEFT_OUT],
|
|
196
208
|
};
|
|
197
209
|
}
|
package/dist/engine/checklist.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { currentPeriodEnd, daysBetween } from "./periods.js";
|
|
2
|
-
import { CAP_PERIODS, choiceName, introEndsText, listed, money, shortDate, wholeDollars, } from "./text.js";
|
|
2
|
+
import { CAP_PERIODS, choiceName, enrollCreditText, introEndsText, listed, money, shortDate, useCreditText, 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";
|
|
6
6
|
// What the wallet's cards need this period, soonest first: bonuses to
|
|
7
7
|
// activate, categories to pick, caps running low, welcome bonuses to finish,
|
|
8
|
-
// and rotating categories the issuer
|
|
8
|
+
// merchant credits to enroll in or use, and rotating categories the issuer
|
|
9
|
+
// hasn't posted yet.
|
|
9
10
|
export function checklist(catalog, wallet, today, summary = walletSummary(catalog, wallet, today),
|
|
10
11
|
// A shorter card name than the issuer's full one, where the caller has it.
|
|
11
12
|
shortName) {
|
|
@@ -93,6 +94,26 @@ shortName) {
|
|
|
93
94
|
due: m.until,
|
|
94
95
|
});
|
|
95
96
|
}
|
|
97
|
+
for (const c of s.credits) {
|
|
98
|
+
if (!c.enrolled)
|
|
99
|
+
items.push({
|
|
100
|
+
kind: 'enroll',
|
|
101
|
+
cardId: card.id,
|
|
102
|
+
ref: c.id,
|
|
103
|
+
text: enrollCreditText(name, c.name),
|
|
104
|
+
due: null,
|
|
105
|
+
});
|
|
106
|
+
// One that needs no enrolling shows once the cardholder uses it, so a
|
|
107
|
+
// card's every credit isn't a to-do for someone who never shops there.
|
|
108
|
+
else if (c.left !== null && c.left > 0 && (c.enroll || c.tracked))
|
|
109
|
+
items.push({
|
|
110
|
+
kind: 'credit',
|
|
111
|
+
cardId: card.id,
|
|
112
|
+
ref: c.id,
|
|
113
|
+
text: useCreditText(name, c, c.left),
|
|
114
|
+
due: c.until,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
96
117
|
const b = s.bonus;
|
|
97
118
|
if (b?.state === 'open' && b.remaining !== null && b.deadline !== null) {
|
|
98
119
|
const weeks = Math.max(1, (b.daysLeft ?? 7) / 7);
|
|
@@ -127,7 +148,7 @@ shortName) {
|
|
|
127
148
|
a.cardId.localeCompare(b.cardId));
|
|
128
149
|
}
|
|
129
150
|
// An all-day calendar event for each dated action (activations, picks,
|
|
130
|
-
// welcome-bonus deadlines), with a reminder two days before. Built on the
|
|
151
|
+
// welcome-bonus deadlines, credits to use), with a reminder two days before. Built on the
|
|
131
152
|
// device: nothing is sent anywhere.
|
|
132
153
|
export function checklistCalendar(items, stamp) {
|
|
133
154
|
const escape = (t) => t.replace(/\\/g, '\\\\').replace(/[,;]/g, (c) => `\\${c}`);
|
|
@@ -149,7 +170,7 @@ export function checklistCalendar(items, stamp) {
|
|
|
149
170
|
const due = i.due ?? '';
|
|
150
171
|
return [
|
|
151
172
|
'BEGIN:VEVENT',
|
|
152
|
-
`UID:${i.kind}-${i.cardId}-${due}@earnrate.dev`,
|
|
173
|
+
`UID:${i.kind}-${i.cardId}${i.ref === undefined ? '' : `-${i.ref}`}-${due}@earnrate.dev`,
|
|
153
174
|
`DTSTAMP:${stamp}`,
|
|
154
175
|
`DTSTART;VALUE=DATE:${day(due)}`,
|
|
155
176
|
`DTEND;VALUE=DATE:${day(next(due))}`,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { merchantFamily } from "../merchants/resolve.js";
|
|
2
|
+
import { categoryMatches } from "./evaluate.js";
|
|
3
|
+
import { periodEnd, periodKey } from "./periods.js";
|
|
4
|
+
import { creditName } from "./rule-text.js";
|
|
5
|
+
import { roundCents } from "./text.js";
|
|
6
|
+
// Only a store itself has a credit: a search for a kind of purchase
|
|
7
|
+
// ("clothing") never does.
|
|
8
|
+
// A credit naming a merchant also covers its brands (Hilton's covers Hampton).
|
|
9
|
+
export function creditCovers(catalog, credit, merchant) {
|
|
10
|
+
if (merchant.source === 'category' || merchant.source === 'none')
|
|
11
|
+
return false;
|
|
12
|
+
const named = credit.match.merchants;
|
|
13
|
+
if (named)
|
|
14
|
+
return merchantFamily(catalog, merchant.merchantId).some((id) => named.includes(id));
|
|
15
|
+
return (credit.match.categories ?? []).some((c) => categoryMatches(c, merchant.category));
|
|
16
|
+
}
|
|
17
|
+
// A credit that needs enrolling counts only once the wallet says it's done.
|
|
18
|
+
export const isEnrolled = (credit, held) => credit.enroll !== true || (held.enrolled ?? []).includes(credit.id);
|
|
19
|
+
export function creditState(catalog, credit, held, date) {
|
|
20
|
+
const anniversary = held.anniversary ?? null;
|
|
21
|
+
const period = periodKey(credit.period, date, anniversary);
|
|
22
|
+
const usage = held.credit_usage?.[credit.id];
|
|
23
|
+
const used = period !== null && usage !== undefined && usage.period === period
|
|
24
|
+
? usage.used
|
|
25
|
+
: 0;
|
|
26
|
+
return {
|
|
27
|
+
credit,
|
|
28
|
+
key: period,
|
|
29
|
+
name: creditName(catalog, credit),
|
|
30
|
+
amount: credit.amount,
|
|
31
|
+
period: credit.period,
|
|
32
|
+
enrolled: isEnrolled(credit, held),
|
|
33
|
+
used,
|
|
34
|
+
left: period === null ? null : roundCents(Math.max(0, credit.amount - used)),
|
|
35
|
+
until: period === null ? null : periodEnd(credit.period, period, anniversary),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export const creditStates = (catalog, card, held, date) => card.credits.map((c) => creditState(catalog, c, held, date));
|
|
39
|
+
export const counts = (s) => s.enrolled && s.left !== null && s.left > 0;
|
|
40
|
+
// The card's credit at this merchant, if any: one that counts first, then
|
|
41
|
+
// the one with the most left.
|
|
42
|
+
export function creditAt(catalog, card, held, merchant, date) {
|
|
43
|
+
const here = card.credits
|
|
44
|
+
.filter((c) => creditCovers(catalog, c, merchant))
|
|
45
|
+
.map((c) => creditState(catalog, c, held, date))
|
|
46
|
+
.sort((a, b) => Number(counts(b)) - Number(counts(a)) || (b.left ?? 0) - (a.left ?? 0));
|
|
47
|
+
return here[0] ?? null;
|
|
48
|
+
}
|
|
49
|
+
// Dollars back on a purchase: null without an amount, 0 when the credit
|
|
50
|
+
// doesn't count (not enrolled, or used up).
|
|
51
|
+
export function creditDollars(s, amount) {
|
|
52
|
+
if (amount === null)
|
|
53
|
+
return null;
|
|
54
|
+
if (!counts(s) || amount <= 0)
|
|
55
|
+
return 0;
|
|
56
|
+
return roundCents(Math.min(amount, s.left ?? 0));
|
|
57
|
+
}
|
|
58
|
+
const PER_YEAR = {
|
|
59
|
+
month: 12,
|
|
60
|
+
quarter: 4,
|
|
61
|
+
half_year: 2,
|
|
62
|
+
calendar_year: 1,
|
|
63
|
+
cardmember_year: 1,
|
|
64
|
+
};
|
|
65
|
+
export const creditPerYear = (c) => c.amount * PER_YEAR[c.period];
|
package/dist/engine/evaluate.js
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
|
+
import { merchantFamily } from "../merchants/resolve.js";
|
|
2
|
+
import { EPSILON } from "./compare.js";
|
|
1
3
|
import { introCovers, introWindows, withIntro, } from "./intro.js";
|
|
2
4
|
import { inEffect, periodKey } from "./periods.js";
|
|
5
|
+
// PayPal checkout happens online, so paying through it says the channel.
|
|
6
|
+
export const paymentChannel = (channel, payment) => channel ?? (payment === 'paypal' ? 'online' : null);
|
|
3
7
|
export function purchaseContext(catalog, wallet, merchant, category, purchase) {
|
|
8
|
+
const payment = purchase.payment ?? null;
|
|
4
9
|
return {
|
|
5
10
|
category,
|
|
6
|
-
|
|
11
|
+
merchantIds: merchantFamily(catalog, merchant.merchantId),
|
|
7
12
|
mcc: category === merchant.category && merchant.likelyMcc !== null
|
|
8
13
|
? merchant.likelyMcc
|
|
9
14
|
: singleMcc(catalog, category),
|
|
10
|
-
channel: merchant.channel,
|
|
15
|
+
channel: paymentChannel(merchant.channel, payment),
|
|
11
16
|
country: purchase.country,
|
|
12
17
|
date: purchase.date,
|
|
13
18
|
amount: purchase.amount,
|
|
14
19
|
memberships: new Set(wallet.memberships),
|
|
20
|
+
payment,
|
|
15
21
|
};
|
|
16
22
|
}
|
|
17
23
|
// A category with one typical MCC (travel agencies are 4722) implies it, the
|
|
@@ -79,18 +85,16 @@ export function isActivated(card, held, ruleId) {
|
|
|
79
85
|
function applicability(card, rule, ctx, held, picked) {
|
|
80
86
|
const { match, exclude } = rule;
|
|
81
87
|
const inCategory = (cats) => cats.some((c) => categoryMatches(c, ctx.category));
|
|
88
|
+
const named = (ids) => ids?.some((id) => ctx.merchantIds.includes(id)) ?? false;
|
|
82
89
|
if (match.categories?.length && !inCategory(match.categories))
|
|
83
90
|
return 'no';
|
|
84
|
-
if (match.merchants?.length &&
|
|
85
|
-
!(ctx.merchantId !== null && match.merchants.includes(ctx.merchantId))) {
|
|
91
|
+
if (match.merchants?.length && !named(match.merchants))
|
|
86
92
|
return 'no';
|
|
87
|
-
}
|
|
88
93
|
if (match.mccs?.length && !(ctx.mcc !== null && match.mccs.includes(ctx.mcc)))
|
|
89
94
|
return 'no';
|
|
90
95
|
if (match.choice !== undefined) {
|
|
91
96
|
const covered = picked.get(match.choice);
|
|
92
|
-
const merchantPicked =
|
|
93
|
-
(covered?.merchants.includes(ctx.merchantId) ?? false);
|
|
97
|
+
const merchantPicked = named(covered?.merchants);
|
|
94
98
|
const onlinePicked = ctx.channel === 'online' &&
|
|
95
99
|
(covered?.online.some((o) => !inCategory(o.exclude)) ?? false);
|
|
96
100
|
if (!merchantPicked &&
|
|
@@ -100,13 +104,14 @@ function applicability(card, rule, ctx, held, picked) {
|
|
|
100
104
|
}
|
|
101
105
|
if (exclude?.categories && inCategory(exclude.categories))
|
|
102
106
|
return 'no';
|
|
103
|
-
if (exclude?.merchants
|
|
104
|
-
ctx.merchantId !== null &&
|
|
105
|
-
exclude.merchants.includes(ctx.merchantId)) {
|
|
107
|
+
if (named(exclude?.merchants))
|
|
106
108
|
return 'no';
|
|
107
|
-
}
|
|
108
109
|
if (exclude?.mccs && ctx.mcc !== null && exclude.mccs.includes(ctx.mcc))
|
|
109
110
|
return 'no';
|
|
111
|
+
if (exclude?.payment &&
|
|
112
|
+
ctx.payment !== null &&
|
|
113
|
+
exclude.payment.includes(ctx.payment))
|
|
114
|
+
return 'no';
|
|
110
115
|
if (rule.channel !== 'any' && rule.channel !== ctx.channel)
|
|
111
116
|
return 'no';
|
|
112
117
|
if (rule.geography === 'us_only' && ctx.country !== 'US')
|
|
@@ -118,6 +123,9 @@ function applicability(card, rule, ctx, held, picked) {
|
|
|
118
123
|
const membership = rule.requires?.membership;
|
|
119
124
|
if (membership !== undefined && !ctx.memberships.has(membership))
|
|
120
125
|
return 'no';
|
|
126
|
+
const payment = rule.requires?.payment;
|
|
127
|
+
if (payment !== undefined && ctx.payment !== payment)
|
|
128
|
+
return 'no';
|
|
121
129
|
if (rule.requires?.activation && !isActivated(card, held, rule.id))
|
|
122
130
|
return 'needs-activation';
|
|
123
131
|
return 'applies';
|
|
@@ -222,3 +230,22 @@ export function evaluateCard(card, held, ctx) {
|
|
|
222
230
|
staleChoice,
|
|
223
231
|
};
|
|
224
232
|
}
|
|
233
|
+
// The way of paying (one a rule on the card requires) that would earn the
|
|
234
|
+
// most on this purchase, when that beats `rate`. Only while the purchase
|
|
235
|
+
// doesn't say how it's paid.
|
|
236
|
+
export function paymentUpgrade(card, held, ctx, rate) {
|
|
237
|
+
if (ctx.payment !== null)
|
|
238
|
+
return null;
|
|
239
|
+
let best = null;
|
|
240
|
+
const methods = new Set(card.rules.flatMap((r) => r.requires?.payment === undefined ? [] : [r.requires.payment]));
|
|
241
|
+
for (const payment of methods) {
|
|
242
|
+
const paid = evaluateCard(card, held, {
|
|
243
|
+
...ctx,
|
|
244
|
+
payment,
|
|
245
|
+
channel: paymentChannel(ctx.channel, payment),
|
|
246
|
+
}).best;
|
|
247
|
+
if (paid.rate > (best?.rate ?? rate) + EPSILON)
|
|
248
|
+
best = { payment, rate: paid.rate, nominalRate: paid.nominalRate };
|
|
249
|
+
}
|
|
250
|
+
return best;
|
|
251
|
+
}
|