feat: merchant rules, auto-match on sync, duplicate API fix
- Removed duplicate unmatchTransaction API entry in api.js - Unmonitored accounts: no chevron, click-to-expand disabled, tx panel hidden - matched_bill_name included via LEFT JOIN bills in accounts query - BillPickerDialog resets search/selection on open - Link to bill: marks historical txs matched, stores merchant rule, applyMerchantRules catches other unmatched txs from same merchant - Track (new subscription): creates bill with is_subscription=1, stores merchant rule for ongoing tracking - SimpleFIN sync: applyMerchantRules runs after tx insert, auto-matches by merchant rule with payment_source='auto_match' - Auto-match payments have transaction_id set, treated same as manual matches - New services/billMerchantRuleService.js for rule storage and matching - Migration for bill_merchant_rules table
This commit is contained in:
parent
eeb26ccab1
commit
6b30ee4eb7
|
|
@ -182,7 +182,7 @@ export const api = {
|
|||
// Subscriptions
|
||||
subscriptions: () => get('/subscriptions'),
|
||||
confirmTransactionMatch: (transactionId, billId) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }),
|
||||
unmatchTransaction: (transactionId) => post(`/matches/${transactionId}/unmatch`),
|
||||
matchRecommendationToBill: (transactionIds, billId, merchant) => post('/subscriptions/recommendations/match-bill', { transaction_ids: transactionIds, bill_id: billId, merchant }),
|
||||
subscriptionRecommendations: () => get('/subscriptions/recommendations'),
|
||||
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
|
||||
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
|
||||
|
|
|
|||
|
|
@ -164,13 +164,13 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
|
|||
return (
|
||||
<div className="border-t border-border/40 first:border-t-0">
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/20 cursor-pointer"
|
||||
onClick={onToggleExpand}
|
||||
className={cn('flex items-center gap-3 px-4 py-2.5', account.monitored && 'hover:bg-muted/20 cursor-pointer')}
|
||||
onClick={account.monitored ? onToggleExpand : undefined}
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0" onClick={e => e.stopPropagation()}>
|
||||
{expanded
|
||||
<span className="text-muted-foreground shrink-0 w-3.5" onClick={e => e.stopPropagation()}>
|
||||
{account.monitored && (expanded
|
||||
? <ChevronDown className="h-3.5 w-3.5" />
|
||||
: <ChevronRight className="h-3.5 w-3.5" />}
|
||||
: <ChevronRight className="h-3.5 w-3.5" />)}
|
||||
</span>
|
||||
|
||||
{/* Monitored toggle */}
|
||||
|
|
@ -214,7 +214,7 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
{expanded && account.monitored && (
|
||||
<div className="border-t border-border/30 bg-muted/10">
|
||||
{account.transactions.length === 0 ? (
|
||||
<p className="px-6 py-3 text-xs text-muted-foreground italic">No transactions synced for this account.</p>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
CalendarDays,
|
||||
CheckCircle2,
|
||||
Cloud,
|
||||
Link2,
|
||||
Loader2,
|
||||
Pause,
|
||||
Plus,
|
||||
|
|
@ -18,6 +19,10 @@ import { cn, fmt, fmtDate } from '@/lib/utils';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import BillModal from '@/components/BillModal';
|
||||
|
||||
const TYPE_LABELS = {
|
||||
|
|
@ -112,7 +117,74 @@ function SubscriptionRow({ item, onEdit, onToggle }) {
|
|||
);
|
||||
}
|
||||
|
||||
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, busy }) {
|
||||
function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, busy }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
|
||||
useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
return (bills || []).filter(b => !q || b.name.toLowerCase().includes(q));
|
||||
}, [bills, search]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Link to existing bill</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<span className="font-medium text-foreground">{recommendation?.name}</span>
|
||||
{recommendation && (
|
||||
<span className="ml-2 text-xs">{recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · {fmt(recommendation.expected_amount)}</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The matching transactions will be marked as paid under the selected bill.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search bills…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
|
||||
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 divide-y divide-border/40">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground text-center">No bills found.</p>
|
||||
) : filtered.map(bill => (
|
||||
<button
|
||||
key={bill.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(bill.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/40',
|
||||
selectedId === bill.id && 'bg-primary/10 text-primary',
|
||||
)}
|
||||
>
|
||||
<span className="truncate font-medium">{bill.name}</span>
|
||||
<span className="shrink-0 ml-3 text-xs text-muted-foreground tabular-nums">
|
||||
${(bill.expected_amount ?? 0).toFixed(2)}/mo
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={() => onConfirm(selectedId)} disabled={!selectedId || busy}>
|
||||
{busy ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />Linking…</> : 'Link to bill'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, busy }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
|
|
@ -139,7 +211,7 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, b
|
|||
<p className="tracker-number text-xs font-semibold text-emerald-600 dark:text-emerald-300">
|
||||
{fmt(recommendation.monthly_equivalent)} / mo
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2 sm:justify-end">
|
||||
<div className="mt-3 flex flex-wrap gap-2 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
|
@ -151,6 +223,17 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, b
|
|||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||
Decline
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
disabled={busy}
|
||||
onClick={() => onMatch(recommendation)}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
Link to bill
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
|
@ -172,10 +255,12 @@ export default function SubscriptionsPage() {
|
|||
const [data, setData] = useState({ summary: {}, subscriptions: [] });
|
||||
const [recommendations, setRecommendations] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [bills, setBills] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [recommendationsLoading, setRecommendationsLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
const [modal, setModal] = useState(null);
|
||||
const [matchTarget, setMatchTarget] = useState(null); // recommendation being linked
|
||||
|
||||
const subscriptionCategoryId = useMemo(() => {
|
||||
const match = categories.find(category => /subscrip/i.test(category.name));
|
||||
|
|
@ -213,6 +298,7 @@ export default function SubscriptionsPage() {
|
|||
useEffect(() => {
|
||||
load();
|
||||
loadRecommendations();
|
||||
api.bills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
|
||||
}, [load, loadRecommendations]);
|
||||
|
||||
async function refreshAll() {
|
||||
|
|
@ -258,6 +344,21 @@ export default function SubscriptionsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function matchRecommendationToBill(billId) {
|
||||
if (!matchTarget || !billId) return;
|
||||
setBusyId(`match-${matchTarget.id}`);
|
||||
try {
|
||||
const result = await api.matchRecommendationToBill(matchTarget.transaction_ids, billId, matchTarget.merchant);
|
||||
toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`);
|
||||
setMatchTarget(null);
|
||||
setRecommendations(prev => prev.filter(r => r.id !== matchTarget.id));
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Could not link recommendation to bill.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function openManualSubscription() {
|
||||
setModal({
|
||||
bill: null,
|
||||
|
|
@ -369,9 +470,10 @@ export default function SubscriptionsPage() {
|
|||
key={recommendation.id}
|
||||
recommendation={recommendation}
|
||||
categoryId={subscriptionCategoryId}
|
||||
busy={busyId === `rec-${recommendation.id}` || busyId === `dec-${recommendation.id}`}
|
||||
busy={busyId === `rec-${recommendation.id}` || busyId === `dec-${recommendation.id}` || busyId === `match-${recommendation.id}`}
|
||||
onAccept={acceptRecommendation}
|
||||
onDecline={declineRecommendation}
|
||||
onMatch={rec => setMatchTarget(rec)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
@ -392,6 +494,15 @@ export default function SubscriptionsPage() {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BillPickerDialog
|
||||
open={!!matchTarget}
|
||||
onClose={() => setMatchTarget(null)}
|
||||
recommendation={matchTarget}
|
||||
bills={bills}
|
||||
onConfirm={matchRecommendationToBill}
|
||||
busy={!!busyId?.startsWith('match-')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1366,6 +1366,27 @@ function reconcileLegacyMigrations() {
|
|||
ON declined_subscription_hints(user_id);
|
||||
`);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.67',
|
||||
description: 'bill_merchant_rules: persistent merchant→bill auto-match rules',
|
||||
check: function() {
|
||||
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_merchant_rules'").get();
|
||||
},
|
||||
run: function() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bill_merchant_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
||||
merchant TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, bill_id, merchant)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_bill_merchant_rules_user
|
||||
ON bill_merchant_rules(user_id);
|
||||
`);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
@ -2167,6 +2188,25 @@ function runMigrations() {
|
|||
ON declined_subscription_hints(user_id);
|
||||
`);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.67',
|
||||
description: 'bill_merchant_rules: persistent merchant→bill auto-match rules',
|
||||
dependsOn: ['v0.66'],
|
||||
run: function() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bill_merchant_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
||||
merchant TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, bill_id, merchant)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_bill_merchant_rules_user
|
||||
ON bill_merchant_rules(user_id);
|
||||
`);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.33.5",
|
||||
"version": "0.33.6",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const {
|
|||
getSubscriptionSummary,
|
||||
getSubscriptions,
|
||||
} = require('../services/subscriptionService');
|
||||
const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService');
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
|
|
@ -41,6 +42,46 @@ router.post('/recommendations/decline', (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// POST /api/subscriptions/recommendations/match-bill
|
||||
// Link an existing bill to all transactions in a recommendation (no new bill created).
|
||||
router.post('/recommendations/match-bill', (req, res) => {
|
||||
const billId = parseInt(req.body?.bill_id, 10);
|
||||
const rawIds = Array.isArray(req.body?.transaction_ids) ? req.body.transaction_ids : [];
|
||||
const txIds = rawIds.map(id => parseInt(id, 10)).filter(n => Number.isInteger(n) && n > 0).slice(0, 50);
|
||||
|
||||
if (!Number.isInteger(billId) || billId < 1) {
|
||||
return res.status(400).json(standardizeError('bill_id is required', 'VALIDATION_ERROR', 'bill_id'));
|
||||
}
|
||||
if (txIds.length === 0) {
|
||||
return res.status(400).json(standardizeError('transaction_ids must be a non-empty array', 'VALIDATION_ERROR', 'transaction_ids'));
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
|
||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
|
||||
const update = db.prepare(`
|
||||
UPDATE transactions
|
||||
SET matched_bill_id = ?, match_status = 'matched', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ? AND ignored = 0 AND match_status != 'matched'
|
||||
`);
|
||||
let matchedCount = 0;
|
||||
db.transaction(() => {
|
||||
for (const id of txIds) {
|
||||
matchedCount += update.run(billId, id, req.user.id).changes;
|
||||
}
|
||||
})();
|
||||
|
||||
// Store merchant rule for ongoing auto-matching on future syncs
|
||||
const merchant = typeof req.body?.merchant === 'string' ? req.body.merchant.trim() : '';
|
||||
if (merchant) addMerchantRule(db, req.user.id, billId, merchant);
|
||||
|
||||
// Apply rules immediately to catch any unmatched transactions beyond the explicit list
|
||||
const { matched: autoMatched } = applyMerchantRules(db, req.user.id);
|
||||
|
||||
res.json({ ok: true, matched_count: matchedCount + autoMatched, bill_name: bill.name });
|
||||
});
|
||||
|
||||
router.post('/recommendations/create', (req, res) => {
|
||||
const db = getDb();
|
||||
ensureUserDefaultCategories(req.user.id);
|
||||
|
|
@ -55,6 +96,8 @@ router.post('/recommendations/create', (req, res) => {
|
|||
}
|
||||
try {
|
||||
const created = createSubscriptionFromRecommendation(db, req.user.id, req.body || {});
|
||||
// Store merchant rule so future SimpleFIN transactions auto-match this bill
|
||||
if (req.body?.merchant) addMerchantRule(db, req.user.id, created.id, req.body.merchant);
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
res.status(err.status || 400).json(standardizeError(err.message || 'Could not create subscription', err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR', err.field || null));
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const {
|
|||
} = require('./simplefinService');
|
||||
const { getBankSyncConfig } = require('./bankSyncConfigService');
|
||||
const { decorateDataSource } = require('./transactionService');
|
||||
const { applyMerchantRules } = require('./billMerchantRuleService');
|
||||
|
||||
function sinceEpoch() {
|
||||
const { sync_days } = getBankSyncConfig();
|
||||
|
|
@ -118,7 +119,10 @@ async function runSync(db, userId, dataSource) {
|
|||
WHERE id = ? AND user_id = ?
|
||||
`).run(partialError, dataSource.id, userId);
|
||||
|
||||
return { accountsUpserted, transactionsNew, transactionsSkip, errlist: raw._errlistSummary || null };
|
||||
// Apply any stored merchant→bill rules to newly synced transactions
|
||||
const { matched: autoMatched } = applyMerchantRules(db, userId);
|
||||
|
||||
return { accountsUpserted, transactionsNew, transactionsSkip, autoMatched, errlist: raw._errlistSummary || null };
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
'use strict';
|
||||
|
||||
const { normalizeMerchant } = require('./subscriptionService');
|
||||
|
||||
// Persist a merchant→bill rule so future synced transactions auto-match.
|
||||
function addMerchantRule(db, userId, billId, merchant) {
|
||||
const normalized = normalizeMerchant(merchant);
|
||||
if (!normalized || normalized.length < 3) return;
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO bill_merchant_rules (user_id, bill_id, merchant)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, bill_id, merchant) DO NOTHING
|
||||
`).run(userId, billId, normalized);
|
||||
} catch {
|
||||
// Table may not exist yet on legacy DBs — safe to skip
|
||||
}
|
||||
}
|
||||
|
||||
// Scan all unmatched negative transactions for this user, apply any stored
|
||||
// merchant rules, create payments, and mark the transactions matched.
|
||||
// Returns { matched: number }.
|
||||
function applyMerchantRules(db, userId) {
|
||||
let rules;
|
||||
try {
|
||||
rules = db.prepare(`
|
||||
SELECT bmr.bill_id, bmr.merchant
|
||||
FROM bill_merchant_rules bmr
|
||||
JOIN bills b ON b.id = bmr.bill_id AND b.user_id = bmr.user_id AND b.deleted_at IS NULL
|
||||
WHERE bmr.user_id = ?
|
||||
`).all(userId);
|
||||
} catch {
|
||||
return { matched: 0 };
|
||||
}
|
||||
|
||||
if (rules.length === 0) return { matched: 0 };
|
||||
|
||||
const txRows = db.prepare(`
|
||||
SELECT id, amount, payee, description, memo, posted_date, transacted_at
|
||||
FROM transactions
|
||||
WHERE user_id = ?
|
||||
AND match_status = 'unmatched'
|
||||
AND ignored = 0
|
||||
AND amount < 0
|
||||
`).all(userId);
|
||||
|
||||
if (txRows.length === 0) return { matched: 0 };
|
||||
|
||||
const insertPayment = db.prepare(`
|
||||
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
|
||||
VALUES (?, ?, ?, 'auto_match', ?)
|
||||
`);
|
||||
const updateTx = db.prepare(`
|
||||
UPDATE transactions
|
||||
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ? AND match_status = 'unmatched'
|
||||
`);
|
||||
|
||||
let matched = 0;
|
||||
|
||||
db.transaction(() => {
|
||||
for (const tx of txRows) {
|
||||
const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
|
||||
if (!txMerchant) continue;
|
||||
|
||||
const rule = rules.find(r =>
|
||||
txMerchant.includes(r.merchant) || r.merchant.includes(txMerchant)
|
||||
);
|
||||
if (!rule) continue;
|
||||
|
||||
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
||||
if (!paidDate) continue;
|
||||
|
||||
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
||||
insertPayment.run(rule.bill_id, amount, paidDate, tx.id);
|
||||
updateTx.run(rule.bill_id, tx.id, userId);
|
||||
matched++;
|
||||
}
|
||||
})();
|
||||
|
||||
return { matched };
|
||||
}
|
||||
|
||||
module.exports = { addMerchantRule, applyMerchantRules };
|
||||
|
|
@ -427,4 +427,5 @@ module.exports = {
|
|||
lookupCatalog,
|
||||
loadCatalog,
|
||||
monthlyEquivalent,
|
||||
normalizeMerchant,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue