earnrate 0.2.13 → 0.2.15
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 +10 -10
- package/data/CHANGELOG.md +28 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-delta-skymiles-gold.yaml +7 -8
- package/data/cards/amex-delta-skymiles-platinum.yaml +7 -8
- package/data/cards/amex-delta-skymiles-reserve.yaml +9 -9
- package/data/cards/amex-marriott-bonvoy-business.yaml +7 -8
- package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
- package/data/cards/discover-it-cash-back.yaml +9 -6
- package/data/cards/venmo-credit-card.yaml +1 -1
- package/data/categories.yaml +10 -0
- package/data/merchants/seed.yaml +3 -1
- package/dist/cli/import.js +14 -11
- package/dist/cli/todo.js +3 -2
- package/dist/cli/wallet.js +50 -17
- package/dist/engine/cheat-sheet.js +15 -11
- package/dist/engine/checklist.js +23 -25
- package/dist/engine/credits.js +2 -2
- package/dist/engine/evaluate.js +1 -1
- package/dist/engine/explore.js +4 -4
- package/dist/engine/intro.js +1 -5
- package/dist/engine/memberships.js +2 -6
- package/dist/engine/offers.js +3 -5
- package/dist/engine/periods.js +5 -0
- package/dist/engine/profiles.js +2 -1
- package/dist/engine/recommend.js +46 -33
- package/dist/engine/record.js +2 -1
- package/dist/engine/rotation.js +2 -1
- package/dist/engine/rule-text.js +98 -33
- package/dist/engine/setup.js +7 -3
- package/dist/engine/simulate.js +4 -4
- package/dist/engine/text.js +29 -8
- package/dist/engine/wallet-change.js +5 -5
- package/dist/engine/wallet-summary.js +24 -21
- package/dist/format/card-text.js +2 -3
- package/dist/format/cards.js +7 -3
- package/dist/format/explore.js +2 -2
- package/dist/format/wallet-yaml.js +13 -17
- package/dist/format/wallet.js +14 -10
- package/dist/import/analyze.js +13 -1
- package/dist/mcp/context.js +2 -3
- package/dist/mcp/inputs.js +3 -3
- package/dist/mcp/schemas.js +2 -0
- package/dist/mcp/server.js +0 -2
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +10 -5
- package/dist/mcp/tools/wallet.js +10 -6
- package/dist/schema/card.js +6 -10
- package/dist/schema/merchant.js +3 -0
- package/package.json +2 -1
package/dist/engine/recommend.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { findMerchant, merchantFamily, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
|
|
2
2
|
import { bonusStatus } from "./bonus.js";
|
|
3
3
|
import { evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
|
-
import { openedOn } from "./intro.js";
|
|
4
|
+
import { introWindows, openedOn, withIntro } from "./intro.js";
|
|
5
5
|
import { membershipName } from "./memberships.js";
|
|
6
6
|
import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
|
|
7
7
|
import { daysBetween } from "./periods.js";
|
|
@@ -9,12 +9,18 @@ import { creditAt, creditDollars } from "./credits.js";
|
|
|
9
9
|
import { ruleWhat } from "./rule-text.js";
|
|
10
10
|
import { rotationGap } from "./rotation.js";
|
|
11
11
|
import { EPSILON, isClose } from "./compare.js";
|
|
12
|
-
import { CAP_ADJECTIVES, categoryPhrase, creditAnswerText, codesAs, currencyLabel, currencyName, earnText, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
12
|
+
import { CAP_ADJECTIVES, categoryPhrase, creditAnswerText, codesAs, currencyLabel, currencyName, earnText, heldName, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, possessive, } from "./text.js";
|
|
13
13
|
import { cardValuation } from "./valuation.js";
|
|
14
14
|
export const STALE_AFTER_DAYS = 180;
|
|
15
15
|
// The engine only tells the U.S. from everywhere else.
|
|
16
16
|
export const FOREIGN_ASSUMPTION = 'Treats this as a purchase outside the U.S.: each card is ranked after its foreign transaction fee.';
|
|
17
17
|
export const isForeign = (purchase) => purchase.country !== 'US';
|
|
18
|
+
// A fee that isn't on file counts as the usual 3%, so a card can't win
|
|
19
|
+
// abroad on a guess that it charges none.
|
|
20
|
+
const ASSUMED_FOREIGN_FEE = 3;
|
|
21
|
+
export const foreignFeeFor = (card, purchase) => isForeign(purchase)
|
|
22
|
+
? (card.foreign_transaction_fee ?? ASSUMED_FOREIGN_FEE)
|
|
23
|
+
: 0;
|
|
18
24
|
// Any purchase outside the U.S., when no country is named: ZZ is a code ISO
|
|
19
25
|
// leaves unassigned, and nothing prints it.
|
|
20
26
|
export const ABROAD = 'ZZ';
|
|
@@ -79,8 +85,7 @@ function rankEntries(catalog, wallet, purchase, merchant, category, options = {}
|
|
|
79
85
|
const bonusPoints = milestonePoints(milestones);
|
|
80
86
|
const perDollar = evaluation.best.rate +
|
|
81
87
|
(bonusPoints > 0 && purchase.amount ? bonusPoints / purchase.amount : 0);
|
|
82
|
-
|
|
83
|
-
const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
|
|
88
|
+
const fee = foreignFeeFor(card, purchase);
|
|
84
89
|
const fresh = card.intro_rates.length > 0 && openedOn(held) === null
|
|
85
90
|
? evaluateCard(card, { ...held, opened: purchase.date }, ctx).best
|
|
86
91
|
: null;
|
|
@@ -138,18 +143,19 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
138
143
|
const { best } = evaluation;
|
|
139
144
|
const lines = [];
|
|
140
145
|
const currency = valuation.currency;
|
|
146
|
+
// The standard rate first; a first-year rate on top of it is its own line.
|
|
147
|
+
// The intro's terms stay in search_cards, not in every answer.
|
|
148
|
+
const [intro] = best.intro;
|
|
149
|
+
const standard = earnText(catalog, intro ? best.standardRate : best.nominalRate, currency);
|
|
141
150
|
if (best.rule) {
|
|
142
151
|
const note = best.rule.note ? ` ${best.rule.note}` : '';
|
|
143
|
-
lines.push(`${
|
|
152
|
+
lines.push(`${standard} on ${ruleWhat(catalog, best.rule, card)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
|
|
144
153
|
}
|
|
145
154
|
else {
|
|
146
|
-
lines.push(`${
|
|
147
|
-
}
|
|
148
|
-
const [intro] = best.intro;
|
|
149
|
-
if (intro) {
|
|
150
|
-
const note = intro.intro.note ? ` ${intro.intro.note}` : '';
|
|
151
|
-
lines.push(`${introRateText(earnText(catalog, best.nominalRate, currency), earnText(catalog, best.standardRate, currency), intro.intro.days, intro.until)}${note}`);
|
|
155
|
+
lines.push(`${standard}, its base rate. No bonus category applies here.`);
|
|
152
156
|
}
|
|
157
|
+
if (intro)
|
|
158
|
+
lines.push(introRateText(earnText(catalog, best.nominalRate, currency), standard, intro.intro.days, intro.until));
|
|
153
159
|
const cap = best.cap;
|
|
154
160
|
if (cap && amount !== null && amount > cap.remaining) {
|
|
155
161
|
const over = amount - cap.remaining;
|
|
@@ -163,10 +169,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
163
169
|
const partners = card.pools_into?.when_wallet_has ?? [];
|
|
164
170
|
const heldPartners = wallet.cards
|
|
165
171
|
.filter((h) => partners.includes(h.id))
|
|
166
|
-
.map((h) => h
|
|
167
|
-
|
|
168
|
-
h.id);
|
|
169
|
-
lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
|
|
172
|
+
.map((h) => heldName(catalog, h));
|
|
173
|
+
lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${listed(heldPartners)}.`);
|
|
170
174
|
}
|
|
171
175
|
if (currency !== 'cash') {
|
|
172
176
|
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.evaluation.best.rate * valuation.centsPerUnit)} back.`);
|
|
@@ -177,10 +181,10 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
177
181
|
const { card, valuation, evaluation } = entry;
|
|
178
182
|
const { best, nominal } = evaluation;
|
|
179
183
|
const warnings = [];
|
|
180
|
-
if (entry.fee > 0)
|
|
181
|
-
warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
|
|
182
184
|
if (isForeign(purchase) && card.foreign_transaction_fee === null)
|
|
183
|
-
warnings.push(
|
|
185
|
+
warnings.push(`Its foreign transaction fee isn't on file, so earnrate assumes the usual ${pct(entry.fee)} and it nets ${pct(entry.rateReturnPct)}. Check the issuer's terms before using it abroad.`);
|
|
186
|
+
else if (entry.fee > 0)
|
|
187
|
+
warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
|
|
184
188
|
const capLabel = (e) => e.cap
|
|
185
189
|
? `${money(e.cap.cap.amount)} ${CAP_ADJECTIVES[e.cap.cap.period]} cap`
|
|
186
190
|
: '';
|
|
@@ -190,17 +194,17 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
190
194
|
warnings.push(`Add this card's anniversary (MM-DD) to your wallet so its ${capLabel(best)} can be tracked.`);
|
|
191
195
|
}
|
|
192
196
|
else if (purchase.amount !== null && purchase.amount > remaining) {
|
|
193
|
-
warnings.push(`This purchase crosses the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)}: only ${money(remaining)} is left for ${period}.`);
|
|
197
|
+
warnings.push(`This purchase crosses the ${capLabel(best)} on ${ruleWhat(catalog, best.rule, card)}: only ${money(remaining)} is left for ${period}.`);
|
|
194
198
|
}
|
|
195
199
|
else if (remaining < cap.amount * NEAR_CAP_SHARE) {
|
|
196
|
-
warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)} for ${period}.`);
|
|
200
|
+
warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on ${ruleWhat(catalog, best.rule, card)} for ${period}.`);
|
|
197
201
|
}
|
|
198
202
|
}
|
|
199
203
|
if (nominal.rule &&
|
|
200
204
|
nominal.cap &&
|
|
201
205
|
nominal !== best &&
|
|
202
206
|
nominal.cap.remaining === 0) {
|
|
203
|
-
warnings.push(`The ${capLabel(nominal)} on ${ruleWhat(catalog, nominal.rule)} is used up for ${nominal.cap.period ?? 'this period'}, so it earns ${earnText(catalog, nominal.afterCap ?? nominal.cap.cap.then_rate, valuation.currency)} until it resets.`);
|
|
207
|
+
warnings.push(`The ${capLabel(nominal)} on ${ruleWhat(catalog, nominal.rule, card)} is used up for ${nominal.cap.period ?? 'this period'}, so it earns ${earnText(catalog, nominal.afterCap ?? nominal.cap.cap.then_rate, valuation.currency)} until it resets.`);
|
|
204
208
|
}
|
|
205
209
|
if (best.rule?.unverified !== undefined) {
|
|
206
210
|
warnings.push(`This rate isn't confirmed against the issuer's terms yet: ${best.rule.unverified}`);
|
|
@@ -212,12 +216,14 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
212
216
|
if (evaluation.staleChoice !== null) {
|
|
213
217
|
warnings.push(`Your chosen categories are set for ${evaluation.staleChoice}, not the current period, so chosen-category bonuses don't apply until you update them.`);
|
|
214
218
|
}
|
|
219
|
+
const windows = introWindows(card, entry.held, purchase.date);
|
|
215
220
|
for (const rule of evaluation.needsActivation) {
|
|
221
|
+
const rate = earnText(catalog, withIntro(rule.rate, rule.id, windows), valuation.currency);
|
|
216
222
|
if (rule.activate_by !== undefined && rule.activate_by < purchase.date) {
|
|
217
|
-
warnings.push(`The bonus here (${
|
|
223
|
+
warnings.push(`The bonus here (${rate}) needed activating by ${shortDate(rule.activate_by)}.`);
|
|
218
224
|
continue;
|
|
219
225
|
}
|
|
220
|
-
warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${
|
|
226
|
+
warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${rate} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
|
|
221
227
|
}
|
|
222
228
|
return warnings;
|
|
223
229
|
}
|
|
@@ -267,7 +273,7 @@ function tieNote(entries) {
|
|
|
267
273
|
const first = tied[0];
|
|
268
274
|
if (!first)
|
|
269
275
|
return null;
|
|
270
|
-
const names = tied.map(displayName)
|
|
276
|
+
const names = listed(tied.map(displayName));
|
|
271
277
|
const verb = tied.length === 1 ? 'earns' : 'earn';
|
|
272
278
|
const why = winner.upside - first.upside > EPSILON
|
|
273
279
|
? 'because its points can be worth more'
|
|
@@ -277,11 +283,11 @@ function tieNote(entries) {
|
|
|
277
283
|
: 'by a fixed order; either works';
|
|
278
284
|
return `${names} also ${verb} ${pct(winner.rateReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
|
|
279
285
|
}
|
|
286
|
+
// "Chase Ultimate Rewards points", but not "IHG One Rewards points points".
|
|
287
|
+
const pointsName = (label) => /\b(points|miles)$/i.test(label) ? label : `${label} points`;
|
|
280
288
|
// Finds the closest point inside a currency's value range where the winner
|
|
281
289
|
// stops winning, varying one currency at a time. Returns are linear in the
|
|
282
290
|
// cents-per-point value, so each pair of cards flips at most once.
|
|
283
|
-
// "Chase Ultimate Rewards points", but not "IHG One Rewards points points".
|
|
284
|
-
const pointsName = (label) => /\b(points|miles)$/i.test(label) ? label : `${label} points`;
|
|
285
291
|
function sensitivity(catalog, entries) {
|
|
286
292
|
const [winner, ...rest] = entries;
|
|
287
293
|
if (!winner)
|
|
@@ -330,7 +336,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
330
336
|
const rule = e.evaluation.best.rule;
|
|
331
337
|
return rule &&
|
|
332
338
|
merchantFamily(catalog, merchant.merchantId).some((id) => (rule.match.merchants ?? []).includes(id))
|
|
333
|
-
? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
|
|
339
|
+
? [`${displayName(e)} (${ruleWhat(catalog, rule, e.card)})`]
|
|
334
340
|
: [];
|
|
335
341
|
});
|
|
336
342
|
assumptions.push(naming.length === 0
|
|
@@ -389,9 +395,12 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
389
395
|
const winner = entries[0];
|
|
390
396
|
const warnings = [];
|
|
391
397
|
const notes = [];
|
|
392
|
-
const charging = entries.filter((e) => e.fee > 0);
|
|
398
|
+
const charging = entries.filter((e) => e.fee > 0 && e.card.foreign_transaction_fee !== null);
|
|
399
|
+
const assumed = entries.filter((e) => e.fee > 0 && e.card.foreign_transaction_fee === null);
|
|
393
400
|
if (charging.length > 0 && entries.length > 1)
|
|
394
401
|
notes.push(`${listed(charging.map((e) => `${displayName(e)} (${pct(e.fee)})`))} ${charging.length === 1 ? 'charges a foreign transaction fee' : 'charge foreign transaction fees'} here, already subtracted in the ranking.`);
|
|
402
|
+
if (assumed.length > 0 && entries.length > 1)
|
|
403
|
+
notes.push(`${listed(assumed.map(displayName))} ${assumed.length === 1 ? 'has' : 'have'} no foreign transaction fee on file, so the usual ${pct(ASSUMED_FOREIGN_FEE)} is subtracted.`);
|
|
395
404
|
if (merchant.source === 'none') {
|
|
396
405
|
warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply. If you know what kind of business it is, give its category (like dining or grocery) to find bonus rates.`);
|
|
397
406
|
}
|
|
@@ -453,7 +462,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
453
462
|
const gap = card ? rotationGap(card, purchase.date) : null;
|
|
454
463
|
if (card && gap !== null) {
|
|
455
464
|
const name = held.checkout_label ?? card.name;
|
|
456
|
-
warnings.push(`${name}
|
|
465
|
+
warnings.push(`${possessive(name)} rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them. If this purchase is in that period's categories, ${name} may earn more. An earnrate update adds them once the issuer announces them.`);
|
|
457
466
|
}
|
|
458
467
|
}
|
|
459
468
|
for (const id of merchant.bookedVia) {
|
|
@@ -477,9 +486,13 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
477
486
|
const ties = !isBest && gap <= EPSILON;
|
|
478
487
|
const bonus = open && (open.behind || ties) ? open : null;
|
|
479
488
|
const best = winner?.effectiveReturnPct ?? 0;
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
489
|
+
const toGo = open
|
|
490
|
+
? `${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}`
|
|
491
|
+
: '';
|
|
492
|
+
if (open && isBest && !bonus)
|
|
493
|
+
notes.push(`${displayName(open.entry)} is the best card here anyway, and this counts toward its welcome bonus (${toGo}).`);
|
|
494
|
+
else if (open && !bonus && isClose(open.entry.effectiveReturnPct, best))
|
|
495
|
+
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 (${toGo}). You're on pace, so this uses the higher rate.`);
|
|
483
496
|
const chosen = bonus?.entry ?? winner;
|
|
484
497
|
// A tie among the top cards doesn't matter when a welcome bonus decides.
|
|
485
498
|
const tie = bonus ? null : tieNote(entries);
|
|
@@ -516,7 +529,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
516
529
|
const worth = status.value === null ? '' : `, worth about ${wholeDollars(status.value)}`;
|
|
517
530
|
notes.unshift(ties
|
|
518
531
|
? `${displayName(bonus.entry)} earns as much here as any of your cards, so it's the pick: it helps its welcome bonus, ${money(remaining)} more to spend by ${deadline}${worth}.`
|
|
519
|
-
: `You're behind pace on ${displayName(bonus.entry)}
|
|
532
|
+
: `You're behind pace on ${possessive(displayName(bonus.entry))} welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
|
|
520
533
|
? ''
|
|
521
534
|
: costHere === null
|
|
522
535
|
? ` It earns ${pct(top.effectiveReturnPct)} here against ${pct(first.effectiveReturnPct)} on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
|
package/dist/engine/record.js
CHANGED
|
@@ -80,7 +80,7 @@ export function applyBonusBooking(wallet, b) {
|
|
|
80
80
|
}
|
|
81
81
|
// Every purchase on a card counts toward each of its spend milestones. None
|
|
82
82
|
// for a cardmember-year milestone on a card with no anniversary.
|
|
83
|
-
|
|
83
|
+
function milestoneBookings(catalog, wallet, purchase) {
|
|
84
84
|
const held = wallet.cards.find((c) => c.id === purchase.cardId);
|
|
85
85
|
const card = catalog.cards.find((c) => c.id === purchase.cardId);
|
|
86
86
|
if (!held || !card)
|
|
@@ -96,6 +96,7 @@ export function milestoneBookings(catalog, wallet, purchase) {
|
|
|
96
96
|
spend: p.milestone.spend,
|
|
97
97
|
before: p.spent,
|
|
98
98
|
after: roundCents(Math.max(0, p.spent + purchase.amount)),
|
|
99
|
+
started: !p.tracked,
|
|
99
100
|
},
|
|
100
101
|
]);
|
|
101
102
|
}
|
package/dist/engine/rotation.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { periodKey } from "./periods.js";
|
|
2
|
+
import { possessive } from "./text.js";
|
|
2
3
|
// The period of the card's rotating categories that `date` falls in, when no
|
|
3
4
|
// rule in the data covers it yet; null when it's covered or the card doesn't
|
|
4
5
|
// rotate.
|
|
@@ -16,4 +17,4 @@ export function rotationGap(card, date) {
|
|
|
16
17
|
}
|
|
17
18
|
// What answers say about a gap: "Freedom Flex's rotating categories for
|
|
18
19
|
// 2027-Q2 aren't in earnrate's data yet. ..."
|
|
19
|
-
export const rotationGapText = (name, gap) => `${name}
|
|
20
|
+
export const rotationGapText = (name, gap) => `${possessive(name)} rotating categories for ${gap} aren't in earnrate's data yet. An earnrate update adds them once the issuer announces them.`;
|
package/dist/engine/rule-text.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { findMerchant } from "../merchants/resolve.js";
|
|
2
|
-
import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, words, } from "./text.js";
|
|
2
|
+
import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, PICK_PERIODS, 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.
|
|
6
|
-
const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
|
|
6
|
+
const BOOKING = /^(Activity|Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
|
|
7
|
+
const plural = (kind) => kind === 'Activity' ? 'activities' : `${kind.toLowerCase()}s`;
|
|
7
8
|
function merchantName(catalog, id) {
|
|
8
9
|
const merchant = findMerchant(catalog, id);
|
|
9
10
|
const name = merchant?.name ?? words(id);
|
|
10
|
-
return merchant?.kind === 'booking'
|
|
11
|
-
? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
|
|
12
|
-
: name;
|
|
11
|
+
return merchant?.kind === 'booking' ? name.replace(BOOKING, plural) : name;
|
|
13
12
|
}
|
|
14
13
|
export function optionName(catalog, card, choiceId, option) {
|
|
15
14
|
const o = card.choices.find((c) => c.id === choiceId)?.options[option];
|
|
@@ -23,51 +22,99 @@ export function optionName(catalog, card, choiceId, option) {
|
|
|
23
22
|
parts.push('online shopping');
|
|
24
23
|
return parts.length > 0 ? listed(parts) : words(option);
|
|
25
24
|
}
|
|
26
|
-
// What a rule covers, as a list of phrases.
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
// What a rule covers, as a list of phrases. With the card, a pick reads
|
|
26
|
+
// "the 2 categories you pick each quarter" and a rule for every purchase reads
|
|
27
|
+
// "every purchase" when it's the card's only one.
|
|
28
|
+
export function ruleParts(catalog, rule, card, { everyStore = false } = {}) {
|
|
29
|
+
const m = rule.match;
|
|
30
|
+
if (m.any)
|
|
29
31
|
return [
|
|
30
32
|
rule.geography === 'foreign_only'
|
|
31
33
|
? 'purchases outside the U.S.'
|
|
32
34
|
: rule.requires?.payment !== undefined
|
|
33
35
|
? 'purchases'
|
|
34
|
-
:
|
|
36
|
+
: card?.rules.every((r) => r.match.any)
|
|
37
|
+
? 'every purchase'
|
|
38
|
+
: 'everything else',
|
|
35
39
|
];
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
if (m.choice !== undefined) {
|
|
41
|
+
const choice = card?.choices.find((c) => c.id === m.choice);
|
|
42
|
+
if (!choice)
|
|
43
|
+
return [`your ${choiceName(m.choice)} picks`];
|
|
44
|
+
const which = choice.pick === 1
|
|
45
|
+
? 'the category'
|
|
46
|
+
: `the ${String(choice.pick)} categories`;
|
|
47
|
+
return [
|
|
48
|
+
`${which} you pick${choice.period === 'until_changed' ? '' : ` ${PICK_PERIODS[choice.period]}`}`,
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
const merchants = m.merchants ?? [];
|
|
52
|
+
const categories = (m.categories ?? []).map((c) => categoryPhrase(catalog, c));
|
|
53
|
+
const merchantParts = (everyStore ? null : shortenedMerchants(catalog, merchants)) ??
|
|
54
|
+
merchants.map((id) => merchantName(catalog, id));
|
|
55
|
+
// A rule naming both matches a purchase that is both.
|
|
56
|
+
if (merchants.length > 0 && categories.length > 0)
|
|
57
|
+
return [`${listed(merchantParts)} when it codes as ${listed(categories)}`];
|
|
58
|
+
const parts = [...categories, ...(merchants.length > 0 ? merchantParts : [])];
|
|
59
|
+
if (parts.length > 0)
|
|
60
|
+
return parts;
|
|
61
|
+
return m.mccs?.length
|
|
62
|
+
? [`purchases coded MCC ${m.mccs.map(String).join(', ')}`]
|
|
63
|
+
: [words(rule.id)];
|
|
64
|
+
}
|
|
65
|
+
// A long list of mostly one kind of store reads as "select streaming
|
|
66
|
+
// services": Amex's streaming list also names Audible, which isn't one.
|
|
67
|
+
// Null when the list is read out in full.
|
|
68
|
+
function shortenedKind(catalog, merchants) {
|
|
41
69
|
const counts = new Map();
|
|
42
|
-
for (const
|
|
43
|
-
const category = findMerchant(catalog,
|
|
70
|
+
for (const id of merchants) {
|
|
71
|
+
const category = findMerchant(catalog, id)?.category;
|
|
44
72
|
if (category)
|
|
45
73
|
counts.set(category, (counts.get(category) ?? 0) + 1);
|
|
46
74
|
}
|
|
47
75
|
const [kind, count = 0] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
|
|
48
|
-
|
|
76
|
+
return merchants.length > 4 &&
|
|
49
77
|
kind !== undefined &&
|
|
50
78
|
count >= merchants.length * 0.75
|
|
51
|
-
?
|
|
52
|
-
:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
];
|
|
57
|
-
return parts.length > 0 ? parts : [words(rule.id)];
|
|
79
|
+
? kind
|
|
80
|
+
: null;
|
|
81
|
+
}
|
|
82
|
+
export function shortenedMerchants(catalog, merchants) {
|
|
83
|
+
const kind = shortenedKind(catalog, merchants);
|
|
84
|
+
return kind === null ? null : [`select ${categoryPhrase(catalog, kind)}`];
|
|
58
85
|
}
|
|
59
|
-
export function ruleWhat(catalog, rule) {
|
|
86
|
+
export function ruleWhat(catalog, rule, card, options = {}) {
|
|
60
87
|
const payment = rule.requires?.payment;
|
|
61
|
-
const what = listed(ruleParts(catalog, rule));
|
|
88
|
+
const what = listed(ruleParts(catalog, rule, card, options));
|
|
62
89
|
return payment === undefined ? what : `${what} ${PAYMENT_PHRASES[payment]}`;
|
|
63
90
|
}
|
|
91
|
+
// "on dining", "on your picks", "at Costco", "on Walmart+ membership": a
|
|
92
|
+
// category's own preposition, "at" for a store unless its data says "on" or
|
|
93
|
+
// it's a booking through a travel site ("on hotels through Amex Travel"), and
|
|
94
|
+
// a shortened list ("select streaming services") reads like its kind.
|
|
95
|
+
export function rulePreposition(catalog, rules) {
|
|
96
|
+
const readsOn = (r) => {
|
|
97
|
+
if (r.match.any === true || r.match.choice !== undefined)
|
|
98
|
+
return true;
|
|
99
|
+
const merchants = r.match.merchants ?? [];
|
|
100
|
+
const kind = shortenedKind(catalog, merchants);
|
|
101
|
+
return ((r.match.categories ?? []).every((c) => categoryReadsOn(catalog, c)) &&
|
|
102
|
+
(kind !== null
|
|
103
|
+
? categoryReadsOn(catalog, kind)
|
|
104
|
+
: merchants.every((id) => {
|
|
105
|
+
const merchant = findMerchant(catalog, id);
|
|
106
|
+
return (merchant?.preposition === 'on' || merchant?.kind === 'booking');
|
|
107
|
+
})));
|
|
108
|
+
};
|
|
109
|
+
return rules.length > 0 && rules.every(readsOn) ? 'on' : 'at';
|
|
110
|
+
}
|
|
64
111
|
// "double every rate for the first year", "an extra 3% cash back on your 3%
|
|
65
112
|
// picks for the first year".
|
|
66
113
|
export function introWhat(catalog, card, intro) {
|
|
67
114
|
const covered = card.rules.filter((r) => intro.rules?.includes(r.id));
|
|
68
115
|
const on = intro.rules === undefined
|
|
69
116
|
? 'every rate'
|
|
70
|
-
: [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join('; ');
|
|
117
|
+
: [...new Set(covered.map((r) => ruleWhat(catalog, r, card)))].join('; ');
|
|
71
118
|
const change = intro.multiply === undefined
|
|
72
119
|
? `an extra ${earnText(catalog, intro.add ?? 0, card.currency)} on ${on}`
|
|
73
120
|
: intro.multiply === 2
|
|
@@ -76,9 +123,27 @@ export function introWhat(catalog, card, intro) {
|
|
|
76
123
|
return `${change} for the ${introPeriod(intro.days)}`;
|
|
77
124
|
}
|
|
78
125
|
// What a merchant credit is named for: "Lululemon", "Disney+, Hulu and ESPN".
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
]
|
|
83
|
-
|
|
84
|
-
|
|
126
|
+
// Bookings through a travel site the credit also names go without saying:
|
|
127
|
+
// "Capital One Travel", not each kind of booking through it.
|
|
128
|
+
export function creditName(catalog, credit) {
|
|
129
|
+
const ids = credit.match.merchants ?? [];
|
|
130
|
+
const portals = ids
|
|
131
|
+
.map((m) => findMerchant(catalog, m))
|
|
132
|
+
.filter((m) => m?.kind === 'portal')
|
|
133
|
+
.map((m) => ` through ${m?.name ?? ''}`);
|
|
134
|
+
const merchants = ids.filter((m) => {
|
|
135
|
+
const merchant = findMerchant(catalog, m);
|
|
136
|
+
return !(merchant?.kind === 'booking' &&
|
|
137
|
+
portals.some((p) => merchant.name.endsWith(p)));
|
|
138
|
+
});
|
|
139
|
+
return listed([
|
|
140
|
+
...merchants.map((m) => merchantName(catalog, m)),
|
|
141
|
+
...(credit.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
142
|
+
]);
|
|
143
|
+
}
|
|
144
|
+
// "at Lululemon", "at restaurants", "on travel", "on Walmart+ membership":
|
|
145
|
+
// the category's or merchant's own preposition.
|
|
146
|
+
export const creditWhere = (catalog, credit) => `${(credit.match.categories ?? []).some((c) => categoryReadsOn(catalog, c)) ||
|
|
147
|
+
(credit.match.merchants ?? []).some((m) => findMerchant(catalog, m)?.preposition === 'on')
|
|
148
|
+
? 'on'
|
|
149
|
+
: 'at'} ${creditName(catalog, credit)}`;
|
package/dist/engine/setup.js
CHANGED
|
@@ -4,7 +4,7 @@ import { milestoneWhat } from "./milestones.js";
|
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
5
|
import { rotationGap, rotationGapText } from "./rotation.js";
|
|
6
6
|
import { creditName, creditWhere, introWhat } from "./rule-text.js";
|
|
7
|
-
import { creditBack, ENROLL_FIRST, idList, listed } from "./text.js";
|
|
7
|
+
import { creditBack, ENROLL_FIRST, idList, listed, possessive } from "./text.js";
|
|
8
8
|
// Rules that start within this many days are worth activating now.
|
|
9
9
|
const ACTIVATION_LOOKAHEAD_DAYS = 45;
|
|
10
10
|
const searchWords = (s) => s
|
|
@@ -77,6 +77,10 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
77
77
|
network: card.network,
|
|
78
78
|
currency: card.currency,
|
|
79
79
|
annualFee: card.annual_fee ?? null,
|
|
80
|
+
firstYearFee: card.first_year_fee !== undefined &&
|
|
81
|
+
card.first_year_fee !== card.annual_fee
|
|
82
|
+
? card.first_year_fee
|
|
83
|
+
: null,
|
|
80
84
|
foreignTransactionFee: card.foreign_transaction_fee,
|
|
81
85
|
business: card.business,
|
|
82
86
|
onlyAt: card.only_at ?? null,
|
|
@@ -168,7 +172,7 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
168
172
|
notes.push({
|
|
169
173
|
kind: 'pick',
|
|
170
174
|
text: choice.default && picked.has(choice.id)
|
|
171
|
-
? `${name}
|
|
175
|
+
? `${possessive(name)} ${choice.id} pick is assumed to be ${idList(choice.default)}, the default. Set it if a different one was picked (${String(choice.pick)} of: ${options}).`
|
|
172
176
|
: `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
|
|
173
177
|
});
|
|
174
178
|
}
|
|
@@ -177,7 +181,7 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
177
181
|
if (missing.length > 0) {
|
|
178
182
|
notes.push({
|
|
179
183
|
kind: 'membership',
|
|
180
|
-
text: `Some of ${name}
|
|
184
|
+
text: `Some of ${possessive(name)} rates need ${listed(missing.map((m) => `${membershipName(catalog, m)} (${m})`), 'or')}; they're counted without it.`,
|
|
181
185
|
});
|
|
182
186
|
}
|
|
183
187
|
const toActivate = activationRules(card, today).filter((id) => !isActivated(card, held, id));
|
package/dist/engine/simulate.js
CHANGED
|
@@ -6,7 +6,7 @@ import { choicePeriod, periodKey } from "./periods.js";
|
|
|
6
6
|
import { applyBooking, capBooking } from "./record.js";
|
|
7
7
|
import { milestoneWhat } from "./milestones.js";
|
|
8
8
|
import { rotationGap } from "./rotation.js";
|
|
9
|
-
import { money, paymentNote, roundCents, wholeDollars } from "./text.js";
|
|
9
|
+
import { money, paymentNote, roundCents, wholeDollars, possessive, } from "./text.js";
|
|
10
10
|
import { cardValuation } from "./valuation.js";
|
|
11
11
|
import { EPSILON, HALF_CENT } from "./compare.js";
|
|
12
12
|
const MONTHS = 12;
|
|
@@ -164,13 +164,13 @@ held = []) {
|
|
|
164
164
|
...new Set(dates.map((d) => rotationGap(card, d)).filter((g) => g !== null)),
|
|
165
165
|
];
|
|
166
166
|
if (gaps.length > 0)
|
|
167
|
-
notes.push(`${card.name}
|
|
167
|
+
notes.push(`${possessive(card.name)} rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
|
|
168
168
|
if (card.annual_fee === undefined)
|
|
169
|
-
notes.push(`${card.name}
|
|
169
|
+
notes.push(`${possessive(card.name)} annual fee isn't on file, so none is subtracted.`);
|
|
170
170
|
else if (!held.includes(card.id) &&
|
|
171
171
|
card.first_year_fee !== undefined &&
|
|
172
172
|
card.first_year_fee !== card.annual_fee)
|
|
173
|
-
notes.push(`${card.name}
|
|
173
|
+
notes.push(`${possessive(card.name)} fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
|
|
174
174
|
}
|
|
175
175
|
return {
|
|
176
176
|
cards: wallet.cards.map((h) => ({
|
package/dist/engine/text.js
CHANGED
|
@@ -48,6 +48,11 @@ export function feeText(fees) {
|
|
|
48
48
|
return `${money(annualFee)} annual fee (${firstYearFee === 0 ? 'waived' : money(firstYearFee)} the first year)`;
|
|
49
49
|
return `${money(annualFee)} annual fee`;
|
|
50
50
|
}
|
|
51
|
+
export function heldName(catalog, held) {
|
|
52
|
+
return (held.checkout_label ??
|
|
53
|
+
catalog.cards.find((c) => c.id === held.id)?.name ??
|
|
54
|
+
held.id);
|
|
55
|
+
}
|
|
51
56
|
export function currencyName(catalog, id) {
|
|
52
57
|
return catalog.currencies.find((c) => c.id === id)?.name ?? id;
|
|
53
58
|
}
|
|
@@ -129,6 +134,20 @@ export function paymentNote(cards) {
|
|
|
129
134
|
}
|
|
130
135
|
// A name with its own "and" ("drugstores and pharmacies") would run into the
|
|
131
136
|
// list's: those lists use commas only, with those names last.
|
|
137
|
+
// "Up to $1,500 of spending a quarter, then 1% cash back"
|
|
138
|
+
export const capSentence = (cap, then, of = 'spending', aside = '') => `Up to ${wholeDollars(cap.amount)} of ${of} ${CAP_PERIODS[cap.period]}${aside}, then ${then}`;
|
|
139
|
+
// "a Costco membership", "an Instacart credit", "an IHG card", "a Uber credit":
|
|
140
|
+
// by sound, so a leading "you" or "won" takes "a" and a letter said with a
|
|
141
|
+
// vowel sound ("an REI") takes "an".
|
|
142
|
+
export function withArticle(name) {
|
|
143
|
+
const initialism = /^[A-Z][A-Z&.-]{1,5}(?![a-z])/.test(name);
|
|
144
|
+
const an = initialism
|
|
145
|
+
? /^[AEFHILMNORSX]/.test(name)
|
|
146
|
+
: /^[aeiou]/i.test(name) && !/^(u[bnst]|u\.s\.|eu|one\b|once\b)/i.test(name);
|
|
147
|
+
return `${an ? 'an' : 'a'} ${name}`;
|
|
148
|
+
}
|
|
149
|
+
// "Amex Platinum's", "Capital One Savor Rewards'".
|
|
150
|
+
export const possessive = (name) => name.endsWith('s') ? `${name}'` : `${name}'s`;
|
|
132
151
|
export const listed = (parts, joiner = 'and') => {
|
|
133
152
|
const anded = parts.filter((p) => p.includes(' and '));
|
|
134
153
|
if (anded.length > 0)
|
|
@@ -166,7 +185,7 @@ export const CAP_ADJECTIVES = {
|
|
|
166
185
|
calendar_year: 'calendar-year',
|
|
167
186
|
cardmember_year: 'cardmember-year',
|
|
168
187
|
};
|
|
169
|
-
// "the $15,000 calendar-year spend", for a spend
|
|
188
|
+
// "the $15,000 calendar-year spend", for a spend bonus.
|
|
170
189
|
export const MILESTONE_PERIODS = {
|
|
171
190
|
calendar_year: 'calendar-year',
|
|
172
191
|
cardmember_year: 'cardmember-year',
|
|
@@ -187,8 +206,8 @@ export const introSince = (days) => days === 365 ? 'in the last year' : `in the
|
|
|
187
206
|
export const introRateText = (earn, standard, days, until) => `${earn} in your ${introPeriod(days)}, through ${shortDate(until)}; ${standard} after that.`;
|
|
188
207
|
// For a card whose opening date isn't known: it's never assumed to be new.
|
|
189
208
|
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)}.`;
|
|
190
|
-
export const introEndsText = (name, days, until) => `${name}
|
|
191
|
-
//
|
|
209
|
+
export const introEndsText = (name, days, until) => `${possessive(name)} ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
|
|
210
|
+
// Statement credits. Always dollars, never a percentage.
|
|
192
211
|
export const CREDIT_PERIODS = {
|
|
193
212
|
month: 'each month',
|
|
194
213
|
quarter: 'each quarter',
|
|
@@ -205,7 +224,7 @@ export const CREDIT_THIS_PERIOD = {
|
|
|
205
224
|
};
|
|
206
225
|
export const ENROLL_FIRST = 'enroll first';
|
|
207
226
|
// The group of cards with a credit at the store, shown before the ranking.
|
|
208
|
-
export const CREDIT_GROUP = 'With a
|
|
227
|
+
export const CREDIT_GROUP = 'With a statement credit';
|
|
209
228
|
// "$75 back each quarter"
|
|
210
229
|
export const creditBack = (c) => `${money(c.amount)} back ${CREDIT_PERIODS[c.period]}`;
|
|
211
230
|
// "$75 back each quarter, then 1x Amex Membership Rewards"
|
|
@@ -216,9 +235,9 @@ export const creditLeft = (left, period) => `${money(left)} left ${CREDIT_THIS_P
|
|
|
216
235
|
export function creditAnswerText(c, dollars) {
|
|
217
236
|
const credit = `${c.name} credit (${creditBack(c)})`;
|
|
218
237
|
if (!c.enrolled)
|
|
219
|
-
return `Has
|
|
238
|
+
return `Has ${withArticle(credit)} here; ${ENROLL_FIRST} with the issuer, then mark it enrolled in your wallet. It isn't counted until then.`;
|
|
220
239
|
if (c.left === null)
|
|
221
|
-
return `Has
|
|
240
|
+
return `Has ${withArticle(credit)} here; give the card's anniversary to track it.`;
|
|
222
241
|
if (c.left <= 0)
|
|
223
242
|
return `Its ${c.name} credit is used up ${CREDIT_THIS_PERIOD[c.period]}${c.until === null ? '' : `; it renews after ${shortDate(c.until)}`}.`;
|
|
224
243
|
if (dollars === null)
|
|
@@ -243,6 +262,8 @@ export function creditShort(c, dollars) {
|
|
|
243
262
|
// For the to-do list: "Use your $75 lululemon credit on Amex Platinum by Dec
|
|
244
263
|
// 31, 2026: $35 left."
|
|
245
264
|
export const useCreditText = (card, c, left) => `Use your ${money(c.amount)} ${c.name} credit on ${card}${c.until === null ? '' : ` by ${shortDate(c.until)}`}: ${money(left)} left.`;
|
|
246
|
-
export const enrollCreditText = (card,
|
|
265
|
+
export const enrollCreditText = (card, names) => names.length === 1
|
|
266
|
+
? `Enroll in ${possessive(card)} ${names[0] ?? ''} credit with the issuer, then mark it enrolled.`
|
|
267
|
+
: `Enroll in ${possessive(card)} ${listed(names)} credits with the issuer, then mark them enrolled.`;
|
|
247
268
|
// Simulations, the cheat sheet and welcome-offer comparisons.
|
|
248
|
-
export const CREDITS_LEFT_OUT = '
|
|
269
|
+
export const CREDITS_LEFT_OUT = 'Statement credits (dollars back at a named store) are left out.';
|
|
@@ -134,15 +134,15 @@ export function cardFields(card, held, input, today) {
|
|
|
134
134
|
const milestone = card.milestones.find((m) => m.id === id);
|
|
135
135
|
if (!milestone) {
|
|
136
136
|
problems.push(card.milestones.length > 0
|
|
137
|
-
? `${card.id} has no spend
|
|
138
|
-
: `${card.id} has no spend
|
|
137
|
+
? `${card.id} has no spend bonus '${id}'. Its milestones are: ${idList(card.milestones.map((m) => m.id))}.`
|
|
138
|
+
: `${card.id} has no spend bonuses.`);
|
|
139
139
|
continue;
|
|
140
140
|
}
|
|
141
141
|
if (!(spent >= 0))
|
|
142
|
-
problems.push('Spending toward a
|
|
142
|
+
problems.push('Spending toward a spend bonus must be $0 or more.');
|
|
143
143
|
const period = periodKey(milestone.period, today, anniversary);
|
|
144
144
|
if (period === null) {
|
|
145
|
-
problems.push(`The '${id}'
|
|
145
|
+
problems.push(`The '${id}' spend bonus resets on the card's anniversary; give the anniversary (MM-DD) too.`);
|
|
146
146
|
continue;
|
|
147
147
|
}
|
|
148
148
|
usage[id] = { period, spent };
|
|
@@ -169,7 +169,7 @@ export function cardFields(card, held, input, today) {
|
|
|
169
169
|
if (!credit) {
|
|
170
170
|
problems.push(card.credits.length > 0
|
|
171
171
|
? `${card.id} has no credit '${id}'. Its credits are: ${idList(card.credits.map((c) => c.id))}.`
|
|
172
|
-
: `${card.id} has no
|
|
172
|
+
: `${card.id} has no statement credits.`);
|
|
173
173
|
continue;
|
|
174
174
|
}
|
|
175
175
|
if (!(used >= 0 && used <= credit.amount))
|