diff --git a/client/App.jsx b/client/App.jsx index d7cffd7..700706e 100644 --- a/client/App.jsx +++ b/client/App.jsx @@ -32,6 +32,7 @@ const CalendarPage = lazy(() => import('@/pages/CalendarPage')); const SummaryPage = lazy(() => import('@/pages/SummaryPage')); const BillsPage = lazy(() => import('@/pages/BillsPage')); const SubscriptionsPage = lazy(() => import('@/pages/SubscriptionsPage')); +const SubscriptionCatalogPage = lazy(() => import('@/pages/SubscriptionCatalogPage')); const CategoriesPage = lazy(() => import('@/pages/CategoriesPage')); const SettingsPage = lazy(() => import('@/pages/SettingsPage')); const StatusPage = lazy(() => import('@/pages/StatusPage')); @@ -207,6 +208,7 @@ export default function App() { }>} /> }>} /> }>} /> + }>} /> }>} /> }>} /> }>} /> diff --git a/client/components/SubscriptionCatalogSection.jsx b/client/components/SubscriptionCatalogSection.jsx index 95f282e..271f725 100644 --- a/client/components/SubscriptionCatalogSection.jsx +++ b/client/components/SubscriptionCatalogSection.jsx @@ -394,7 +394,9 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete const data = await api.subscriptionCatalog(); setCatalog(data.catalog || []); } catch (err) { - setError(err.message || 'Failed to load catalog'); + const message = err.message || 'Failed to load catalog'; + setError(message); + toast.error(message); } finally { setLoading(false); } @@ -530,9 +532,9 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete return ( - Known Services + Known Service Catalog - Popular subscriptions and services. Ones you're already tracking appear at the top. + Popular services, linked bills, and custom bank descriptors used to improve matching. {/* Category filter chips */} diff --git a/client/pages/SubscriptionCatalogPage.jsx b/client/pages/SubscriptionCatalogPage.jsx new file mode 100644 index 0000000..6c2601c --- /dev/null +++ b/client/pages/SubscriptionCatalogPage.jsx @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { ArrowLeft, Link2, RefreshCw } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { Button } from '@/components/ui/button'; +import BillModal from '@/components/BillModal'; +import SubscriptionCatalogSection from '@/components/SubscriptionCatalogSection'; + +export default function SubscriptionCatalogPage() { + const [catalogKey, setCatalogKey] = useState(0); + const [subscriptions, setSubscriptions] = useState([]); + const [categories, setCategories] = useState([]); + const [modal, setModal] = useState(null); + + const refreshSubscriptions = useCallback(async () => { + try { + const [subscriptionData, categoryData] = await Promise.all([ + api.subscriptions(), + api.categories(), + ]); + setSubscriptions(subscriptionData?.subscriptions || []); + setCategories(Array.isArray(categoryData) ? categoryData : []); + } catch (err) { + toast.error(err.message || 'Subscriptions could not be refreshed.'); + } + }, []); + + useEffect(() => { + refreshSubscriptions(); + }, [refreshSubscriptions]); + + function openBillEditor(billId) { + const bill = subscriptions.find(item => item.id === billId) || { id: billId }; + setModal({ bill }); + } + + return ( +
+
+
+

+ Matching Tools +

+

Service Catalog

+

+ Link tracked subscriptions to known services and tune bank descriptors so future recommendations are more accurate. +

+
+
+ + +
+
+ +
+
+ + + +
+

This page improves matching, not discovery.

+

+ Recommendations on the Subscriptions page come from bank transactions. Use this catalog when a service needs a better descriptor or an existing bill should be linked to a known service. +

+
+
+
+ + { + await refreshSubscriptions(); + setCatalogKey(key => key + 1); + }} + /> + + {modal && ( + setModal(null)} + onSave={async () => { + setModal(null); + await refreshSubscriptions(); + setCatalogKey(key => key + 1); + }} + /> + )} +
+ ); +} diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx index 5151566..8afedbb 100644 --- a/client/pages/SubscriptionsPage.jsx +++ b/client/pages/SubscriptionsPage.jsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; import { toast } from 'sonner'; import { Bell, @@ -30,7 +31,6 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import BillModal from '@/components/BillModal'; -import SubscriptionCatalogSection from '@/components/SubscriptionCatalogSection'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; const TYPE_LABELS = { @@ -284,6 +284,11 @@ function TxResultRow({ tx, onTrack }) { } function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, busy }) { + const identity = recommendation.evidence?.identity; + const amount = recommendation.evidence?.amount; + const cadence = recommendation.evidence?.cadence; + const amountRange = recommendation.evidence?.amount_range; + return (
@@ -293,6 +298,14 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o

{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charges · last seen {fmtDate(recommendation.last_seen_date)}

+ {recommendation.catalog_match?.starting_monthly_usd && ( +

+ Catalog starts at {fmt(recommendation.catalog_match.starting_monthly_usd)} / mo + {recommendation.catalog_match.starting_annual_usd + ? ` or ${fmt(recommendation.catalog_match.starting_annual_usd)} / yr` + : ''} +

+ )} {recommendation.accounts?.length > 0 && (

{recommendation.accounts.length > 1 ? 'Accounts' : 'Account'}: {recommendation.accounts.join(', ')} @@ -311,6 +324,34 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o {recommendation.confidence}% match + {identity?.label && ( + + {identity.label} + + )} + {amount?.label && ( + + {amount.match === 'unusual' ? 'Unusual amount' : 'Price checked'} + + )} + {cadence?.recurring && ( + + Recurring + + )} + {amountRange && amountRange.min !== amountRange.max && ( + + Range {fmt(amountRange.min)}-{fmt(amountRange.max)} + + )} {recommendation.reasons?.map(reason => ( {reason} @@ -413,7 +454,12 @@ export default function SubscriptionsPage() { useEffect(() => { load(); loadRecommendations(); - api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[SubscriptionsPage] failed to load bills', err)); + api.allBills() + .then(b => setBills(Array.isArray(b) ? b : [])) + .catch(err => { + console.error('[SubscriptionsPage] failed to load bills', err); + toast.error(err.message || 'Bills could not be loaded for subscription linking.'); + }); }, [load, loadRecommendations]); useEffect(() => { @@ -425,7 +471,10 @@ export default function SubscriptionsPage() { try { const result = await api.subscriptionTransactionMatches({ q, limit: 50 }); setTxResults(Array.isArray(result) ? result : (result?.transactions ?? [])); - } catch { setTxResults([]); } + } catch (err) { + setTxResults([]); + toast.error(err.message || 'Transaction search failed.'); + } finally { setTxSearching(false); } }, 300); return () => clearTimeout(txDebounce.current); @@ -621,7 +670,7 @@ export default function SubscriptionsPage() { const MIN_CONFIDENCE = 90; const highConfidenceRecs = useMemo( - () => recommendations.filter(r => (r.confidence ?? 0) >= MIN_CONFIDENCE), + () => recommendations.filter(r => r.catalog_match && (r.confidence ?? 0) >= MIN_CONFIDENCE), [recommendations], ); const filteredRecs = useMemo(() => { @@ -717,7 +766,7 @@ export default function SubscriptionsPage() { )}

- Recurring charges from your accounts with 90%+ confidence. + Known subscription services found in your bank transactions with 90%+ confidence. {!recommendationsLoading && highConfidenceRecs.length > 0 && (
@@ -742,8 +791,8 @@ export default function SubscriptionsPage() {

No high-confidence recommendations.

{recommendations.length > 0 - ? `${recommendations.length} low-confidence pattern${recommendations.length !== 1 ? 's' : ''} found — more account activity will improve accuracy.` - : 'Sync your accounts after a few recurring charges appear.'} + ? 'More account activity or stronger descriptors will improve accuracy.' + : 'Sync your accounts after charges from known subscription services appear.'}

) : filteredRecs.length === 0 ? ( @@ -828,13 +877,28 @@ export default function SubscriptionsPage() { )} - { - const bill = subscriptions.find(s => s.id === billId) || { id: billId }; - setModal({ bill }); - }} - onTrackComplete={refreshAll} - /> + + +
+ + Improve Matching +
+ + Manage known services, catalog links, and custom bank descriptors on a dedicated page. + +
+ +

+ Use the service catalog when a recommendation names the wrong service, a bill needs a catalog link, or your bank uses a custom descriptor. +

+ +
+
{modal && ( = 4 && (normalized.includes(desc) || desc.includes(normalized))) { - const score = 2000 + desc.length; - if (score > bestScore) { best = entry; bestScore = score; } + const value = desc.value || desc; + if (value.length >= 4 && (normalized.includes(value) || value.includes(normalized))) { + const score = (desc.source === 'user_bank' ? 2200 : 2000) + value.length; + if (score > bestScore) { + best = entry; + bestScore = score; + bestMatch = { + type: desc.source === 'user_bank' ? 'user_descriptor' : 'bank_descriptor', + label: desc.source === 'user_bank' ? 'custom bank descriptor' : 'known bank descriptor', + descriptor: value, + }; + } } } - // 2. Name / domain fuzzy match (original logic, unchanged) + // 2. Name / domain / slang match. const nameKey = normalizeCatalogName(entry.name); const nameCompact = compactCatalogKey(entry.name); const nameScore = 1000 + nameKey.length; @@ -160,6 +173,7 @@ function lookupCatalog(catalog, merchantText) { ) { best = entry; bestScore = nameScore; + bestMatch = { type: 'name', label: 'service name', descriptor: nameKey }; } for (const domainKey of catalogDomainKeys(entry)) { const domainCompact = domainKey.replace(/\s+/g, ''); @@ -170,10 +184,28 @@ function lookupCatalog(catalog, merchantText) { ) { best = entry; bestScore = domainScore; + bestMatch = { type: 'domain', label: 'service domain', descriptor: domainKey }; + } + } + for (const slang of (entry.slangTerms || [])) { + const slangCompact = slang.replace(/\s+/g, ''); + const slangScore = 300 + slang.length; + if ( + slang.length >= 4 + && (normalized.includes(slang) || (slangCompact.length >= 5 && compact.includes(slangCompact))) + && slangScore > bestScore + ) { + best = entry; + bestScore = slangScore; + bestMatch = { type: 'slang', label: 'known alternate name', descriptor: slang }; } } } - return best; + return best ? { entry: best, match: bestMatch || { type: 'unknown', label: 'catalog match' } } : null; +} + +function lookupCatalog(catalog, merchantText) { + return lookupCatalogMatch(catalog, merchantText)?.entry || null; } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -220,9 +252,115 @@ function catalogMatchPayload(catalogEntry) { subscription_type: catalogEntry.subscription_type || 'other', website: catalogEntry.website || null, starting_monthly_usd: catalogEntry.starting_monthly_usd ?? null, + starting_annual_usd: catalogEntry.starting_annual_usd ?? null, + price_notes: catalogEntry.price_notes || null, } : null; } +function identityEvidence(match) { + const type = match?.type || 'unknown'; + const table = { + user_descriptor: { score: 84, label: 'Matched your custom bank descriptor' }, + bank_descriptor: { score: 82, label: 'Matched a known bank descriptor' }, + name: { score: 74, label: 'Matched the service name' }, + domain: { score: 70, label: 'Matched the service domain' }, + slang: { score: 66, label: 'Matched a known alternate name' }, + unknown: { score: 60, label: 'Matched the service catalog' }, + }; + return { type, descriptor: match?.descriptor || null, ...(table[type] || table.unknown) }; +} + +function priceClose(amount, expected) { + if (!amount || !expected) return null; + const delta = Math.abs(amount - expected); + const pct = delta / expected; + return { delta, pct }; +} + +function amountEvidence(amount, cycleType, catalogEntry) { + const monthly = Number(catalogEntry?.starting_monthly_usd || 0); + const annual = Number(catalogEntry?.starting_annual_usd || 0) || (monthly ? monthly * 12 : 0); + if (!amount || (!monthly && !annual)) { + return { score: 0, label: null, match: 'unknown', inferred_cycle_type: null }; + } + + const monthlyClose = priceClose(amount, monthly); + const annualClose = priceClose(amount, annual); + const annualLike = annual && annualClose && (annualClose.delta <= 2 || annualClose.pct <= 0.12); + const monthlyLike = monthly && monthlyClose && (monthlyClose.delta <= 1 || monthlyClose.pct <= 0.08); + const plausibleMonthly = monthly && amount >= monthly * 0.70 && amount <= Math.max(monthly * 4, monthly + 25); + const plausibleAnnual = annual && amount >= annual * 0.70 && amount <= annual * 1.35; + + if (cycleType === 'annual' || annualLike || (!monthlyLike && plausibleAnnual && amount >= monthly * 8)) { + if (annualLike) { + return { + score: 13, + label: `Amount aligns with catalog annual pricing near $${annual.toFixed(2)}`, + match: 'annual_close', + inferred_cycle_type: 'annual', + }; + } + if (plausibleAnnual) { + return { + score: 9, + label: `Amount is plausible for annual pricing near $${annual.toFixed(2)}`, + match: 'annual_plausible', + inferred_cycle_type: 'annual', + }; + } + } + + if (monthlyLike) { + return { + score: 12, + label: `Amount aligns with catalog pricing from $${monthly.toFixed(2)}/mo`, + match: 'monthly_close', + inferred_cycle_type: 'monthly', + }; + } + if (plausibleMonthly) { + return { + score: 8, + label: `Amount is plausible for catalog pricing from $${monthly.toFixed(2)}/mo`, + match: 'monthly_plausible', + inferred_cycle_type: null, + }; + } + return { + score: -10, + label: `Amount is unusual for catalog pricing from ${monthly ? `$${monthly.toFixed(2)}/mo` : `$${annual.toFixed(2)}/yr`}`, + match: 'unusual', + inferred_cycle_type: null, + }; +} + +function cadenceEvidence(sorted, cycleType, avgGap, maxDelta, averageAmount) { + if (sorted.length < 2) { + return { + score: 0, + label: 'One matching bank transaction', + stable: true, + recurring: false, + }; + } + const stable = maxDelta <= Math.max(1, averageAmount * 0.08); + const score = 8 + Math.min(15, sorted.length * 3) + (stable ? 8 : 0) + (cycleType !== 'weekly' ? 8 : 0); + return { + score, + label: `${sorted.length} similar charges about ${Math.round(avgGap)} days apart`, + stable, + recurring: true, + }; +} + +function scoreKnownServiceRecommendation({ match, amountInfo, cadenceInfo }) { + const identity = identityEvidence(match); + const confidence = Math.min(99, Math.max(0, + identity.score + amountInfo.score + cadenceInfo.score + )); + return { confidence, identity }; +} + function monthlyEquivalent(amount, cycleType, billingCycle) { const key = String(cycleType || billingCycle || 'monthly').toLowerCase(); const fallback = String(billingCycle || '').toLowerCase() === 'quarterly' @@ -373,17 +511,20 @@ function getSubscriptionRecommendations(db, userId) { if (amount < 1) continue; const key = `${merchant}:${Math.round(amount)}`; if (!groups.has(key)) { - groups.set(key, { merchant, items: [], catalogEntry: null }); + groups.set(key, { merchant, items: [], catalogMatch: null }); } const group = groups.get(key); group.items.push({ ...tx, amount_dollars: amount }); - if (!group.catalogEntry) group.catalogEntry = lookupCatalog(catalog, merchant); + if (!group.catalogMatch) group.catalogMatch = lookupCatalogMatch(catalog, merchant); } const recommendations = []; for (const group of groups.values()) { - const { merchant, catalogEntry } = group; + const { merchant } = group; + const catalogEntry = group.catalogMatch?.entry || null; + const catalogIdentityMatch = group.catalogMatch?.match || null; + if (!catalogEntry) continue; const declineKey = catalogEntry ? `catalog:${catalogEntry.id}` : `merchant:${merchant}`; if (declined.has(declineKey)) continue; @@ -400,11 +541,25 @@ function getSubscriptionRecommendations(db, userId) { : 0; const last = sorted[sorted.length - 1]; - // Tier 1: catalog match with 1 occurrence + // Tier 1: known-service match with 1 occurrence. Exact bank descriptors can + // still be 90+, but weaker name/domain hits need recurrence or stronger amount + // evidence before they appear as recommendations. if (catalogEntry && sorted.length === 1) { + let cycleType = 'monthly'; + let amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry); + if (amountInfo.inferred_cycle_type) { + cycleType = amountInfo.inferred_cycle_type; + amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry); + } + const cadenceInfo = cadenceEvidence(sorted, cycleType, 30, maxDelta, averageAmount); + const scored = scoreKnownServiceRecommendation({ + match: catalogIdentityMatch, amountInfo, cadenceInfo, sorted, + }); + if (scored.confidence < 90) continue; recommendations.push(buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, - cycleType: 'monthly', avgGap: 30, confidence: 90, tier: 'known_service', declineKey, catalogTypeMap, + cycleType, avgGap: 30, confidence: scored.confidence, tier: 'known_service', + declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo, })); continue; } @@ -429,17 +584,17 @@ function getSubscriptionRecommendations(db, userId) { if (cycleType === 'weekly') continue; if (maxDelta > Math.max(3, averageAmount * 0.18)) continue; - let confidence; - if (catalogEntry) { - confidence = Math.min(99, 68 + sorted.length * 8 + (maxDelta <= 1 ? 8 : 0)); - } else { - confidence = Math.min(96, 58 + sorted.length * 9 + (maxDelta <= 1 ? 10 : 0)); - } + const amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry); + const cadenceInfo = cadenceEvidence(sorted, cycleType, avgGap, maxDelta, averageAmount); + const scored = scoreKnownServiceRecommendation({ + match: catalogIdentityMatch, amountInfo, cadenceInfo, sorted, + }); + if (scored.confidence < 90) continue; - const tier = catalogEntry ? 'confirmed' : 'pattern'; recommendations.push(buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, - cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap, + cycleType, avgGap, confidence: scored.confidence, tier: 'confirmed', + declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo, })); } @@ -457,7 +612,7 @@ function getSubscriptionRecommendations(db, userId) { return deduped.slice(0, 20); } -function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap }) { +function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap, identityInfo = null, amountInfo = null, cadenceInfo = null }) { const name = catalogEntry ? catalogEntry.name : titleCase(merchant); const subscriptionType = inferType(merchant, catalogEntry, catalogTypeMap); const accounts = Array.from(new Set(sorted @@ -471,8 +626,9 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma const reasons = []; if (catalogEntry) reasons.push(`Matches known service: ${catalogEntry.name}`); - if (sorted.length > 1) reasons.push(`${sorted.length} similar charges`); - if (sorted.length > 1) reasons.push(`About ${Math.round(avgGap)} days apart`); + if (identityInfo?.label) reasons.push(identityInfo.label); + if (amountInfo?.label) reasons.push(amountInfo.label); + if (cadenceInfo?.recurring && cadenceInfo.label) reasons.push(cadenceInfo.label); reasons.push(`${last.currency || 'USD'} ${averageAmount.toFixed(2)} average`); return { @@ -489,6 +645,25 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma confidence, tier, catalog_match: catalogMatchPayload(catalogEntry), + evidence: { + identity: identityInfo, + amount: amountInfo ? { + match: amountInfo.match, + label: amountInfo.label, + score: amountInfo.score, + } : null, + cadence: cadenceInfo ? { + recurring: cadenceInfo.recurring, + stable: cadenceInfo.stable, + label: cadenceInfo.label, + score: cadenceInfo.score, + } : null, + amount_range: sorted.length > 1 ? { + min: Math.min(...sorted.map(item => item.amount_dollars)), + max: Math.max(...sorted.map(item => item.amount_dollars)), + max_delta: Math.round(maxDelta * 100) / 100, + } : null, + }, transaction_ids: sorted.map(item => item.id), merchant, decline_key: declineKey, diff --git a/tests/subscriptionService.test.js b/tests/subscriptionService.test.js index 3ababf6..c0427a0 100644 --- a/tests/subscriptionService.test.js +++ b/tests/subscriptionService.test.js @@ -69,6 +69,53 @@ test('known catalog services appear as high-confidence subscription recommendati assert.equal(netflix.confidence >= 90, true); assert.deepEqual(netflix.accounts, ['Checking']); assert.match(netflix.reasons.join(' '), /Matches known service: Netflix/); + assert.equal(netflix.evidence.identity.type, 'bank_descriptor'); + assert.equal(netflix.evidence.amount.match, 'monthly_plausible'); +}); + +test('weak one-off known service names stay below the recommendation threshold', () => { + const db = getDb(); + const userId = createUser(db, 'weak-known'); + const accountId = createAccount(db, userId, true); + createTransaction(db, userId, { + account_id: accountId, + description: 'MAX', + payee: 'MAX', + amount: -35000, + }); + + const recommendations = getSubscriptionRecommendations(db, userId); + assert.equal(recommendations.some(item => item.catalog_match?.name === 'Max'), false); +}); + +test('unknown recurring patterns do not appear as known-service recommendations', () => { + const db = getDb(); + const userId = createUser(db, 'unknown-pattern'); + const accountId = createAccount(db, userId, true); + createTransaction(db, userId, { + account_id: accountId, + description: 'LOCAL CLUB MEMBERSHIP', + payee: 'LOCAL CLUB MEMBERSHIP', + amount: -2500, + posted_date: '2026-01-05', + }); + createTransaction(db, userId, { + account_id: accountId, + description: 'LOCAL CLUB MEMBERSHIP', + payee: 'LOCAL CLUB MEMBERSHIP', + amount: -2500, + posted_date: '2026-02-05', + }); + createTransaction(db, userId, { + account_id: accountId, + description: 'LOCAL CLUB MEMBERSHIP', + payee: 'LOCAL CLUB MEMBERSHIP', + amount: -2500, + posted_date: '2026-03-05', + }); + + const recommendations = getSubscriptionRecommendations(db, userId); + assert.equal(recommendations.some(item => item.merchant === 'local club membership'), false); }); test('subscription transaction search annotates known catalog matches', () => { diff --git a/vite.config.mjs b/vite.config.mjs index df354b2..22958d6 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -8,6 +8,7 @@ import { createRequire } from 'module'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const pkg = require('./package.json'); +const apiPort = process.env.API_PORT || process.env.PORT || 3000; export default defineConfig({ plugins: [ @@ -56,7 +57,7 @@ export default defineConfig({ server: { port: 5173, proxy: { - '/api': { target: 'http://localhost:3000', changeOrigin: true }, + '/api': { target: `http://localhost:${apiPort}`, changeOrigin: true }, }, }, build: {