earnrate 0.2.11 → 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 +37 -12
- package/data/CHANGELOG.md +291 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +1 -0
- package/data/cards/amex-blue-cash-everyday.yaml +29 -2
- package/data/cards/amex-blue-cash-preferred.yaml +26 -0
- package/data/cards/amex-business-gold.yaml +169 -0
- package/data/cards/amex-business-platinum.yaml +184 -0
- package/data/cards/amex-delta-skymiles-blue.yaml +91 -0
- package/data/cards/amex-delta-skymiles-gold.yaml +29 -1
- package/data/cards/amex-delta-skymiles-platinum.yaml +35 -1
- package/data/cards/amex-delta-skymiles-reserve.yaml +35 -1
- package/data/cards/amex-gold.yaml +53 -0
- package/data/cards/amex-hilton-honors-aspire.yaml +25 -1
- package/data/cards/amex-hilton-honors-business.yaml +106 -0
- package/data/cards/amex-hilton-honors-surpass.yaml +16 -1
- package/data/cards/amex-hilton-honors.yaml +2 -0
- package/data/cards/amex-marriott-bonvoy-bevy.yaml +2 -0
- package/data/cards/amex-marriott-bonvoy-brilliant.yaml +17 -1
- package/data/cards/amex-marriott-bonvoy-business.yaml +116 -0
- package/data/cards/amex-platinum.yaml +111 -0
- package/data/cards/apple-card.yaml +76 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +1 -0
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +2 -0
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +1 -0
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +34 -1
- package/data/cards/bank-of-america-premium-rewards.yaml +134 -0
- package/data/cards/bank-of-america-travel-rewards.yaml +123 -0
- package/data/cards/bank-of-america-unlimited-cash-rewards.yaml +83 -0
- package/data/cards/barclays-jetblue-plus.yaml +86 -0
- package/data/cards/bilt-blue.yaml +60 -0
- package/data/cards/bilt-obsidian.yaml +13 -2
- package/data/cards/bilt-palladium.yaml +86 -0
- package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
- package/data/cards/capital-one-kohls-card.yaml +1 -0
- package/data/cards/capital-one-kohls-rewards-visa.yaml +1 -0
- package/data/cards/capital-one-quicksilver.yaml +2 -0
- package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -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 +124 -0
- package/data/cards/capital-one-venture-x.yaml +32 -2
- package/data/cards/capital-one-venture.yaml +2 -0
- package/data/cards/chase-aeroplan.yaml +16 -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 +28 -1
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +18 -0
- package/data/cards/chase-ihg-one-rewards-premier.yaml +16 -0
- package/data/cards/chase-ihg-one-rewards.yaml +1 -0
- package/data/cards/chase-ink-business-cash.yaml +14 -0
- package/data/cards/chase-ink-business-preferred.yaml +1 -0
- package/data/cards/chase-ink-business-unlimited.yaml +80 -0
- package/data/cards/chase-marriott-bonvoy-bold.yaml +1 -0
- package/data/cards/chase-marriott-bonvoy-boundless.yaml +2 -0
- package/data/cards/chase-prime-visa.yaml +1 -0
- package/data/cards/chase-sapphire-preferred.yaml +12 -0
- package/data/cards/chase-sapphire-reserve.yaml +45 -4
- 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 +33 -0
- package/data/cards/chase-united-club-business.yaml +41 -0
- package/data/cards/chase-united-club.yaml +41 -0
- package/data/cards/chase-united-explorer.yaml +33 -0
- package/data/cards/chase-united-gateway.yaml +1 -0
- package/data/cards/chase-united-quest.yaml +41 -0
- package/data/cards/citi-aadvantage-business.yaml +2 -0
- package/data/cards/citi-aadvantage-executive.yaml +28 -1
- package/data/cards/citi-aadvantage-globe.yaml +2 -0
- package/data/cards/citi-aadvantage-mileup.yaml +2 -0
- package/data/cards/citi-aadvantage-platinum-select.yaml +2 -0
- package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
- package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
- package/data/cards/citi-custom-cash.yaml +1 -0
- package/data/cards/citi-double-cash.yaml +1 -0
- package/data/cards/citi-my-best-buy-visa.yaml +1 -0
- package/data/cards/citi-strata-elite.yaml +21 -1
- package/data/cards/citi-strata-premier.yaml +1 -0
- package/data/cards/citi-strata.yaml +164 -0
- package/data/cards/discover-it-cash-back.yaml +22 -3
- package/data/cards/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 +52 -0
- package/data/cards/penfed-power-cash-rewards.yaml +47 -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 +156 -0
- package/data/cards/usbank-altitude-go.yaml +124 -0
- package/data/cards/usbank-cash-plus.yaml +1 -0
- 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/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 +48 -0
- package/data/issuers.yaml +44 -1
- package/data/memberships.yaml +45 -0
- package/data/merchants/overrides.yaml +947 -1
- package/data/merchants/seed.yaml +1916 -62
- package/dist/cli/args.js +18 -0
- package/dist/cli/explore.js +10 -1
- package/dist/cli/import.js +2 -2
- package/dist/cli/run.js +4 -1
- package/dist/cli/wallet.js +37 -3
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +59 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +28 -7
- package/dist/engine/cheat-sheet.js +17 -5
- package/dist/engine/checklist.js +38 -6
- package/dist/engine/credits.js +65 -0
- package/dist/engine/evaluate.js +57 -18
- package/dist/engine/explore.js +70 -7
- package/dist/engine/intro.js +36 -0
- package/dist/engine/offers.js +2 -1
- package/dist/engine/periods.js +10 -3
- package/dist/engine/recommend.js +101 -20
- package/dist/engine/record.js +43 -1
- package/dist/engine/rotation.js +3 -0
- package/dist/engine/rule-text.js +28 -3
- package/dist/engine/setup.js +20 -3
- package/dist/engine/simulate.js +10 -1
- package/dist/engine/text.js +107 -0
- package/dist/engine/wallet-change.js +57 -4
- package/dist/engine/wallet-summary.js +54 -6
- package/dist/format/card-art.js +21 -0
- package/dist/format/card-text.js +4 -3
- package/dist/format/cards.js +1 -0
- 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-yaml.js +1 -0
- package/dist/format/wallet.js +15 -5
- package/dist/import/analyze.js +8 -2
- package/dist/mcp/inputs.js +13 -0
- package/dist/mcp/prompts.js +9 -1
- package/dist/mcp/schemas.js +87 -1
- package/dist/mcp/tools/account.js +3 -1
- package/dist/mcp/tools/answers.js +9 -4
- package/dist/mcp/tools/wallet.js +20 -8
- package/dist/merchants/resolve.js +8 -0
- package/dist/schema/card.js +60 -3
- package/dist/schema/common.js +1 -1
- package/dist/schema/issuer.js +4 -0
- package/dist/schema/merchant.js +3 -0
- package/dist/schema/wallet.js +11 -0
- package/package.json +12 -3
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/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/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
|
@@ -14,8 +14,17 @@ 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.
|
|
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.
|
|
19
28
|
--bonus-opened <date> Track a welcome bonus: the day the account opened
|
|
20
29
|
(YYYY-MM-DD). Spending needed and the deadline
|
|
21
30
|
default to the card's published offer.
|
|
@@ -44,8 +53,8 @@ ${CARD_OPTIONS}
|
|
|
44
53
|
update: `Usage: ${CLI_NAME} update <card-id> [options]
|
|
45
54
|
|
|
46
55
|
Changes a card in your wallet: picks for this period, label, anniversary,
|
|
47
|
-
activations or welcome bonus. Picks for the
|
|
48
|
-
ones already set.
|
|
56
|
+
opening date, activations, merchant credits or welcome bonus. Picks for the
|
|
57
|
+
current period are merged with ones already set.
|
|
49
58
|
|
|
50
59
|
${CARD_OPTIONS}
|
|
51
60
|
`,
|
|
@@ -130,7 +139,10 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
130
139
|
label: { type: 'string' },
|
|
131
140
|
pick: { type: 'string', multiple: true },
|
|
132
141
|
anniversary: { type: 'string' },
|
|
142
|
+
opened: { type: 'string' },
|
|
133
143
|
activated: { type: 'string' },
|
|
144
|
+
enrolled: { type: 'string' },
|
|
145
|
+
'credit-used': { type: 'string', multiple: true },
|
|
134
146
|
'bonus-opened': { type: 'string' },
|
|
135
147
|
'bonus-spent': { type: 'string' },
|
|
136
148
|
'bonus-spend': { type: 'string' },
|
|
@@ -158,7 +170,10 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
158
170
|
const cardOptionsGiven = values.label !== undefined ||
|
|
159
171
|
values.pick !== undefined ||
|
|
160
172
|
values.anniversary !== undefined ||
|
|
161
|
-
values.
|
|
173
|
+
values.opened !== undefined ||
|
|
174
|
+
values.activated !== undefined ||
|
|
175
|
+
values.enrolled !== undefined ||
|
|
176
|
+
values['credit-used'] !== undefined;
|
|
162
177
|
if (command !== 'add' && command !== 'update' && cardOptionsGiven) {
|
|
163
178
|
return usage(`${CLI_NAME} ${command} doesn't take card options.`);
|
|
164
179
|
}
|
|
@@ -236,6 +251,8 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
236
251
|
input.checkoutLabel = values.label;
|
|
237
252
|
if (values.anniversary !== undefined)
|
|
238
253
|
input.anniversary = values.anniversary;
|
|
254
|
+
if (values.opened !== undefined)
|
|
255
|
+
input.opened = values.opened;
|
|
239
256
|
if (values.activated !== undefined) {
|
|
240
257
|
input.activated = values.activated
|
|
241
258
|
.split(',')
|
|
@@ -254,6 +271,23 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
254
271
|
const n = Number(text.replace(/[$,]/g, ''));
|
|
255
272
|
return Number.isFinite(n) ? n : `--${flag} takes dollars, like 1200.`;
|
|
256
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
|
+
}
|
|
257
291
|
if (values['no-bonus']) {
|
|
258
292
|
input.bonus = null;
|
|
259
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
|
@@ -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,8 @@ 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)],
|
|
117
|
+
['credit', card.credits.map((c) => c.id)],
|
|
90
118
|
]) {
|
|
91
119
|
for (const id of duplicates(ids))
|
|
92
120
|
problems.push(`${where}: duplicate ${kind} id '${id}'`);
|
|
@@ -95,6 +123,24 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
95
123
|
for (const m of card.milestones)
|
|
96
124
|
if (card.rules.some((r) => r.id === m.id))
|
|
97
125
|
problems.push(`${where}: milestone '${m.id}' has a rule's id`);
|
|
126
|
+
for (const intro of card.intro_rates) {
|
|
127
|
+
if (card.rules.some((r) => r.id === intro.id) ||
|
|
128
|
+
card.milestones.some((m) => m.id === intro.id))
|
|
129
|
+
problems.push(`${where}: intro rate '${intro.id}' has a rule's id`);
|
|
130
|
+
for (const id of intro.rules ?? [])
|
|
131
|
+
if (!card.rules.some((r) => r.id === id))
|
|
132
|
+
problems.push(`${where} intro rate ${intro.id}: unknown rule '${id}'`);
|
|
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
|
+
}
|
|
98
144
|
if (card.foreign_transaction_fee === null &&
|
|
99
145
|
!card.todo.some((t) => /foreign transaction fee/i.test(t)))
|
|
100
146
|
problems.push(`${where}: foreign_transaction_fee is null without a todo saying why`);
|
|
@@ -111,6 +157,8 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
111
157
|
...(card.requires_membership ? ['requires_membership'] : []),
|
|
112
158
|
...card.rules.map((r) => r.id),
|
|
113
159
|
...card.milestones.map((m) => m.id),
|
|
160
|
+
...card.intro_rates.map((i) => i.id),
|
|
161
|
+
...card.credits.map((c) => c.id),
|
|
114
162
|
]);
|
|
115
163
|
const sourcePages = new Set(card.source_urls.map((u) => u.split('#')[0]));
|
|
116
164
|
const backed = new Set(card.evidence.map((e) => e.rule));
|
|
@@ -205,6 +253,7 @@ export function catalogProblems(catalog) {
|
|
|
205
253
|
const { seed, overrides } = catalog.merchants;
|
|
206
254
|
problems.push(...merchantProblems('seed', seed, categories));
|
|
207
255
|
problems.push(...merchantProblems('overrides', overrides, categories));
|
|
256
|
+
problems.push(...lookupProblems(catalog));
|
|
208
257
|
const merchantIds = new Set([...seed, ...overrides].map((m) => m.id));
|
|
209
258
|
for (const m of [...seed, ...overrides]) {
|
|
210
259
|
for (const via of m.booked_via) {
|
|
@@ -212,6 +261,16 @@ export function catalogProblems(catalog) {
|
|
|
212
261
|
problems.push(`merchant ${m.id}: booked_via '${via}' is not a merchant`);
|
|
213
262
|
}
|
|
214
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
|
+
}
|
|
215
274
|
for (const m of catalog.memberships)
|
|
216
275
|
for (const id of m.members_shop_at)
|
|
217
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
|
|
@@ -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
|
}
|
|
@@ -39,6 +40,18 @@ function fieldLines(field, f, indent) {
|
|
|
39
40
|
return f.activated === undefined
|
|
40
41
|
? ''
|
|
41
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
|
+
}
|
|
42
55
|
case 'cap_usage': {
|
|
43
56
|
if (f.cap_usage === undefined)
|
|
44
57
|
return '';
|
|
@@ -86,10 +99,13 @@ function fieldLines(field, f, indent) {
|
|
|
86
99
|
const FIELDS = [
|
|
87
100
|
'checkout_label',
|
|
88
101
|
'anniversary',
|
|
102
|
+
'opened',
|
|
89
103
|
'chosen',
|
|
90
104
|
'activated',
|
|
91
105
|
'cap_usage',
|
|
92
106
|
'milestone_usage',
|
|
107
|
+
'enrolled',
|
|
108
|
+
'credit_usage',
|
|
93
109
|
'bonus',
|
|
94
110
|
];
|
|
95
111
|
function cardsSeq(text) {
|
|
@@ -279,9 +295,9 @@ function setValuationText(text, id, cents) {
|
|
|
279
295
|
const indent = ' '.repeat(column(text, range(first?.key)[0]));
|
|
280
296
|
return insertLines(text, afterLine(text, range(last?.value ?? last?.key)[1] - 1), `${indent}${line}\n`);
|
|
281
297
|
}
|
|
282
|
-
function flowUsage(period, spent) {
|
|
298
|
+
function flowUsage(period, spent, name = 'spent') {
|
|
283
299
|
const key = /^\d+$/.test(period) ? `"${period}"` : period;
|
|
284
|
-
return `{ period: ${key},
|
|
300
|
+
return `{ period: ${key}, ${name}: ${String(spent)} }`;
|
|
285
301
|
}
|
|
286
302
|
// Sets one entry of a card's cap_usage or milestone_usage, adding the map
|
|
287
303
|
// when the card has none.
|
|
@@ -322,9 +338,9 @@ function bonusSpentText(text, cardId, spent) {
|
|
|
322
338
|
const at = afterLine(text, range(bonus)[1] - 1);
|
|
323
339
|
return insertLines(text, at, `${indent}spent: ${value}\n`);
|
|
324
340
|
}
|
|
325
|
-
// Writes spending toward caps, welcome bonuses and spend milestones
|
|
326
|
-
// checked write.
|
|
327
|
-
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 = []) {
|
|
328
344
|
const { text, wallet } = await readWalletText(file);
|
|
329
345
|
if (text === null || wallet === null)
|
|
330
346
|
throw new Error(`No wallet at ${file}.`);
|
|
@@ -342,10 +358,15 @@ export async function writeProgress(file, caps, bonuses, milestones = []) {
|
|
|
342
358
|
after = bonusSpentText(after, b.cardId, b.after);
|
|
343
359
|
expected = applyBonusBooking(expected, b);
|
|
344
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
|
+
}
|
|
345
365
|
const byHand = [
|
|
346
366
|
...caps.map((b) => `${b.cardId} cap_usage ${b.capKey}: ${flowUsage(b.period, b.after)}`),
|
|
347
367
|
...bonuses.map((b) => `${b.cardId} bonus spent: ${String(b.after)}`),
|
|
348
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')}`),
|
|
349
370
|
].join('; ');
|
|
350
371
|
await commitWalletText(file, after, expected, `add it by hand: ${byHand}`);
|
|
351
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, 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) {
|
|
@@ -18,12 +19,12 @@ shortName) {
|
|
|
18
19
|
const name = s.checkoutLabel ?? shortName?.(card.id) ?? s.cardName;
|
|
19
20
|
for (const a of activationsDue(catalog, card, held, today)) {
|
|
20
21
|
const starts = a.from !== null && a.from > today
|
|
21
|
-
?
|
|
22
|
+
? ` (it starts ${shortDate(a.from)})`
|
|
22
23
|
: '';
|
|
23
24
|
items.push({
|
|
24
25
|
kind: 'activate',
|
|
25
26
|
cardId: card.id,
|
|
26
|
-
text: `Activate ${name}'s bonus
|
|
27
|
+
text: `Activate ${name}'s bonus ${a.preposition} ${a.what}${a.activateBy === null ? '' : ` by ${shortDate(a.activateBy)}`}${starts}.`,
|
|
27
28
|
due: a.activateBy ?? (a.from !== null && a.from > today ? a.from : a.to),
|
|
28
29
|
});
|
|
29
30
|
}
|
|
@@ -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);
|
|
@@ -103,6 +124,17 @@ shortName) {
|
|
|
103
124
|
due: b.deadline,
|
|
104
125
|
});
|
|
105
126
|
}
|
|
127
|
+
const quarterEnd = currentPeriodEnd('quarter', today, null);
|
|
128
|
+
if (s.intro?.state === 'active' &&
|
|
129
|
+
s.intro.until !== null &&
|
|
130
|
+
quarterEnd !== null &&
|
|
131
|
+
s.intro.until <= quarterEnd)
|
|
132
|
+
items.push({
|
|
133
|
+
kind: 'intro',
|
|
134
|
+
cardId: card.id,
|
|
135
|
+
text: introEndsText(name, s.intro.days, s.intro.until),
|
|
136
|
+
due: s.intro.until,
|
|
137
|
+
});
|
|
106
138
|
if (s.rotationGap !== null) {
|
|
107
139
|
items.push({
|
|
108
140
|
kind: 'rotation',
|
|
@@ -116,7 +148,7 @@ shortName) {
|
|
|
116
148
|
a.cardId.localeCompare(b.cardId));
|
|
117
149
|
}
|
|
118
150
|
// An all-day calendar event for each dated action (activations, picks,
|
|
119
|
-
// 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
|
|
120
152
|
// device: nothing is sent anywhere.
|
|
121
153
|
export function checklistCalendar(items, stamp) {
|
|
122
154
|
const escape = (t) => t.replace(/\\/g, '\\\\').replace(/[,;]/g, (c) => `\\${c}`);
|
|
@@ -138,7 +170,7 @@ export function checklistCalendar(items, stamp) {
|
|
|
138
170
|
const due = i.due ?? '';
|
|
139
171
|
return [
|
|
140
172
|
'BEGIN:VEVENT',
|
|
141
|
-
`UID:${i.kind}-${i.cardId}-${due}@earnrate.dev`,
|
|
173
|
+
`UID:${i.kind}-${i.cardId}${i.ref === undefined ? '' : `-${i.ref}`}-${due}@earnrate.dev`,
|
|
142
174
|
`DTSTAMP:${stamp}`,
|
|
143
175
|
`DTSTART;VALUE=DATE:${day(due)}`,
|
|
144
176
|
`DTEND;VALUE=DATE:${day(next(due))}`,
|