Compare commits
No commits in common. "5aa5c0cc0e0f493af2d452eedb1e41dfd735c897" and "5ffe2db85a9351ccc098a7714cc1c68619e039c5" have entirely different histories.
5aa5c0cc0e
...
5ffe2db85a
|
|
@ -36,7 +36,3 @@ mkdocs/
|
|||
/bills.db
|
||||
Mobile App/PLAN.md
|
||||
mobile/
|
||||
|
||||
# LIVE production DB copy for SimpleFIN testing — REAL financial data + secrets.
|
||||
# NEVER commit. Whole dir is ignored.
|
||||
tests/Site-BKUP-Test/
|
||||
|
|
|
|||
11
HISTORY.md
11
HISTORY.md
|
|
@ -3,9 +3,6 @@
|
|||
|
||||
### 🐛 QA Fixes
|
||||
|
||||
- **[SimpleFIN] Purging a soft-deleted bill orphaned its matched transactions** — found on the live SimpleFIN DB: 3 transactions were `match_status='matched'` with `matched_bill_id=NULL`. Root cause: bills are soft-deleted (retained for recovery), then the retention GC (`pruneSoftDeletedFinancialRecords`, `services/cleanupService.js`) hard-deletes them past the 30-day window. `transactions.matched_bill_id` is `ON DELETE SET NULL`, so the purge nulled the pointer but left `match_status='matched'` — a limbo row **excluded from spending/analytics (`match_status != 'matched'`) yet attributed to no bill**, silently dropping that spend. The purge now releases those matches back to `'unmatched'` in the same transaction and self-heals any pre-existing orphans; retention behaviour is unchanged. Verified on a copy of the live DB (3→0 orphans, 0 transactions lost). Regression: 3 tests in `tests/backupAndCleanup.test.js`. (QA-B5-04)
|
||||
- **[Security] SQLite DB was created world-readable (644)** — `docker-entrypoint.sh` locked the data *directory* (`chmod 700`) but never the DB *file*, and SQLite created `bills.db`/`-wal`/`-shm` under the default umask (644). On a real deploy the DB (financial data + encrypted SimpleFIN token, sessions, SMTP/OIDC secrets) was world-readable, shielded only by the parent dir's 700. Added `umask 077` to the entrypoint (DB, WAL/SHM, backups, exports now created 600/700) plus an explicit `chmod 600` for pre-existing files on upgrade. (QA-B16-02)
|
||||
- **[Privacy] The version check is now opt-out-able** — the privacy policy described the external version check as "optional", but there was no way to disable it (it hit a hardcoded upstream host whenever the About/Status/version page loaded). Added an **admin toggle**: an `update_check_enabled` setting gates the request in `services/updateCheckService.js` (default on — when off, **no external request is made**), exposed via `GET`/`PUT /api/about-admin/update-check-setting` and a switch on the admin **System Status** page. Privacy policy updated to state an admin can disable it. Test: `tests/updateCheckOptOut.test.js`. (was QA-B16-01)
|
||||
- **[Security] Bill name could inject HTML into reminder emails** — `buildEmailHtml` (`services/notificationService.js`) escaped the bill name in the detail table but interpolated it **raw** into the reminder message line (`<strong>${bill.name}</strong> is due…`), so a bill named `<img src=x onerror=…>` landed unescaped in the email HTML. Self-XSS (reminder emails go to the bill's owner), but a clear inconsistent-escaping bug — now escaped everywhere. Covered by `tests/notificationDelivery.test.js`. (was QA-B14-04)
|
||||
- **[Notifications] "Send test push" was completely broken** — `services/notificationService.js` attached its `_push` export (the ntfy/Gotify/Discord/Telegram helpers) *before* the final `module.exports = {…}`, which clobbered it, so `require('…/notificationService')._push` was `undefined`. `routes/notifications.js` (`const { sendTestPush } = require(…)._push || {}`) therefore always hit `throw 'Push service not initialised'` → **`POST /api/notifications/test-push` always returned 500** for every user testing their push channel. Scheduled reminders were unaffected (they call `sendPushToUser` in-scope). Moved the `_push` assignment after the reassignment. Covered by `tests/notificationDelivery.test.js` (per-channel payloads, dispatch, error handling, and a check that the auth token never leaks into the message body). (was QA-B10-01)
|
||||
- **[Summary/Analytics] Non-monthly bills were counted in every month** — the Summary expense list/total and the Analytics "expected vs actual" line both counted annual (and off-month quarterly) bills for months they weren't due, over-stating the obligation and disagreeing with the Tracker (e.g. a yearly insurance bill inflated every month). Both `routes/summary.js` and `services/analyticsService.js` now gate bills by `resolveDueDate` — the same occurrence check the Tracker uses. Guarded by Tracker↔Summary and Tracker↔Analytics reconciliation checks in `e2e/api.probe.spec.js`. The SimpleFIN bank-tracking `unpaid_this_month` metric had the same gap and is fixed the same way (fetch + JS `resolveDueDate` filter, since SQL can't call it), covered by `tests/summaryBankTracking.test.js`. (was QA-B5-01, QA-B5-02, QA-B5-03)
|
||||
|
|
@ -16,16 +13,8 @@
|
|||
- **[Money] `toCents` lost a cent on 3-decimal half values** — `toCents(1.005)` returned `100` ($1.00) instead of `101`, because `Math.round(n * 100)` inherits binary-float error (`1.005 * 100 === 100.4999…`). Now rounds off the shortest decimal string that round-trips the value (half away from zero); output is **identical** for every integer / ≤2-decimal / `"$1,234.56"` input, so no downstream behavior changes. Added `tests/money.test.js` (9 tests). (was QA-B7-01)
|
||||
- **[a11y] Nested interactive controls in Categories & Snowball rows** — Categories rows were `role="button"` (and draggable) yet wrapped their own move/edit/delete buttons, and the Snowball plan-status header wrapped its action buttons *inside* the collapsible trigger button (axe **serious `nested-interactive`**). Restructured both: Categories now use a plain container with a dedicated chevron toggle button (click-anywhere still expands via mouse; keyboard/SR use the chevron); the Snowball header splits into a name/progress toggle, sibling action buttons, and a chevron toggle. Expand/collapse verified by `e2e/categories.spec.js`; all 8 authenticated pages now pass axe. (completes QA-B14-02)
|
||||
|
||||
### ✨ QA Improvements
|
||||
|
||||
- **[Bills] "Recently deleted" restore view** — deleted bills are retained for 30 days, but the only way back was the transient "Undo" toast; once it faded, a bill deleted earlier was unrecoverable from the UI. Bills now shows a **Recently deleted (N)** button (when any exist) that opens a dialog listing each deleted bill with its amount, category, and days left before purge, and a **Restore** action. Backed by `GET /api/bills/deleted` (user-scoped, 30-day window, newest first). Test: `tests/billsDeletedRoute.test.js`. (was IMP-UX-01)
|
||||
- **[Nav] Data is now a first-class menu item** — import/export/backups (`/data`) was only reachable from the account overflow dropdown and the command palette, easy to miss for how central it is. It now lives in the main app nav (desktop dropdown + mobile) alongside Bills/Categories/Spending. Same visibility gate as before (hidden for the default-admin account). (was IMP-IA-01)
|
||||
|
||||
### 🧹 QA Cleanup
|
||||
|
||||
- **[DB] Split `database.js` from 4,174 → 1,297 lines** — the DB module was dominated by three big blocks, now each in its own file: the static `subscription_catalog` seed (`db/subscriptionCatalogSeed.js`), the ~1,740-line versioned migration list (`db/migrations/versionedMigrations.js`), and the ~830-line legacy-reconcile list (`db/migrations/legacyReconcileMigrations.js`). The two migration lists are `build*(deps)` factories: each `run`/`check` body closes over the live `db` + a few schema helpers, injected so behavior is byte-identical. Verified behavior-preserving — full server suite (125) passes, a fresh DB applies all 79 migrations and is idempotent, and the real production DB copy (v1.06) migrates as a clean no-op with data intact. `tests/migrationModules.test.js` locks the version-sync invariant between the two lists. (was IMP-CODE-02)
|
||||
- **[Banking] One canonical writer for transaction match state** — a transaction's `match_status`, `matched_bill_id` and `ignored` columns must move together, but they were updated by copy-pasted inline `UPDATE`s across six routes/services — the same drift that produced QA-B5-04 (a `matched` row with a `NULL` bill). Added `services/transactionMatchState.js` (`markMatched`/`markUnmatched`/`markIgnored`, ownership-scoped) and routed every single-transaction transition (match, unmatch, ignore, unignore, and unmatch-on-payment-delete) through it. Guarded bulk auto-match sweeps and the retention purge keep their own queries by design (their `WHERE` carries idempotency guards). Test: `tests/transactionMatchState.test.js`. (was IMP-CODE-03)
|
||||
- **[Client] One source of truth for money formatting** — the client had ~15 hand-rolled currency formatters (local `fmt`/`money`/`fmtFull`/`fmtDollars`/… across pages and components) alongside a canonical `fmt` in `lib/utils` used at ~190 sites — the same rules copy-pasted with inconsistent handling of negatives, whole-dollar, cents-vs-dollars, and blank input. Added `client/lib/money.js` (`formatUSD` / `formatUSDWhole` / `formatCentsUSD`, with `signed`/`dash`/`whole` options) as the single implementation; inputs are coerced so `null`/`''`/`NaN` never render as `$NaN` and `-0` never shows as `-$0.00`. `lib/utils.fmt` now delegates to it (byte-identical — guarded by the existing `utils.test.js` suite) and the 15 local formatters were removed in favor of it. Tests: `client/lib/money.test.js`. (was IMP-CODE-01)
|
||||
- **[Build] Split the vendor bundle** — the main `index` chunk was ~659 kB (over Vite's 500 kB warning). Added `build.rollupOptions.output.manualChunks` (`vite.config.mjs`) to split React, Radix, framer-motion, and TanStack into separately-cacheable vendor chunks; the index chunk dropped to ~334 kB and the warning is gone. (was QA-B0-01)
|
||||
- **[Deps] Removed unused markdown libraries** — `react-markdown`, `rehype-sanitize`, and `remark-gfm` were declared but never imported (client markdown is rendered by a custom `MarkdownText` component). Removed all three and corrected the security notes: the actual XSS defense is React auto-escaping + the restrictive custom renderer (https-only link hrefs, no `dangerouslySetInnerHTML` anywhere), not rehype-sanitize. (was QA-B14-03)
|
||||
- **[Debt] Removed dead `totalInterestPaid`** — unused outside its own export, and its unrounded accumulation diverged from `amortizationSchedule`'s per-month rounding. Removed from `services/aprService.js`. (was QA-B7-02)
|
||||
|
|
|
|||
|
|
@ -210,7 +210,6 @@ export const api = {
|
|||
// Bills
|
||||
bills: (params = {}) => get(`/bills${queryString(params)}`),
|
||||
allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`),
|
||||
deletedBills: () => get('/bills/deleted'),
|
||||
billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`),
|
||||
bill: (id) => get(`/bills/${id}`),
|
||||
createBill: (data) => post('/bills', data),
|
||||
|
|
@ -345,8 +344,6 @@ export const api = {
|
|||
roadmap: (refresh = false) => get(`/about-admin/roadmap${refresh ? '?refresh=1' : ''}`),
|
||||
updateStatus: () => get('/version/update-status'),
|
||||
checkForUpdates: () => post('/about-admin/check-updates'),
|
||||
getUpdateCheckSetting: () => get('/about-admin/update-check-setting'),
|
||||
setUpdateCheckSetting: (enabled) => put('/about-admin/update-check-setting', { enabled }),
|
||||
devLog: () => get('/about-admin/dev-log'),
|
||||
version: () => get('/version'),
|
||||
releaseHistory: () => get('/version/history'),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
|
||||
|
|
@ -344,7 +344,7 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
|||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{s.label}</span>
|
||||
<span className="shrink-0 font-mono text-muted-foreground tabular-nums">
|
||||
{fmt(amountVal)}
|
||||
${amountVal.toFixed(2)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useActionState, useEffect, useState } from 'react';
|
||||
import { ChevronDown, Copy, Layers, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { formatCentsUSD } from '@/lib/money';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
@ -63,7 +62,13 @@ const SUBSCRIPTION_TYPES = [
|
|||
];
|
||||
|
||||
function fmtTransactionAmount(amount, currency = 'USD') {
|
||||
return formatCentsUSD(amount, { signed: true, currency });
|
||||
const cents = Number(amount || 0);
|
||||
const value = Math.abs(cents) / 100;
|
||||
const sign = cents < 0 ? '-' : '+';
|
||||
return `${sign}${new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD',
|
||||
}).format(value)}`;
|
||||
}
|
||||
|
||||
function transactionDate(tx) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { scheduleLabel } from '@/lib/billingSchedule';
|
||||
import { formatUSDWhole } from '@/lib/money';
|
||||
import { MobileBillRow } from '@/components/MobileBillRow';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
|
|
@ -179,7 +178,7 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
|
|||
<>
|
||||
{(prefs.showCycle || prefs.showDueDay || (prefs.showApr && bill.interest_rate != null)) && <span className="text-border">·</span>}
|
||||
<span className="tracker-number text-[11px] font-medium text-muted-foreground">
|
||||
{formatUSDWhole(bill.current_balance)} balance
|
||||
${Number(bill.current_balance).toLocaleString(undefined, { maximumFractionDigits: 0 })} balance
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { formatUSD } from '@/lib/money';
|
||||
import { TrendingUp, EyeOff, Eye, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -9,7 +8,7 @@ import {
|
|||
} from '@/components/ui/dialog';
|
||||
|
||||
function fmt(n) {
|
||||
return formatUSD(n);
|
||||
return Number(n || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
}
|
||||
|
||||
export default function IncomeBreakdownModal({ open, onClose, year, month, bankTracking }) {
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { RotateCcw, Trash2, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatUSD } from '@/lib/money';
|
||||
|
||||
function daysLeftLabel(days) {
|
||||
if (days == null) return null;
|
||||
if (days <= 0) return 'purges today';
|
||||
if (days === 1) return '1 day left';
|
||||
return `${days} days left`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists bills that were soft-deleted within the 30-day recovery window and lets
|
||||
* the user restore them — a durable path beyond the transient "Undo" toast.
|
||||
*
|
||||
* Presentational: the parent owns the list (`bills`) and the async `onRestore`,
|
||||
* so restoring refreshes the page's active bills too.
|
||||
*/
|
||||
export default function RecentlyDeletedBillsDialog({ open, onOpenChange, bills = [], onRestore }) {
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
|
||||
async function handleRestore(bill) {
|
||||
setBusyId(bill.id);
|
||||
try {
|
||||
await onRestore(bill);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Recently deleted</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deleted bills are kept for 30 days before they’re permanently removed. Restore one to bring it back.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{bills.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<Trash2 className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">Nothing to recover</p>
|
||||
<p className="text-xs text-muted-foreground/70">Bills you delete will appear here for 30 days.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="-mx-2 max-h-[55vh] divide-y divide-border/60 overflow-y-auto">
|
||||
{bills.map(bill => {
|
||||
const left = daysLeftLabel(bill.days_left);
|
||||
const busy = busyId === bill.id;
|
||||
return (
|
||||
<li key={bill.id} className="flex items-center gap-3 px-2 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{bill.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{formatUSD(bill.expected_amount)}
|
||||
{bill.category_name ? ` · ${bill.category_name}` : ''}
|
||||
{left ? <span className="text-muted-foreground/60"> · {left}</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 shrink-0 gap-1.5"
|
||||
disabled={busy}
|
||||
onClick={() => handleRestore(bill)}
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="h-3.5 w-3.5" />}
|
||||
Restore
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,10 +4,10 @@ import { toast } from 'sonner';
|
|||
import { api } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatUSD } from '@/lib/money';
|
||||
|
||||
function fmtAmt(dollars) {
|
||||
return formatUSD(dollars, { dash: true });
|
||||
if (dollars == null) return '—';
|
||||
return `$${Number(dollars).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatUSD, formatCentsUSD } from '@/lib/money';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
|
@ -78,7 +77,10 @@ function fmtShortDate(date) {
|
|||
}
|
||||
|
||||
function fmtDollars(cents) {
|
||||
return formatCentsUSD(cents, { dash: true });
|
||||
if (cents == null) return '—';
|
||||
const abs = Math.abs(cents) / 100;
|
||||
const sign = cents < 0 ? '-' : '';
|
||||
return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function MatchBadge({ status, billName }) {
|
||||
|
|
@ -865,7 +867,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
|||
<span>{acc.org_name ? `${acc.org_name} — ` : ''}{acc.name}</span>
|
||||
{acc.balance_dollars !== null && (
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
{formatUSD(acc.balance_dollars)}
|
||||
${acc.balance_dollars.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ const trackerItems = [
|
|||
{ to: '/bank-transactions', icon: Landmark, label: 'Banking', simplefinOnly: true },
|
||||
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
|
||||
{ to: '/payoff', icon: Calculator, label: 'Payoff' },
|
||||
{ to: '/data', icon: Database, label: 'Data', accountToolsOnly: true },
|
||||
];
|
||||
|
||||
function TrackerMenu({ onNavigate, badge, badgeNames = [], items = trackerItems }) {
|
||||
|
|
@ -165,6 +164,10 @@ function UserMenu({ adminMode = false }) {
|
|||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => navigate('/data')}>
|
||||
<Database className="h-4 w-4" />
|
||||
Data
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
|
@ -196,13 +199,9 @@ export default function Sidebar({ adminMode = false }) {
|
|||
const { data: overdueData } = useOverdueCount();
|
||||
const overdueCount = (!adminMode && overdueData?.count) ? overdueData.count : 0;
|
||||
const overdueNames = (!adminMode && overdueData?.names) ? overdueData.names : [];
|
||||
const accountToolsAllowed = !user?.is_default_admin;
|
||||
const trackerMenuItems = useMemo(
|
||||
() => trackerItems.filter(item => (
|
||||
(!item.simplefinOnly || simplefinReady)
|
||||
&& (!item.accountToolsOnly || accountToolsAllowed)
|
||||
)),
|
||||
[simplefinReady, accountToolsAllowed],
|
||||
() => trackerItems.filter(item => !item.simplefinOnly || simplefinReady),
|
||||
[simplefinReady],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React from 'react';
|
||||
import { formatUSD } from '@/lib/money';
|
||||
|
||||
const W = 720;
|
||||
const H = 300;
|
||||
|
|
@ -14,7 +13,9 @@ function money(v) {
|
|||
}
|
||||
|
||||
function fullMoney(v) {
|
||||
return formatUSD(v);
|
||||
return (Number(v) || 0).toLocaleString(undefined, {
|
||||
style: 'currency', currency: 'USD', maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
function buildPoints(track, startBalance, maxMonths) {
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@ import React, { useState } from 'react';
|
|||
import { ChevronDown, History } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatUSD, formatUSDWhole } from '@/lib/money';
|
||||
|
||||
function fmt(v) {
|
||||
return formatUSDWhole(v);
|
||||
return (Number(v) || 0).toLocaleString(undefined, {
|
||||
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function fmtFull(v) {
|
||||
return formatUSD(v);
|
||||
return (Number(v) || 0).toLocaleString(undefined, {
|
||||
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
function dateRange(plan) {
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ import {
|
|||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatUSDWhole } from '@/lib/money';
|
||||
|
||||
function fmt(v) {
|
||||
return formatUSDWhole(v);
|
||||
return (Number(v) || 0).toLocaleString(undefined, {
|
||||
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function dateLabel(iso) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import {
|
|||
CheckCircle2, Loader2, Link2, Search, Plus,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatCentsUSD } from '@/lib/money';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
|
|
@ -20,7 +19,12 @@ export function transactionTitle(tx) {
|
|||
}
|
||||
|
||||
export function formatTransactionAmount(amount, currency = 'USD') {
|
||||
return formatCentsUSD(amount, { signed: true, currency });
|
||||
const value = Math.abs(Number(amount || 0)) / 100;
|
||||
const sign = Number(amount || 0) < 0 ? '-' : '+';
|
||||
return `${sign}${new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD',
|
||||
}).format(value)}`;
|
||||
}
|
||||
|
||||
export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading, onCreateBill }) {
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
// Currency formatting for the client, mirroring the server's utils/money.js.
|
||||
//
|
||||
// The API sends money in two units: bill / summary values are serialized as
|
||||
// DOLLARS (the server calls fromCents before responding), while raw bank
|
||||
// transaction amounts arrive as integer CENTS. So there are two entry points —
|
||||
// formatUSD(dollars) and formatCentsUSD(cents) — matching the server's
|
||||
// formatUSD / formatCentsUSD split. USD / en-US throughout (the app is USD-only,
|
||||
// and this matches the server). Inputs are coerced defensively so null, '',
|
||||
// undefined, or NaN never render as "$NaN".
|
||||
|
||||
const DASH = '—';
|
||||
|
||||
function toNumber(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return n === 0 ? 0 : n; // normalize -0 → +0 so it never renders as "-$0.00"
|
||||
}
|
||||
|
||||
function isBlank(value) {
|
||||
return value === null || value === undefined || value === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a DOLLAR amount → "$1,234.56".
|
||||
* @param {number|string|null} dollars
|
||||
* @param {{ whole?: boolean, dash?: boolean }} [opts]
|
||||
* whole — drop the cents ("$1,235"); dash — render blank input as "—" not "$0.00".
|
||||
*/
|
||||
export function formatUSD(dollars, { whole = false, dash = false } = {}) {
|
||||
if (dash && isBlank(dollars)) return DASH;
|
||||
return toNumber(dollars).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: whole ? 0 : 2,
|
||||
maximumFractionDigits: whole ? 0 : 2,
|
||||
});
|
||||
}
|
||||
|
||||
/** Whole-dollar convenience → "$1,235". */
|
||||
export function formatUSDWhole(dollars, opts = {}) {
|
||||
return formatUSD(dollars, { ...opts, whole: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an integer-CENTS amount (e.g. a bank transaction) → "$12.34".
|
||||
* @param {number|string|null} cents
|
||||
* @param {{ signed?: boolean, dash?: boolean, currency?: string }} [opts]
|
||||
* signed — prefix "+"/"-" (income vs expense); dash — blank input → "—";
|
||||
* currency — ISO code (defaults USD).
|
||||
*/
|
||||
export function formatCentsUSD(cents, { signed = false, dash = false, currency = 'USD' } = {}) {
|
||||
if (dash && isBlank(cents)) return DASH;
|
||||
const c = toNumber(cents);
|
||||
const body = (Math.abs(c) / 100).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
if (signed) return (c < 0 ? '-' : '+') + body;
|
||||
return (c < 0 ? '-' : '') + body;
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { formatUSD, formatUSDWhole, formatCentsUSD } from './money';
|
||||
|
||||
describe('formatUSD (dollars in)', () => {
|
||||
it('formats to two decimals with grouping', () => {
|
||||
expect(formatUSD(1234.5)).toBe('$1,234.50');
|
||||
expect(formatUSD(0)).toBe('$0.00');
|
||||
expect(formatUSD(8.99)).toBe('$8.99');
|
||||
});
|
||||
|
||||
it('formats negatives', () => {
|
||||
expect(formatUSD(-12.34)).toBe('-$12.34');
|
||||
});
|
||||
|
||||
it('normalizes negative zero to "$0.00" (not "-$0.00")', () => {
|
||||
expect(formatUSD(-0)).toBe('$0.00');
|
||||
expect(formatUSD(Math.round(-0.2))).toBe('$0.00'); // Math.round(-0.2) === -0
|
||||
});
|
||||
|
||||
it('coerces blank / non-numeric to $0.00 (never NaN)', () => {
|
||||
expect(formatUSD(null)).toBe('$0.00');
|
||||
expect(formatUSD(undefined)).toBe('$0.00');
|
||||
expect(formatUSD('')).toBe('$0.00');
|
||||
expect(formatUSD('not a number')).toBe('$0.00');
|
||||
});
|
||||
|
||||
it('accepts numeric strings', () => {
|
||||
expect(formatUSD('1234.5')).toBe('$1,234.50');
|
||||
});
|
||||
|
||||
it('whole:true drops the cents', () => {
|
||||
expect(formatUSD(1234.56, { whole: true })).toBe('$1,235');
|
||||
expect(formatUSDWhole(1234.4)).toBe('$1,234');
|
||||
});
|
||||
|
||||
it('dash:true renders blank input as an em dash, but 0 as $0.00', () => {
|
||||
expect(formatUSD(null, { dash: true })).toBe('—');
|
||||
expect(formatUSD(undefined, { dash: true })).toBe('—');
|
||||
expect(formatUSD('', { dash: true })).toBe('—');
|
||||
expect(formatUSD(0, { dash: true })).toBe('$0.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCentsUSD (integer cents in)', () => {
|
||||
it('converts cents to dollars', () => {
|
||||
expect(formatCentsUSD(1234)).toBe('$12.34');
|
||||
expect(formatCentsUSD(0)).toBe('$0.00');
|
||||
expect(formatCentsUSD(99)).toBe('$0.99');
|
||||
});
|
||||
|
||||
it('unsigned negatives get a leading minus only', () => {
|
||||
expect(formatCentsUSD(-1234)).toBe('-$12.34');
|
||||
});
|
||||
|
||||
it('signed:true adds +/- for income vs expense', () => {
|
||||
expect(formatCentsUSD(1234, { signed: true })).toBe('+$12.34');
|
||||
expect(formatCentsUSD(-1234, { signed: true })).toBe('-$12.34');
|
||||
expect(formatCentsUSD(0, { signed: true })).toBe('+$0.00');
|
||||
});
|
||||
|
||||
it('dash:true renders blank input as an em dash', () => {
|
||||
expect(formatCentsUSD(null, { dash: true })).toBe('—');
|
||||
expect(formatCentsUSD(undefined, { dash: true })).toBe('—');
|
||||
expect(formatCentsUSD(0, { dash: true })).toBe('$0.00');
|
||||
});
|
||||
|
||||
it('coerces non-numeric to $0.00 (never NaN)', () => {
|
||||
expect(formatCentsUSD('abc')).toBe('$0.00');
|
||||
expect(formatCentsUSD(null)).toBe('$0.00');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { formatUSD } from './money';
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Canonical dollar formatter for the app ("$1,234.50"). Kept here for the many
|
||||
// existing call sites; the implementation lives in ./money (formatUSD) so all
|
||||
// currency formatting has a single source of truth.
|
||||
export function fmt(amount) {
|
||||
return formatUSD(amount);
|
||||
const n = Number(amount) || 0;
|
||||
const sign = n < 0 ? '-' : '';
|
||||
return sign + '$' + Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
export function fmtDate(dateStr) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { formatUSD, formatUSDWhole } from '@/lib/money';
|
||||
import { Printer, RefreshCw, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
|
|
@ -28,11 +27,19 @@ function currentMonth() {
|
|||
}
|
||||
|
||||
function money(value) {
|
||||
return formatUSDWhole(value);
|
||||
return (Number(value) || 0).toLocaleString(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function fullMoney(value) {
|
||||
return formatUSD(value);
|
||||
return (Number(value) || 0).toLocaleString(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
function formatRange(range) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
|||
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
||||
import BillsTableInner from '@/components/BillsTableInner';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import RecentlyDeletedBillsDialog from '@/components/RecentlyDeletedBillsDialog';
|
||||
import { makeBillDraft } from '@/lib/billDrafts';
|
||||
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
|
||||
|
||||
|
|
@ -602,8 +601,6 @@ export default function BillsPage() {
|
|||
const [bills, setBills] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [savedTemplates, setSavedTemplates] = useState([]);
|
||||
const [deletedBills, setDeletedBills] = useState([]);
|
||||
const [showDeleted, setShowDeleted] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
|
@ -643,16 +640,14 @@ export default function BillsPage() {
|
|||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [billsRes, catRes, templateRes, deletedRes] = await Promise.all([
|
||||
const [billsRes, catRes, templateRes] = await Promise.all([
|
||||
api.allBills(),
|
||||
api.categories(),
|
||||
api.billTemplates(),
|
||||
api.deletedBills().catch(() => []), // non-critical: never block the page
|
||||
]);
|
||||
setBills(billsRes || []);
|
||||
setCategories(catRes || []);
|
||||
setSavedTemplates(templateRes || []);
|
||||
setDeletedBills(deletedRes || []);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
|
|
@ -788,16 +783,6 @@ export default function BillsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleRestoreDeleted(bill) {
|
||||
try {
|
||||
await api.restoreBill(bill.id);
|
||||
toast.success(`"${bill.name}" restored`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to restore bill');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAlternative() {
|
||||
if (!deleteTarget) return;
|
||||
const bill = deleteTarget;
|
||||
|
|
@ -955,17 +940,6 @@ export default function BillsPage() {
|
|||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{deletedBills.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleted(true)}
|
||||
className="h-9 flex-1 gap-2 px-3 text-sm sm:flex-none"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Recently deleted ({deletedBills.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setModal({ bill: null })}
|
||||
className="h-9 flex-1 gap-2 px-4 text-sm font-medium sm:flex-none"
|
||||
|
|
@ -1168,13 +1142,6 @@ export default function BillsPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<RecentlyDeletedBillsDialog
|
||||
open={showDeleted}
|
||||
onOpenChange={setShowDeleted}
|
||||
bills={deletedBills}
|
||||
onRestore={handleRestoreDeleted}
|
||||
/>
|
||||
|
||||
{/* ── Deactivate confirmation (replaces window.confirm) ── */}
|
||||
<AlertDialog open={!!deactivate} onOpenChange={open => { if (!open) { setDeactivate(null); setDeactivateReason(''); } }}>
|
||||
<AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
SelectSeparator, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatUSD, formatUSDWhole } from '@/lib/money';
|
||||
import PayoffChart from '@/components/snowball/PayoffChart';
|
||||
|
||||
// ─── Print isolation ──────────────────────────────────────────────────────────
|
||||
|
|
@ -37,11 +36,14 @@ const PRINT_STYLES = `
|
|||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(v) {
|
||||
return formatUSD(v);
|
||||
return (Number(v) || 0).toLocaleString(undefined, {
|
||||
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
function fmtShort(v) {
|
||||
return formatUSDWhole(v);
|
||||
const n = Number(v) || 0;
|
||||
return n.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function buildPayoffSchedule(balance, annualRatePct, monthlyPayment, oneTimeExtra = 0) {
|
||||
|
|
|
|||
|
|
@ -14,15 +14,19 @@ import { makeBillDraft } from '@/lib/billDrafts';
|
|||
import PlanStatusBanner from '@/components/snowball/PlanStatusBanner';
|
||||
import PlanHistoryPanel from '@/components/snowball/PlanHistoryPanel';
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
||||
import { formatUSD, formatUSDWhole } from '@/lib/money';
|
||||
|
||||
// ── formatters ────────────────────────────────────────────────────────────────
|
||||
function fmt(val) {
|
||||
return formatUSD(val, { dash: true });
|
||||
if (val == null) return '—';
|
||||
return Number(val).toLocaleString(undefined, {
|
||||
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
function fmtCompact(val) {
|
||||
if (val == null || val === 0) return '—';
|
||||
return formatUSDWhole(val);
|
||||
return Number(val).toLocaleString(undefined, {
|
||||
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
function ordinal(n) {
|
||||
const d = Number(n);
|
||||
|
|
|
|||
|
|
@ -14,12 +14,11 @@ import {
|
|||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
|
||||
import { formatUSD } from '@/lib/money';
|
||||
|
||||
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
|
||||
function fmt(n) {
|
||||
return formatUSD(n);
|
||||
return Number(n || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
}
|
||||
|
||||
function settingEnabled(value, fallback = true) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { toast } from 'sonner';
|
|||
import { api } from '@/api';
|
||||
import { cn, fmtUptime, fmtBytes } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { MarkdownText } from '@/components/MarkdownText';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -129,7 +128,7 @@ function SkeletonCard() {
|
|||
|
||||
const CATEGORY_ORDER = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security'];
|
||||
|
||||
function UpdateCard({ update, onCheckNow, checking, enabled = true, onToggle }) {
|
||||
function UpdateCard({ update, onCheckNow, checking }) {
|
||||
const hasUpdate = !!update.has_update;
|
||||
const isKnown = update.up_to_date !== null && update.up_to_date !== undefined;
|
||||
const hasError = !!update.error;
|
||||
|
|
@ -174,15 +173,8 @@ function UpdateCard({ update, onCheckNow, checking, enabled = true, onToggle })
|
|||
{update.error && (
|
||||
<p className="text-[11px] text-red-400 leading-relaxed pt-1.5">{update.error}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3 py-1.5 border-t border-border/40 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Automatic version check
|
||||
<span className="block text-[10px] text-muted-foreground/60">External request; off = no phone-home</span>
|
||||
</span>
|
||||
<Switch checked={enabled} onCheckedChange={onToggle} aria-label="Enable automatic version check" />
|
||||
</div>
|
||||
<div className="pt-3">
|
||||
<Button variant="outline" size="sm" onClick={onCheckNow} disabled={checking || !enabled}
|
||||
<Button variant="outline" size="sm" onClick={onCheckNow} disabled={checking}
|
||||
className="h-7 text-xs gap-1.5">
|
||||
<RefreshCw className={cn('h-3 w-3', checking && 'animate-spin')} />
|
||||
{checking ? 'Checking…' : 'Check Now'}
|
||||
|
|
@ -237,7 +229,6 @@ export default function StatusPage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [updateData, setUpdateData] = useState(null);
|
||||
const [updateChecking, setUpdateChecking] = useState(false);
|
||||
const [updateEnabled, setUpdateEnabled] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -252,10 +243,6 @@ export default function StatusPage() {
|
|||
const historyData = await api.releaseHistory();
|
||||
setHistoryMeta({ version: historyData.version, updated_at: historyData.updated_at });
|
||||
} catch { setHistoryMeta(null); }
|
||||
try {
|
||||
const s = await api.getUpdateCheckSetting();
|
||||
setUpdateEnabled(s.enabled !== false);
|
||||
} catch { /* keep default */ }
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to load status.');
|
||||
} finally {
|
||||
|
|
@ -276,17 +263,6 @@ export default function StatusPage() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleUpdateCheck = useCallback(async (enabled) => {
|
||||
setUpdateEnabled(enabled); // optimistic
|
||||
try {
|
||||
await api.setUpdateCheckSetting(enabled);
|
||||
if (enabled) handleCheckNow();
|
||||
} catch (err) {
|
||||
setUpdateEnabled(!enabled); // revert
|
||||
toast.error(err.message || 'Failed to update setting');
|
||||
}
|
||||
}, [handleCheckNow]);
|
||||
|
||||
// Normalize the nested response shape
|
||||
const app = data?.application ?? data?.app ?? {};
|
||||
const rt = data?.runtime ?? {};
|
||||
|
|
@ -526,8 +502,6 @@ export default function StatusPage() {
|
|||
update={updateData}
|
||||
onCheckNow={handleCheckNow}
|
||||
checking={updateChecking}
|
||||
enabled={updateEnabled}
|
||||
onToggle={handleToggleUpdateCheck}
|
||||
/>
|
||||
)}
|
||||
<ReleaseNotesCard version={version} historyMeta={historyMeta} />
|
||||
|
|
|
|||
|
|
@ -498,7 +498,7 @@ function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, bus
|
|||
}
|
||||
|
||||
function TxResultRow({ tx, onTrack }) {
|
||||
const dollars = fmt(Math.abs(tx.amount) / 100);
|
||||
const dollars = (Math.abs(tx.amount) / 100).toFixed(2);
|
||||
const label = tx.payee || tx.description || tx.memo || '—';
|
||||
const account = tx.account_name || tx.data_source_name || null;
|
||||
const isMatched = tx.match_status === 'matched';
|
||||
|
|
@ -545,7 +545,7 @@ function TxResultRow({ tx, onTrack }) {
|
|||
{catalogMatch ? ` · ${TYPE_LABELS[catalogMatch.subscription_type] || 'Other'}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-mono text-sm font-semibold tabular-nums shrink-0">{dollars}</span>
|
||||
<span className="font-mono text-sm font-semibold tabular-nums shrink-0">${dollars}</span>
|
||||
{!isMatched && (
|
||||
<Button size="sm" variant="outline" onClick={() => onTrack(tx)}
|
||||
className="h-8 shrink-0 gap-1.5 px-2.5 text-xs">
|
||||
|
|
|
|||
2927
db/database.js
2927
db/database.js
File diff suppressed because it is too large
Load Diff
|
|
@ -1,853 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// The legacy-reconcile migration list, extracted from db/database.js. Same
|
||||
// factory pattern as versionedMigrations: each check/run body closes over the
|
||||
// live db + schema helpers, injected here. Runs only for legacy databases whose
|
||||
// schema predates migration tracking (and for a freshly schema.sql'd DB, whose
|
||||
// schema_migrations starts empty). Consumed by reconcileLegacyMigrations().
|
||||
module.exports = function buildLegacyReconcileMigrations(deps) {
|
||||
const {
|
||||
db,
|
||||
isValidColumnName,
|
||||
isValidSqlDefinition,
|
||||
ensureTransactionFoundationSchema,
|
||||
runSubscriptionCatalogMigration,
|
||||
runSubscriptionCatalogV2Migration,
|
||||
runAdvisoryFiltersMigration,
|
||||
runMerchantStoreMatchMigration,
|
||||
} = deps;
|
||||
return [
|
||||
{
|
||||
version: 'v0.2',
|
||||
description: 'payments: soft-delete column',
|
||||
check: function() {
|
||||
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
||||
return paymentCols.includes('deleted_at');
|
||||
},
|
||||
run: function() {
|
||||
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
||||
if (!paymentCols.includes('deleted_at')) {
|
||||
db.exec('ALTER TABLE payments ADD COLUMN deleted_at TEXT');
|
||||
// Index for fast filtering of live payments
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_deleted ON payments(deleted_at)');
|
||||
console.log('[migration] payments.deleted_at column added');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.3',
|
||||
description: 'payments: compound index for tracker query',
|
||||
check: function() {
|
||||
// Check if the index exists
|
||||
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_payments_bill_date_del'").all();
|
||||
return indexes.length > 0;
|
||||
},
|
||||
run: function() {
|
||||
// Supports: WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_bill_date_del ON payments(bill_id, paid_date, deleted_at)');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.4',
|
||||
description: 'monthly_bill_state: per-bill per-month overrides',
|
||||
check: function() {
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_bill_state'").all();
|
||||
return tables.length > 0;
|
||||
},
|
||||
run: function() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS monthly_bill_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
||||
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
||||
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
||||
actual_amount REAL,
|
||||
notes TEXT,
|
||||
is_skipped INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(bill_id, year, month)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)');
|
||||
console.log('[migration] monthly_bill_state table ensured');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.13',
|
||||
description: 'users: profile columns',
|
||||
check: function() {
|
||||
const userColsNow = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
const profileCols = ['display_name', 'last_password_change_at'];
|
||||
return profileCols.every(col => userColsNow.includes(col));
|
||||
},
|
||||
run: function() {
|
||||
const userColsNow = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
const profileCols = [
|
||||
['display_name', 'TEXT'],
|
||||
['last_password_change_at','TEXT'],
|
||||
];
|
||||
for (const [col, def] of profileCols) {
|
||||
if (!userColsNow.includes(col)) {
|
||||
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
|
||||
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
|
||||
throw new Error(`Invalid migration: column '${col}' not in whitelist`);
|
||||
}
|
||||
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.14',
|
||||
description: 'bills: history visibility mode',
|
||||
check: function() {
|
||||
const billColsHist = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
return billColsHist.includes('history_visibility');
|
||||
},
|
||||
run: function() {
|
||||
const billColsHist = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
if (!billColsHist.includes('history_visibility')) {
|
||||
db.exec("ALTER TABLE bills ADD COLUMN history_visibility TEXT NOT NULL DEFAULT 'default'");
|
||||
console.log('[migration] bills.history_visibility column added');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.14.4',
|
||||
description: 'bills: optional credit-card APR / interest rate',
|
||||
check: function() {
|
||||
const billColsInterest = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
return billColsInterest.includes('interest_rate');
|
||||
},
|
||||
run: function() {
|
||||
const billColsInterest = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
if (!billColsInterest.includes('interest_rate')) {
|
||||
db.exec('ALTER TABLE bills ADD COLUMN interest_rate REAL');
|
||||
console.log('[migration] bills.interest_rate column added');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.15',
|
||||
description: 'import_sessions and import_history tables',
|
||||
check: function() {
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('import_sessions', 'import_history')").all();
|
||||
return tables.length >= 2;
|
||||
},
|
||||
run: function() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS import_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
preview_json TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_user ON import_sessions(user_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_expires ON import_sessions(expires_at)');
|
||||
|
||||
// ── import_history: per-user audit log (v0.38) ────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS import_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
imported_at TEXT NOT NULL,
|
||||
source_filename TEXT,
|
||||
file_type TEXT DEFAULT 'xlsx',
|
||||
sheet_name TEXT,
|
||||
rows_parsed INTEGER DEFAULT 0,
|
||||
rows_created INTEGER DEFAULT 0,
|
||||
rows_updated INTEGER DEFAULT 0,
|
||||
rows_skipped INTEGER DEFAULT 0,
|
||||
rows_ambiguous INTEGER DEFAULT 0,
|
||||
rows_errored INTEGER DEFAULT 0,
|
||||
options_json TEXT,
|
||||
summary_json TEXT
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_user ON import_history(user_id)');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.17',
|
||||
description: 'users: external identity / OIDC columns',
|
||||
check: function() {
|
||||
const userColsOidc = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
const oidcUserCols = ['auth_provider', 'external_subject', 'email', 'last_login_at'];
|
||||
return oidcUserCols.every(col => userColsOidc.includes(col));
|
||||
},
|
||||
run: function() {
|
||||
const userColsOidc = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
const oidcUserCols = [
|
||||
['auth_provider', "TEXT NOT NULL DEFAULT 'local'"],
|
||||
['external_subject', 'TEXT'],
|
||||
['email', 'TEXT'],
|
||||
['last_login_at', 'TEXT'],
|
||||
];
|
||||
for (const [col, def] of oidcUserCols) {
|
||||
if (!userColsOidc.includes(col)) {
|
||||
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
|
||||
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
|
||||
throw new Error(`Invalid migration: column '${col}' not in whitelist`);
|
||||
}
|
||||
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── oidc_states: short-lived PKCE + nonce state for OIDC login (v0.17) ───
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS oidc_states (
|
||||
id TEXT PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
code_verifier TEXT NOT NULL,
|
||||
redirect_to TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_oidc_states_expires ON oidc_states(expires_at)');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.18.1',
|
||||
description: 'monthly_income: per-user monthly income for Summary planning',
|
||||
check: function() {
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_income'").all();
|
||||
return tables.length > 0;
|
||||
},
|
||||
run: function() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS monthly_income (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
||||
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
||||
label TEXT NOT NULL DEFAULT 'Salary',
|
||||
amount REAL NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, year, month)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.18.2',
|
||||
description: 'monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th',
|
||||
check: function() {
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_starting_amounts'").all();
|
||||
return tables.length > 0;
|
||||
},
|
||||
run: function() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS monthly_starting_amounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
||||
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
||||
first_amount REAL NOT NULL DEFAULT 0 CHECK(first_amount >= 0),
|
||||
fifteenth_amount REAL NOT NULL DEFAULT 0 CHECK(fifteenth_amount >= 0),
|
||||
other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0),
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, year, month)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.18.3',
|
||||
description: 'monthly_starting_amounts: add other_amount column',
|
||||
check: function() {
|
||||
const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name);
|
||||
return startingCols.includes('other_amount');
|
||||
},
|
||||
run: function() {
|
||||
const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name);
|
||||
if (!startingCols.includes('other_amount')) {
|
||||
// Security FIX (2026-05-08): Validate column name to prevent SQL injection
|
||||
if (!isValidColumnName('other_amount')) {
|
||||
throw new Error('Invalid migration: column other_amount not in whitelist');
|
||||
}
|
||||
db.exec('ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)');
|
||||
console.log('[migration] monthly_starting_amounts.other_amount column added');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.38',
|
||||
description: 'import_history: per-user audit log',
|
||||
check: function() {
|
||||
// Already handled in v0.15
|
||||
return true;
|
||||
},
|
||||
run: function() {
|
||||
// This was already handled in v0.15, but keeping for completeness
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.40',
|
||||
description: 'ownership: user-scoped bills/categories',
|
||||
check: function() {
|
||||
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
||||
return billCols.includes('user_id') && categoryCols.includes('user_id');
|
||||
},
|
||||
run: function() {
|
||||
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
if (!billCols.includes('user_id')) {
|
||||
db.exec('ALTER TABLE bills ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE');
|
||||
}
|
||||
const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
||||
if (!categoryCols.includes('user_id')) {
|
||||
db.exec('ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE');
|
||||
}
|
||||
const categorySql = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='categories'").get()?.sql || '';
|
||||
if (/name\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i.test(categorySql)) {
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS categories_v040 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('INSERT INTO categories_v040 (id, user_id, name, created_at, updated_at) SELECT id, user_id, name, created_at, updated_at FROM categories');
|
||||
db.exec('DROP TABLE categories');
|
||||
db.exec('ALTER TABLE categories_v040 RENAME TO categories');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
|
||||
const firstAdmin = db.prepare("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get();
|
||||
if (firstAdmin) {
|
||||
db.prepare('UPDATE bills SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id);
|
||||
// Drop any NULL-owner categories whose name already exists for this admin (case-insensitive)
|
||||
// to prevent a UNIQUE(user_id, name) violation when we assign them below.
|
||||
db.prepare(`
|
||||
DELETE FROM categories
|
||||
WHERE user_id IS NULL
|
||||
AND LOWER(name) IN (
|
||||
SELECT LOWER(name) FROM categories WHERE user_id = ?
|
||||
)
|
||||
`).run(firstAdmin.id);
|
||||
db.prepare('UPDATE categories SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id);
|
||||
}
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_active ON bills(user_id, active)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_name ON categories(user_id, name)');
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_categories_user_name_unique ON categories(user_id, name COLLATE NOCASE)');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.41',
|
||||
description: 'bills and categories: is_seeded flag for demo data cleanup',
|
||||
check: function() {
|
||||
const billColsSeeded = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
const categoryColsSeeded = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
||||
return billColsSeeded.includes('is_seeded') && categoryColsSeeded.includes('is_seeded');
|
||||
},
|
||||
run: function() {
|
||||
// ── bills: is_seeded flag for demo data cleanup (v0.41) ───────────────────
|
||||
const billColsSeeded = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
if (!billColsSeeded.includes('is_seeded')) {
|
||||
db.exec('ALTER TABLE bills ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0');
|
||||
console.log('[migration] bills.is_seeded column added');
|
||||
}
|
||||
|
||||
// ── categories: is_seeded flag for demo data cleanup (v0.41) ──────────────
|
||||
const categoryColsSeeded = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
||||
if (!categoryColsSeeded.includes('is_seeded')) {
|
||||
db.exec('ALTER TABLE categories ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0');
|
||||
console.log('[migration] categories.is_seeded column added');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.42',
|
||||
description: 'bill_history_ranges: per-bill date ranges for history visibility',
|
||||
check: function() {
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_history_ranges'").all();
|
||||
return tables.length > 0;
|
||||
},
|
||||
run: function() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bill_history_ranges (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
||||
start_year INTEGER NOT NULL,
|
||||
start_month INTEGER NOT NULL,
|
||||
end_year INTEGER,
|
||||
end_month INTEGER,
|
||||
label TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_bill_history_ranges_bill ON bill_history_ranges(bill_id)');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.43',
|
||||
description: 'sessions: add created_at column',
|
||||
check: function() {
|
||||
const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name);
|
||||
return sessionCols.includes('created_at');
|
||||
},
|
||||
run: function() {
|
||||
const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name);
|
||||
if (!sessionCols.includes('created_at')) {
|
||||
// Security FIX (2026-05-09): Validate column name to prevent SQL injection
|
||||
if (!isValidColumnName('created_at')) {
|
||||
throw new Error('Invalid migration: column created_at not in whitelist');
|
||||
}
|
||||
db.exec("ALTER TABLE sessions ADD COLUMN created_at TEXT DEFAULT (datetime('now'))");
|
||||
console.log('[migration] sessions.created_at column added');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.44',
|
||||
description: 'performance: add missing indexes for frequently queried columns',
|
||||
check: function() {
|
||||
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_bills_user_name'").get();
|
||||
},
|
||||
run: function() {
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_name ON bills(user_id, name)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_method ON payments(method)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user ON monthly_starting_amounts(user_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_imported_at ON import_history(imported_at)');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.45',
|
||||
description: 'audit: add audit_log table for security event tracking',
|
||||
check: function() {
|
||||
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='audit_log'").get();
|
||||
},
|
||||
run: function() {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id INTEGER,
|
||||
details_json TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at)');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.46',
|
||||
description: 'billing: add cycle_type and cycle_day columns to bills',
|
||||
check: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
return cols.includes('cycle_type') && cols.includes('cycle_day');
|
||||
},
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
if (!cols.includes('cycle_type')) {
|
||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
|
||||
}
|
||||
if (!cols.includes('cycle_day')) {
|
||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.47',
|
||||
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
|
||||
check: function() {
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'backup_schedule_retention_count'").get();
|
||||
return !row || row.value !== '14';
|
||||
},
|
||||
run: function() {
|
||||
db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run();
|
||||
console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.48',
|
||||
description: 'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)',
|
||||
check: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
return ['current_balance', 'minimum_payment', 'snowball_order', 'snowball_include'].every(c => cols.includes(c));
|
||||
},
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
if (!cols.includes('current_balance')) db.exec('ALTER TABLE bills ADD COLUMN current_balance REAL');
|
||||
if (!cols.includes('minimum_payment')) db.exec('ALTER TABLE bills ADD COLUMN minimum_payment REAL');
|
||||
if (!cols.includes('snowball_order')) db.exec('ALTER TABLE bills ADD COLUMN snowball_order INTEGER');
|
||||
if (!cols.includes('snowball_include')) db.exec('ALTER TABLE bills ADD COLUMN snowball_include INTEGER NOT NULL DEFAULT 0');
|
||||
console.log('[migration] bills: debt snowball columns added');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.49',
|
||||
description: 'users: snowball_extra_payment column',
|
||||
check: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
return cols.includes('snowball_extra_payment');
|
||||
},
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
if (!cols.includes('snowball_extra_payment')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN snowball_extra_payment REAL NOT NULL DEFAULT 0');
|
||||
}
|
||||
console.log('[migration] users: snowball_extra_payment column added');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.50',
|
||||
description: 'payments: balance_delta column for debt payoff tracking',
|
||||
check: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
||||
return cols.includes('balance_delta');
|
||||
},
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
||||
if (!cols.includes('balance_delta')) {
|
||||
db.exec('ALTER TABLE payments ADD COLUMN balance_delta REAL');
|
||||
}
|
||||
console.log('[migration] payments: balance_delta column added');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.51',
|
||||
description: 'bills: snowball_exempt column for hiding debt-like bills',
|
||||
check: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
return cols.includes('snowball_exempt');
|
||||
},
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
if (!cols.includes('snowball_exempt')) {
|
||||
db.exec('ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
console.log('[migration] bills: snowball_exempt column added');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.52',
|
||||
description: 'users: last_seen_version for release-notes notifications',
|
||||
check: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
return cols.includes('last_seen_version');
|
||||
},
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
if (!cols.includes('last_seen_version')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN last_seen_version TEXT');
|
||||
}
|
||||
console.log('[migration] users: last_seen_version column added');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.53',
|
||||
description: 'user_login_history: track last 3 logins per user',
|
||||
check: function() {
|
||||
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_login_history'").get();
|
||||
},
|
||||
run: function() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_login_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
logged_in_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
browser TEXT,
|
||||
os TEXT,
|
||||
device_type TEXT,
|
||||
device_fingerprint TEXT
|
||||
)
|
||||
`);
|
||||
console.log('[migration] user_login_history table created');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.54',
|
||||
description: 'user_settings: per-user display and billing preferences',
|
||||
check: function() {
|
||||
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'").get();
|
||||
},
|
||||
run: function() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, key)
|
||||
)
|
||||
`);
|
||||
const userSettingKeys = ['currency', 'date_format', 'grace_period_days', 'notify_days_before'];
|
||||
const users = db.prepare('SELECT id FROM users').all();
|
||||
const getCurrent = db.prepare('SELECT value FROM settings WHERE key = ?');
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)');
|
||||
for (const user of users) {
|
||||
for (const key of userSettingKeys) {
|
||||
const row = getCurrent.get(key);
|
||||
if (row) insert.run(user.id, key, row.value);
|
||||
}
|
||||
}
|
||||
console.log('[migration] user_settings table ensured');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.55',
|
||||
description: 'user_login_history: parsed device metadata and fingerprint',
|
||||
check: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name);
|
||||
return ['browser', 'os', 'device_type', 'device_fingerprint'].every(c => cols.includes(c));
|
||||
},
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name);
|
||||
for (const col of ['browser', 'os', 'device_type', 'device_fingerprint']) {
|
||||
if (!cols.includes(col)) db.exec(`ALTER TABLE user_login_history ADD COLUMN ${col} TEXT`);
|
||||
}
|
||||
console.log('[migration] user_login_history device metadata columns ensured');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.56',
|
||||
description: 'bills/categories: soft-delete columns',
|
||||
check: function() {
|
||||
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
||||
return billCols.includes('deleted_at') && catCols.includes('deleted_at');
|
||||
},
|
||||
run: function() {
|
||||
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
||||
if (!billCols.includes('deleted_at')) {
|
||||
db.exec('ALTER TABLE bills ADD COLUMN deleted_at TEXT');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_deleted ON bills(user_id, deleted_at)');
|
||||
}
|
||||
if (!catCols.includes('deleted_at')) {
|
||||
db.exec('ALTER TABLE categories ADD COLUMN deleted_at TEXT');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(user_id, deleted_at)');
|
||||
}
|
||||
console.log('[migration] bills/categories deleted_at columns added');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.57',
|
||||
description: 'autopay: suggestions and auto-mark paid',
|
||||
check: function() {
|
||||
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
const hasDismissals = !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='autopay_suggestion_dismissals'").get();
|
||||
return billCols.includes('auto_mark_paid') && hasDismissals;
|
||||
},
|
||||
run: function() {
|
||||
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
if (!billCols.includes('auto_mark_paid')) {
|
||||
db.exec('ALTER TABLE bills ADD COLUMN auto_mark_paid INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS autopay_suggestion_dismissals (
|
||||
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,
|
||||
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
||||
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
||||
dismissed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, bill_id, year, month)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_autopay_suggestion_dismissals_user_month
|
||||
ON autopay_suggestion_dismissals(user_id, year, month);
|
||||
`);
|
||||
console.log('[migration] autopay auto_mark_paid and suggestion dismissals ensured');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.58',
|
||||
description: 'bills: saved bill templates',
|
||||
check: function() {
|
||||
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_templates'").get();
|
||||
},
|
||||
run: function() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bill_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_bill_templates_user_name
|
||||
ON bill_templates(user_id, name);
|
||||
`);
|
||||
console.log('[migration] bill_templates table ensured');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.59',
|
||||
description: 'payments: source metadata for future transaction matching',
|
||||
check: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
||||
return cols.includes('payment_source') && cols.includes('transaction_id');
|
||||
},
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
||||
if (!cols.includes('payment_source')) {
|
||||
db.exec("ALTER TABLE payments ADD COLUMN payment_source TEXT NOT NULL DEFAULT 'manual'");
|
||||
}
|
||||
if (!cols.includes('transaction_id')) {
|
||||
db.exec('ALTER TABLE payments ADD COLUMN transaction_id INTEGER');
|
||||
}
|
||||
console.log('[migration] payments: source metadata columns added');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.60',
|
||||
description: 'transactions: shared transaction foundation tables',
|
||||
check: function() {
|
||||
const tables = db.prepare(`
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name IN ('data_sources', 'financial_accounts', 'transactions')
|
||||
`).all();
|
||||
return tables.length === 3;
|
||||
},
|
||||
run: function() {
|
||||
ensureTransactionFoundationSchema(db);
|
||||
console.log('[migration] transaction foundation tables ensured');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.61',
|
||||
description: 'payments: one active payment per linked transaction',
|
||||
check: function() {
|
||||
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_payments_transaction_active'").get();
|
||||
},
|
||||
run: function() {
|
||||
db.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_transaction_active
|
||||
ON payments(transaction_id)
|
||||
WHERE transaction_id IS NOT NULL AND deleted_at IS NULL
|
||||
`);
|
||||
console.log('[migration] payments: transaction active unique index ensured');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.62',
|
||||
description: 'matches: rejected transaction match suggestions',
|
||||
check: function() {
|
||||
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='match_suggestion_rejections'").get();
|
||||
},
|
||||
run: function() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS match_suggestion_rejections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
||||
rejected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, transaction_id, bill_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_match_suggestion_rejections_user
|
||||
ON match_suggestion_rejections(user_id, transaction_id, bill_id);
|
||||
`);
|
||||
console.log('[migration] match suggestion rejections table ensured');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.63',
|
||||
description: 'bills: subscription metadata fields',
|
||||
check: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
return ['is_subscription', 'subscription_type', 'reminder_days_before', 'subscription_source', 'subscription_detected_at']
|
||||
.every(col => cols.includes(col));
|
||||
},
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
if (!cols.includes('is_subscription')) db.exec('ALTER TABLE bills ADD COLUMN is_subscription INTEGER NOT NULL DEFAULT 0');
|
||||
if (!cols.includes('subscription_type')) db.exec('ALTER TABLE bills ADD COLUMN subscription_type TEXT');
|
||||
if (!cols.includes('reminder_days_before')) db.exec('ALTER TABLE bills ADD COLUMN reminder_days_before INTEGER NOT NULL DEFAULT 3');
|
||||
if (!cols.includes('subscription_source')) db.exec("ALTER TABLE bills ADD COLUMN subscription_source TEXT NOT NULL DEFAULT 'manual'");
|
||||
if (!cols.includes('subscription_detected_at')) db.exec('ALTER TABLE bills ADD COLUMN subscription_detected_at TEXT');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_subscription ON bills(user_id, is_subscription, active)');
|
||||
console.log('[migration] bills: subscription metadata columns added');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.64',
|
||||
description: 'financial_accounts: monitored flag for bill matching',
|
||||
check: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name);
|
||||
return cols.includes('monitored');
|
||||
},
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name);
|
||||
if (!cols.includes('monitored')) {
|
||||
db.exec('ALTER TABLE financial_accounts ADD COLUMN monitored INTEGER NOT NULL DEFAULT 1');
|
||||
console.log('[migration] financial_accounts: monitored column added');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.65',
|
||||
description: 'subscription_catalog: top-200 known subscription services',
|
||||
check: function() {
|
||||
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='subscription_catalog'").get();
|
||||
},
|
||||
run: function() {
|
||||
runSubscriptionCatalogMigration(db);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.66',
|
||||
description: 'declined_subscription_hints: per-user dismissed recommendation store',
|
||||
check: function() {
|
||||
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='declined_subscription_hints'").get();
|
||||
},
|
||||
run: function() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS declined_subscription_hints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
decline_key TEXT NOT NULL,
|
||||
declined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, decline_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_declined_hints_user
|
||||
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);
|
||||
`);
|
||||
}
|
||||
}
|
||||
];
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,324 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// Seed rows for the subscription_catalog table, extracted from db/database.js
|
||||
// to keep that module focused on schema + migrations. Pure data, no logic.
|
||||
|
||||
// ── Subscription catalog seed ─────────────────────────────────────────────────
|
||||
// [rank, name, category, subscription_type, website, domain]
|
||||
const SUBSCRIPTION_CATALOG_ROWS = [
|
||||
[1,'Netflix','Video Streaming','streaming','https://www.netflix.com/','netflix.com'],
|
||||
[2,'Amazon Prime Video','Video Streaming','streaming','https://www.primevideo.com/','primevideo.com'],
|
||||
[3,'Hulu','Video Streaming','streaming','https://www.hulu.com/','hulu.com'],
|
||||
[4,'Disney+','Video Streaming','streaming','https://www.disneyplus.com/','disneyplus.com'],
|
||||
[5,'Max','Video Streaming','streaming','https://www.max.com/','max.com'],
|
||||
[6,'Peacock','Video Streaming','streaming','https://www.peacocktv.com/','peacocktv.com'],
|
||||
[7,'Paramount+','Video Streaming','streaming','https://www.paramountplus.com/','paramountplus.com'],
|
||||
[8,'Apple TV+','Video Streaming','streaming','https://tv.apple.com/','tv.apple.com'],
|
||||
[9,'YouTube Premium','Video Streaming','streaming','https://www.youtube.com/premium','youtube.com'],
|
||||
[10,'ESPN+','Sports Streaming','streaming','https://plus.espn.com/','plus.espn.com'],
|
||||
[11,'YouTube TV','Live TV Streaming','streaming','https://tv.youtube.com/','tv.youtube.com'],
|
||||
[12,'Sling TV','Live TV Streaming','streaming','https://www.sling.com/','sling.com'],
|
||||
[13,'Fubo','Live TV Streaming','streaming','https://www.fubo.tv/','fubo.tv'],
|
||||
[14,'DirecTV Stream','Live TV Streaming','streaming','https://streamtv.directv.com/','streamtv.directv.com'],
|
||||
[15,'Philo','Live TV Streaming','streaming','https://www.philo.com/','philo.com'],
|
||||
[16,'Starz','Video Streaming','streaming','https://www.starz.com/','starz.com'],
|
||||
[17,'MGM+','Video Streaming','streaming','https://www.mgmplus.com/','mgmplus.com'],
|
||||
[18,'AMC+','Video Streaming','streaming','https://www.amcplus.com/','amcplus.com'],
|
||||
[19,'BET+','Video Streaming','streaming','https://www.bet.plus/','bet.plus'],
|
||||
[20,'Crunchyroll','Video Streaming','streaming','https://www.crunchyroll.com/','crunchyroll.com'],
|
||||
[21,'HIDIVE','Video Streaming','streaming','https://www.hidive.com/','hidive.com'],
|
||||
[22,'Shudder','Video Streaming','streaming','https://www.shudder.com/','shudder.com'],
|
||||
[23,'Acorn TV','Video Streaming','streaming','https://acorn.tv/','acorn.tv'],
|
||||
[24,'BritBox','Video Streaming','streaming','https://www.britbox.com/','britbox.com'],
|
||||
[25,'The Criterion Channel','Video Streaming','streaming','https://www.criterionchannel.com/','criterionchannel.com'],
|
||||
[26,'MUBI','Video Streaming','streaming','https://mubi.com/','mubi.com'],
|
||||
[27,'Discovery+','Video Streaming','streaming','https://www.discoveryplus.com/','discoveryplus.com'],
|
||||
[28,'Hallmark+','Video Streaming','streaming','https://www.hallmarkplus.com/','hallmarkplus.com'],
|
||||
[29,'PBS Passport','Video Streaming','streaming','https://www.pbs.org/passport/','pbs.org'],
|
||||
[30,'MagellanTV','Video Streaming','streaming','https://www.magellantv.com/','magellantv.com'],
|
||||
[31,'Curiosity Stream','Video Streaming','streaming','https://curiositystream.com/','curiositystream.com'],
|
||||
[32,'Nebula','Video Streaming','streaming','https://nebula.tv/','nebula.tv'],
|
||||
[33,'WOW Presents Plus','Video Streaming','streaming','https://www.wowpresentsplus.com/','wowpresentsplus.com'],
|
||||
[34,'ViX Premium','Video Streaming','streaming','https://vix.com/','vix.com'],
|
||||
[35,'FloSports','Sports Streaming','streaming','https://www.flosports.tv/','flosports.tv'],
|
||||
[36,'DAZN','Sports Streaming','streaming','https://www.dazn.com/','dazn.com'],
|
||||
[37,'MLB.TV','Sports Streaming','streaming','https://www.mlb.com/live-stream-games/subscribe','mlb.com'],
|
||||
[38,'NBA League Pass','Sports Streaming','streaming','https://www.nba.com/watch/league-pass-stream','nba.com'],
|
||||
[39,'NHL Power Play on ESPN+','Sports Streaming','streaming','https://www.espn.com/espnplus/catalog/nhl','espn.com'],
|
||||
[40,'NFL+','Sports Streaming','streaming','https://www.nfl.com/plus/','nfl.com'],
|
||||
[41,'Spotify Premium','Music & Audio','music','https://www.spotify.com/premium/','spotify.com'],
|
||||
[42,'Apple Music','Music & Audio','music','https://www.apple.com/apple-music/','apple.com'],
|
||||
[43,'Amazon Music Unlimited','Music & Audio','music','https://music.amazon.com/','music.amazon.com'],
|
||||
[44,'Pandora Premium','Music & Audio','music','https://www.pandora.com/upgrade','pandora.com'],
|
||||
[45,'SiriusXM','Music & Audio','music','https://www.siriusxm.com/','siriusxm.com'],
|
||||
[46,'TIDAL','Music & Audio','music','https://tidal.com/','tidal.com'],
|
||||
[47,'Qobuz','Music & Audio','music','https://www.qobuz.com/us-en/music/streaming/offers','qobuz.com'],
|
||||
[48,'SoundCloud Go+','Music & Audio','music','https://soundcloud.com/go','soundcloud.com'],
|
||||
[49,'Deezer','Music & Audio','music','https://www.deezer.com/us/offers','deezer.com'],
|
||||
[50,'iHeartRadio Plus','Music & Audio','music','https://www.iheart.com/plus/','iheart.com'],
|
||||
[51,'Audible','Audiobooks','education','https://www.audible.com/','audible.com'],
|
||||
[52,'Spotify Audiobooks','Audiobooks','education','https://www.spotify.com/us/audiobooks/','spotify.com'],
|
||||
[53,'Everand','Audiobooks & Ebooks','education','https://www.everand.com/','everand.com'],
|
||||
[54,'Scribd','Documents & Ebooks','education','https://www.scribd.com/','scribd.com'],
|
||||
[55,'Kindle Unlimited','Ebooks','education','https://www.amazon.com/kindle-dbs/hz/subscribe/ku','amazon.com'],
|
||||
[56,'Kobo Plus','Ebooks & Audiobooks','education','https://www.kobo.com/us/en/plus','kobo.com'],
|
||||
[57,'Libro.fm','Audiobooks','education','https://libro.fm/','libro.fm'],
|
||||
[58,'Blinkist','Books & Learning','education','https://www.blinkist.com/','blinkist.com'],
|
||||
[59,'Pocket Casts Plus','Podcasts','music','https://pocketcasts.com/plus/','pocketcasts.com'],
|
||||
[60,'Wondery+','Podcasts','music','https://wondery.com/plus/','wondery.com'],
|
||||
[61,'The New York Times','News & Magazines','news','https://www.nytimes.com/subscription','nytimes.com'],
|
||||
[62,'The Wall Street Journal','News & Magazines','news','https://www.wsj.com/news/subscribe','wsj.com'],
|
||||
[63,'The Washington Post','News & Magazines','news','https://subscribe.washingtonpost.com/','subscribe.washingtonpost.com'],
|
||||
[64,'The Atlantic','News & Magazines','news','https://www.theatlantic.com/subscribe/','theatlantic.com'],
|
||||
[65,'The New Yorker','News & Magazines','news','https://www.newyorker.com/subscribe','newyorker.com'],
|
||||
[66,'Bloomberg.com','News & Magazines','news','https://www.bloomberg.com/subscriptions','bloomberg.com'],
|
||||
[67,'Financial Times','News & Magazines','news','https://www.ft.com/products','ft.com'],
|
||||
[68,'The Economist','News & Magazines','news','https://www.economist.com/subscribe','economist.com'],
|
||||
[69,'TIME','News & Magazines','news','https://time.com/subscribe/','time.com'],
|
||||
[70,'WIRED','News & Magazines','news','https://www.wired.com/subscribe/','wired.com'],
|
||||
[71,'Consumer Reports','News & Magazines','news','https://www.consumerreports.org/join/','consumerreports.org'],
|
||||
[72,'Politico Pro','News & Magazines','news','https://www.politicopro.com/','politicopro.com'],
|
||||
[73,'The Athletic','Sports Media','streaming','https://theathletic.com/','theathletic.com'],
|
||||
[74,'Substack','Creator Media','news','https://substack.com/','substack.com'],
|
||||
[75,'Medium','Creator Media','news','https://medium.com/membership','medium.com'],
|
||||
[76,'Patreon','Creator Media','news','https://www.patreon.com/','patreon.com'],
|
||||
[77,'Apple News+','News & Magazines','news','https://www.apple.com/apple-news/','apple.com'],
|
||||
[78,'Readly','News & Magazines','news','https://us.readly.com/','us.readly.com'],
|
||||
[79,'PressReader','News & Magazines','news','https://www.pressreader.com/','pressreader.com'],
|
||||
[80,'The Information','News & Magazines','news','https://www.theinformation.com/subscribe','theinformation.com'],
|
||||
[81,'Microsoft 365','Software & Productivity','software','https://www.microsoft.com/microsoft-365','microsoft.com'],
|
||||
[82,'Google One','Cloud & Storage','cloud','https://one.google.com/','one.google.com'],
|
||||
[83,'iCloud+','Cloud & Storage','cloud','https://www.apple.com/icloud/','apple.com'],
|
||||
[84,'Dropbox','Cloud & Storage','cloud','https://www.dropbox.com/plans','dropbox.com'],
|
||||
[85,'Box','Cloud & Storage','cloud','https://www.box.com/pricing','box.com'],
|
||||
[86,'Adobe Creative Cloud','Software & Design','software','https://www.adobe.com/creativecloud.html','adobe.com'],
|
||||
[87,'Canva Pro','Software & Design','software','https://www.canva.com/pro/','canva.com'],
|
||||
[88,'Figma','Software & Design','software','https://www.figma.com/pricing/','figma.com'],
|
||||
[89,'Notion','Software & Productivity','software','https://www.notion.so/pricing','notion.so'],
|
||||
[90,'Evernote','Software & Productivity','software','https://evernote.com/compare-plans','evernote.com'],
|
||||
[91,'Todoist','Software & Productivity','software','https://todoist.com/pricing','todoist.com'],
|
||||
[92,'Grammarly','Writing & AI','software','https://www.grammarly.com/plans','grammarly.com'],
|
||||
[93,'ChatGPT','AI','software','https://chatgpt.com/pricing','chatgpt.com'],
|
||||
[94,'Claude.ai','AI','software','https://claude.ai/upgrade','anthropic.com'],
|
||||
[95,'Perplexity','AI','software','https://www.perplexity.ai/pro','perplexity.ai'],
|
||||
[96,'Gemini Advanced','AI','software','https://one.google.com/about/google-ai-plans/','one.google.com'],
|
||||
[97,'GitHub Copilot','Developer Tools','software','https://github.com/features/copilot/plans','github.com'],
|
||||
[98,'Cursor','Developer Tools','software','https://www.cursor.com/pricing','cursor.com'],
|
||||
[99,'Replit','Developer Tools','software','https://replit.com/pricing','replit.com'],
|
||||
[100,'Setapp','Software & Productivity','software','https://setapp.com/','setapp.com'],
|
||||
[101,'1Password','Security','security','https://1password.com/pricing','1password.com'],
|
||||
[102,'Dashlane','Security','security','https://www.dashlane.com/pricing','dashlane.com'],
|
||||
[103,'NordVPN','Security','security','https://nordvpn.com/pricing/','nordvpn.com'],
|
||||
[104,'ExpressVPN','Security','security','https://www.expressvpn.com/','expressvpn.com'],
|
||||
[105,'Surfshark','Security','security','https://surfshark.com/pricing','surfshark.com'],
|
||||
[106,'Norton 360','Security','security','https://us.norton.com/products','us.norton.com'],
|
||||
[107,'McAfee+','Security','security','https://www.mcafee.com/en-us/consumer-support/pricing.html','mcafee.com'],
|
||||
[108,'QuickBooks Online','Finance Software','software','https://quickbooks.intuit.com/pricing/','quickbooks.intuit.com'],
|
||||
[109,'TurboTax Live','Finance Software','software','https://turbotax.intuit.com/personal-taxes/online/live/','turbotax.intuit.com'],
|
||||
[110,'YNAB','Finance Software','software','https://www.ynab.com/pricing','ynab.com'],
|
||||
[111,'Rocket Money Premium','Finance Software','software','https://www.rocketmoney.com/premium','rocketmoney.com'],
|
||||
[112,'Copilot Money','Finance Software','software','https://copilot.money/','copilot.money'],
|
||||
[113,'Calendly','Software & Productivity','software','https://calendly.com/pricing','calendly.com'],
|
||||
[114,'Zoom Workplace','Software & Productivity','software','https://www.zoom.com/en/pricing/','zoom.com'],
|
||||
[115,'Slack','Software & Productivity','software','https://slack.com/pricing','slack.com'],
|
||||
[116,'Xbox Game Pass','Gaming','gaming','https://www.xbox.com/en-US/xbox-game-pass','xbox.com'],
|
||||
[117,'PlayStation Plus','Gaming','gaming','https://www.playstation.com/en-us/ps-plus/','playstation.com'],
|
||||
[118,'Nintendo Switch Online','Gaming','gaming','https://www.nintendo.com/us/switch/online/','nintendo.com'],
|
||||
[119,'Apple Arcade','Gaming','gaming','https://www.apple.com/apple-arcade/','apple.com'],
|
||||
[120,'EA Play','Gaming','gaming','https://www.ea.com/ea-play','ea.com'],
|
||||
[121,'Ubisoft+','Gaming','gaming','https://www.ubisoft.com/en-us/ubisoft-plus','ubisoft.com'],
|
||||
[122,'NVIDIA GeForce NOW','Gaming','gaming','https://www.nvidia.com/en-us/geforce-now/memberships/','nvidia.com'],
|
||||
[123,'Roblox Premium','Gaming','gaming','https://www.roblox.com/premium/membership','roblox.com'],
|
||||
[124,'Fortnite Crew','Gaming','gaming','https://www.fortnite.com/fortnite-crew-subscription','fortnite.com'],
|
||||
[125,'Minecraft Realms','Gaming','gaming','https://www.minecraft.net/realms','minecraft.net'],
|
||||
[126,'Twitch Turbo','Creator & Social','news','https://www.twitch.tv/turbo','twitch.tv'],
|
||||
[127,'Discord Nitro','Creator & Social','news','https://discord.com/nitro','discord.com'],
|
||||
[128,'X Premium','Creator & Social','news','https://help.x.com/en/using-x/x-premium','help.x.com'],
|
||||
[129,'Snapchat+','Creator & Social','news','https://www.snapchat.com/plus','snapchat.com'],
|
||||
[130,'TikTok Live Subscription','Creator & Social','news','https://www.tiktok.com/live/creators/en-US/subscription/','tiktok.com'],
|
||||
[131,'Meta Verified','Creator & Social','news','https://about.meta.com/technologies/meta-verified/','about.meta.com'],
|
||||
[132,'LinkedIn Premium','Career & Social','news','https://premium.linkedin.com/','premium.linkedin.com'],
|
||||
[133,'Tinder Gold','Dating','other','https://tinder.com/feature/plus','tinder.com'],
|
||||
[134,'Bumble Premium','Dating','other','https://bumble.com/en/the-buzz/bumble-premium','bumble.com'],
|
||||
[135,'Hinge+','Dating','other','https://hinge.co/hinge-plus','hinge.co'],
|
||||
[136,'Amazon Prime','Shopping & Delivery','shopping','https://www.amazon.com/amazonprime','amazon.com'],
|
||||
[137,'Walmart+','Shopping & Delivery','shopping','https://www.walmart.com/plus','walmart.com'],
|
||||
[138,'Target Circle 360','Shopping & Delivery','shopping','https://www.target.com/circle/target-circle-360','target.com'],
|
||||
[139,'Costco','Warehouse Clubs','shopping','https://www.costco.com/join-costco.html','costco.com'],
|
||||
[140,'Sam\'s Club','Warehouse Clubs','shopping','https://www.samsclub.com/join','samsclub.com'],
|
||||
[141,'BJ\'s Wholesale Club','Warehouse Clubs','shopping','https://www.bjs.com/membership','bjs.com'],
|
||||
[142,'Instacart+','Grocery & Delivery','food','https://www.instacart.com/instacart-plus','instacart.com'],
|
||||
[143,'DoorDash DashPass','Food Delivery','food','https://www.doordash.com/dashpass/','doordash.com'],
|
||||
[144,'Uber One','Food & Rides','food','https://www.uber.com/us/en/u/uber-one/','uber.com'],
|
||||
[145,'Grubhub+','Food Delivery','food','https://www.grubhub.com/plus','grubhub.com'],
|
||||
[146,'Shipt','Grocery & Delivery','food','https://www.shipt.com/membership/','shipt.com'],
|
||||
[147,'Kroger Boost','Grocery & Delivery','food','https://www.kroger.com/pr/boost','kroger.com'],
|
||||
[148,'Thrive Market','Grocery & Delivery','food','https://thrivemarket.com/','thrivemarket.com'],
|
||||
[149,'Misfits Market','Grocery & Delivery','food','https://www.misfitsmarket.com/','misfitsmarket.com'],
|
||||
[150,'Imperfect Foods','Grocery & Delivery','food','https://www.imperfectfoods.com/','imperfectfoods.com'],
|
||||
[151,'Chewy Autoship','Pet Retail','shopping','https://www.chewy.com/app/content/autoship','chewy.com'],
|
||||
[152,'Petco Vital Care Premier','Pet Retail','shopping','https://www.petco.com/shop/en/petcostore/c/vitalcare','petco.com'],
|
||||
[153,'PetSmart Treats Rewards VIPP','Pet Retail','shopping','https://www.petsmart.com/treats-rewards-vipp.html','petsmart.com'],
|
||||
[154,'GameStop Pro','Retail Memberships','shopping','https://www.gamestop.com/pro/','gamestop.com'],
|
||||
[155,'Barnes & Noble Membership','Retail Memberships','shopping','https://www.barnesandnoble.com/membership','barnesandnoble.com'],
|
||||
[156,'HelloFresh','Food & Meal Kits','food','https://www.hellofresh.com/','hellofresh.com'],
|
||||
[157,'Blue Apron','Food & Meal Kits','food','https://www.blueapron.com/','blueapron.com'],
|
||||
[158,'Home Chef','Food & Meal Kits','food','https://www.homechef.com/','homechef.com'],
|
||||
[159,'Marley Spoon','Food & Meal Kits','food','https://marleyspoon.com/','marleyspoon.com'],
|
||||
[160,'Dinnerly','Food & Meal Kits','food','https://dinnerly.com/','dinnerly.com'],
|
||||
[161,'EveryPlate','Food & Meal Kits','food','https://www.everyplate.com/','everyplate.com'],
|
||||
[162,'Green Chef','Food & Meal Kits','food','https://www.greenchef.com/','greenchef.com'],
|
||||
[163,'Purple Carrot','Food & Meal Kits','food','https://www.purplecarrot.com/','purplecarrot.com'],
|
||||
[164,'Sunbasket','Food & Meal Kits','food','https://sunbasket.com/','sunbasket.com'],
|
||||
[165,'Factor','Prepared Meals','food','https://www.factor75.com/','factor75.com'],
|
||||
[166,'CookUnity','Prepared Meals','food','https://www.cookunity.com/','cookunity.com'],
|
||||
[167,'Fresh N Lean','Prepared Meals','food','https://www.freshnlean.com/','freshnlean.com'],
|
||||
[168,'Hungryroot','Food & Meal Kits','food','https://www.hungryroot.com/','hungryroot.com'],
|
||||
[169,'Daily Harvest','Prepared Meals','food','https://www.daily-harvest.com/','daily-harvest.com'],
|
||||
[170,'Tovala','Prepared Meals','food','https://www.tovala.com/','tovala.com'],
|
||||
[171,'MistoBox','Coffee & Tea','food','https://mistobox.com/','mistobox.com'],
|
||||
[172,'Trade Coffee','Coffee & Tea','food','https://www.drinktrade.com/','drinktrade.com'],
|
||||
[173,'Atlas Coffee Club','Coffee & Tea','food','https://atlascoffeeclub.com/','atlascoffeeclub.com'],
|
||||
[174,'Bean Box','Coffee & Tea','food','https://beanbox.com/','beanbox.com'],
|
||||
[175,'Universal Yums','Snacks','food','https://www.universalyums.com/','universalyums.com'],
|
||||
[176,'Peloton App','Fitness & Wellness','fitness','https://www.onepeloton.com/app','onepeloton.com'],
|
||||
[177,'ClassPass','Fitness & Wellness','fitness','https://classpass.com/','classpass.com'],
|
||||
[178,'Apple Fitness+','Fitness & Wellness','fitness','https://www.apple.com/apple-fitness-plus/','apple.com'],
|
||||
[179,'Strava','Fitness & Wellness','fitness','https://www.strava.com/subscribe','strava.com'],
|
||||
[180,'Fitbit Premium','Fitness & Wellness','fitness','https://www.fitbit.com/global/us/products/services/premium','fitbit.com'],
|
||||
[181,'MyFitnessPal Premium','Fitness & Wellness','fitness','https://www.myfitnesspal.com/premium','myfitnesspal.com'],
|
||||
[182,'Noom','Fitness & Wellness','fitness','https://www.noom.com/','noom.com'],
|
||||
[183,'WW','Fitness & Wellness','fitness','https://www.weightwatchers.com/us/plans','weightwatchers.com'],
|
||||
[184,'Headspace','Meditation & Wellness','fitness','https://www.headspace.com/','headspace.com'],
|
||||
[185,'Calm','Meditation & Wellness','fitness','https://www.calm.com/premium','calm.com'],
|
||||
[186,'Sleep Cycle Premium','Sleep & Wellness','fitness','https://www.sleepcycle.com/premium/','sleepcycle.com'],
|
||||
[187,'Oura Membership','Fitness & Wellness','fitness','https://ouraring.com/membership','ouraring.com'],
|
||||
[188,'Whoop','Fitness & Wellness','fitness','https://www.whoop.com/us/en/membership/','whoop.com'],
|
||||
[189,'Aaptiv','Fitness & Wellness','fitness','https://aaptiv.com/','aaptiv.com'],
|
||||
[190,'Fitbod','Fitness & Wellness','fitness','https://fitbod.me/pricing/','fitbod.me'],
|
||||
[191,'Alo Moves','Fitness & Wellness','fitness','https://www.alomoves.com/','alomoves.com'],
|
||||
[192,'Obe Fitness','Fitness & Wellness','fitness','https://obefitness.com/','obefitness.com'],
|
||||
[193,'Centr','Fitness & Wellness','fitness','https://centr.com/','centr.com'],
|
||||
[194,'Future','Fitness & Wellness','fitness','https://www.future.co/','future.co'],
|
||||
[195,'Tonal Membership','Fitness & Wellness','fitness','https://www.tonal.com/membership/','tonal.com'],
|
||||
[196,'Duolingo Super','Education','education','https://www.duolingo.com/super','duolingo.com'],
|
||||
[197,'MasterClass','Education','education','https://www.masterclass.com/','masterclass.com'],
|
||||
[198,'Coursera Plus','Education','education','https://www.coursera.org/courseraplus','coursera.org'],
|
||||
[199,'Skillshare','Education','education','https://www.skillshare.com/','skillshare.com'],
|
||||
[200,'Book of the Month','Books & Subscription Boxes','education','https://www.bookofthemonth.com/','bookofthemonth.com'],
|
||||
];
|
||||
|
||||
const SUBSCRIPTION_CATALOG_V2_ROWS = [
|
||||
// ── AI ──────────────────────────────────────────────────────────────────────
|
||||
[201,'Suno','AI Music','music','https://suno.com/','suno.com'],
|
||||
[202,'Midjourney','AI Image Generation','software','https://midjourney.com/','midjourney.com'],
|
||||
[203,'Grok','AI','software','https://grok.com/','grok.com'],
|
||||
[204,'ElevenLabs','AI Voice','software','https://elevenlabs.io/','elevenlabs.io'],
|
||||
[205,'Character.ai Plus','AI','software','https://character.ai/','character.ai'],
|
||||
[206,'Runway','AI Video','software','https://runwayml.com/','runwayml.com'],
|
||||
[207,'Windsurf','Developer Tools','software','https://windsurf.com/','windsurf.com'],
|
||||
[208,'Leonardo.ai','AI Image Generation','software','https://leonardo.ai/','leonardo.ai'],
|
||||
// ── Home Security / Smart Home ───────────────────────────────────────────────
|
||||
[209,'Ring Protect','Home Security','other','https://ring.com/protect-plans','ring.com'],
|
||||
[210,'Nest Aware','Home Security','other','https://store.google.com/us/category/connected_home','nest.com'],
|
||||
[211,'SimpliSafe','Home Security','other','https://simplisafe.com/','simplisafe.com'],
|
||||
[212,'ADT','Home Security','other','https://www.adt.com/','adt.com'],
|
||||
[213,'Arlo Secure','Home Security','other','https://www.arlo.com/','arlo.com'],
|
||||
[214,'Wyze Cam Plus','Home Security','other','https://www.wyze.com/','wyze.com'],
|
||||
[215,'Abode','Home Security','other','https://goabode.com/','goabode.com'],
|
||||
// ── Financial Data & Aggregation ────────────────────────────────────────────
|
||||
[216,'SimpleFIN Bridge','Financial Data','software','https://simplefin.org/','simplefin.org'],
|
||||
[217,'Tiller Money','Financial Data','software','https://www.tillerhq.com/','tillerhq.com'],
|
||||
[218,'Monarch Money','Finance Software','software','https://www.monarchmoney.com/','monarchmoney.com'],
|
||||
[219,'Empower','Finance Software','software','https://www.empower.com/','empower.com'],
|
||||
// ── Cloud Backup ─────────────────────────────────────────────────────────────
|
||||
[220,'Backblaze','Cloud Backup','cloud','https://www.backblaze.com/','backblaze.com'],
|
||||
[221,'Carbonite','Cloud Backup','cloud','https://www.carbonite.com/','carbonite.com'],
|
||||
[222,'iDrive','Cloud Backup','cloud','https://www.idrive.com/','idrive.com'],
|
||||
// ── Email ────────────────────────────────────────────────────────────────────
|
||||
[223,'Proton Mail','Email & Privacy','software','https://proton.me/mail','proton.me'],
|
||||
[224,'Fastmail','Email','software','https://www.fastmail.com/','fastmail.com'],
|
||||
[225,'Superhuman','Email','software','https://superhuman.com/','superhuman.com'],
|
||||
[226,'Hey','Email','software','https://www.hey.com/','hey.com'],
|
||||
// ── Security / Privacy ───────────────────────────────────────────────────────
|
||||
[227,'Bitwarden','Security','security','https://bitwarden.com/','bitwarden.com'],
|
||||
[228,'Keeper','Security','security','https://www.keepersecurity.com/','keepersecurity.com'],
|
||||
[229,'LastPass','Security','security','https://www.lastpass.com/','lastpass.com'],
|
||||
[230,'Proton VPN','Security','security','https://protonvpn.com/','protonvpn.com'],
|
||||
[231,'Mullvad VPN','Security','security','https://mullvad.net/','mullvad.net'],
|
||||
[232,'Private Internet Access','Security','security','https://www.privateinternetaccess.com/','privateinternetaccess.com'],
|
||||
[233,'Proton Pass','Security','security','https://proton.me/pass','proton.me'],
|
||||
[234,'SimpleLogin','Email Privacy','software','https://simplelogin.io/','simplelogin.io'],
|
||||
// ── Identity Protection ──────────────────────────────────────────────────────
|
||||
[235,'LifeLock','Identity Protection','security','https://www.lifelock.com/','lifelock.com'],
|
||||
[236,'Aura','Identity Protection','security','https://www.aura.com/','aura.com'],
|
||||
[237,'IdentityForce','Identity Protection','security','https://www.identityforce.com/','identityforce.com'],
|
||||
// ── Comics ───────────────────────────────────────────────────────────────────
|
||||
[238,'Marvel Unlimited','Comics','streaming','https://www.marvel.com/unlimited','marvel.com'],
|
||||
[239,'DC Universe Infinite','Comics','streaming','https://www.dcuniverseinfinite.com/','dcuniverseinfinite.com'],
|
||||
// ── Genealogy ────────────────────────────────────────────────────────────────
|
||||
[240,'Ancestry','Genealogy','other','https://www.ancestry.com/','ancestry.com'],
|
||||
[241,'MyHeritage','Genealogy','other','https://www.myheritage.com/','myheritage.com'],
|
||||
[242,'Findmypast','Genealogy','other','https://www.findmypast.com/','findmypast.com'],
|
||||
// ── Telehealth ───────────────────────────────────────────────────────────────
|
||||
[243,'BetterHelp','Telehealth','other','https://www.betterhelp.com/','betterhelp.com'],
|
||||
[244,'Talkspace','Telehealth','other','https://www.talkspace.com/','talkspace.com'],
|
||||
[245,'Teladoc','Telehealth','other','https://www.teladoc.com/','teladoc.com'],
|
||||
[246,'Hims & Hers','Telehealth','other','https://www.forhims.com/','forhims.com'],
|
||||
[247,'Cerebral','Telehealth','other','https://cerebral.com/','cerebral.com'],
|
||||
// ── Website Builder / Hosting / Domains ─────────────────────────────────────
|
||||
[248,'Squarespace','Website Builder','software','https://www.squarespace.com/','squarespace.com'],
|
||||
[249,'Wix','Website Builder','software','https://www.wix.com/','wix.com'],
|
||||
[250,'Webflow','Website Builder','software','https://webflow.com/','webflow.com'],
|
||||
[251,'GoDaddy','Domain & Hosting','software','https://www.godaddy.com/','godaddy.com'],
|
||||
[252,'Namecheap','Domain & Hosting','software','https://www.namecheap.com/','namecheap.com'],
|
||||
[253,'Netlify','Developer Tools','software','https://www.netlify.com/','netlify.com'],
|
||||
[254,'Vercel','Developer Tools','software','https://vercel.com/','vercel.com'],
|
||||
// ── Learning ─────────────────────────────────────────────────────────────────
|
||||
[255,'LinkedIn Learning','Education','education','https://www.linkedin.com/learning/','linkedin.com'],
|
||||
[256,'Pluralsight','Education','education','https://www.pluralsight.com/','pluralsight.com'],
|
||||
[257,"O'Reilly",'Education','education','https://www.oreilly.com/','oreilly.com'],
|
||||
[258,'CBT Nuggets','Education','education','https://www.cbtnuggets.com/','cbtnuggets.com'],
|
||||
[259,'Udacity','Education','education','https://www.udacity.com/','udacity.com'],
|
||||
[260,'Frontend Masters','Education','education','https://frontendmasters.com/','frontendmasters.com'],
|
||||
// ── Project Management ───────────────────────────────────────────────────────
|
||||
[261,'Monday.com','Software & Productivity','software','https://monday.com/','monday.com'],
|
||||
[262,'Asana','Software & Productivity','software','https://asana.com/','asana.com'],
|
||||
[263,'ClickUp','Software & Productivity','software','https://clickup.com/','clickup.com'],
|
||||
[264,'Linear','Developer Tools','software','https://linear.app/','linear.app'],
|
||||
[265,'Basecamp','Software & Productivity','software','https://basecamp.com/','basecamp.com'],
|
||||
[266,'Jira','Developer Tools','software','https://www.atlassian.com/software/jira','atlassian.com'],
|
||||
[267,'Miro','Software & Productivity','software','https://miro.com/','miro.com'],
|
||||
[268,'Airtable','Software & Productivity','software','https://airtable.com/','airtable.com'],
|
||||
// ── Email Marketing ──────────────────────────────────────────────────────────
|
||||
[269,'Mailchimp','Email Marketing','software','https://mailchimp.com/','mailchimp.com'],
|
||||
[270,'ConvertKit','Email Marketing','software','https://convertkit.com/','convertkit.com'],
|
||||
[271,'Beehiiv','Newsletter Platform','software','https://www.beehiiv.com/','beehiiv.com'],
|
||||
[272,'Constant Contact','Email Marketing','software','https://www.constantcontact.com/','constantcontact.com'],
|
||||
[273,'ActiveCampaign','Email Marketing','software','https://www.activecampaign.com/','activecampaign.com'],
|
||||
// ── Cloud Computing ──────────────────────────────────────────────────────────
|
||||
[274,'DigitalOcean','Cloud Computing','cloud','https://www.digitalocean.com/','digitalocean.com'],
|
||||
[275,'Linode','Cloud Computing','cloud','https://www.linode.com/','linode.com'],
|
||||
[276,'Render','Cloud Computing','cloud','https://render.com/','render.com'],
|
||||
[277,'Vultr','Cloud Computing','cloud','https://www.vultr.com/','vultr.com'],
|
||||
[278,'Hetzner','Cloud Computing','cloud','https://www.hetzner.com/','hetzner.com'],
|
||||
[279,'Cloudflare','Network & Security','cloud','https://www.cloudflare.com/','cloudflare.com'],
|
||||
// ── Media Server ─────────────────────────────────────────────────────────────
|
||||
[280,'Plex Pass','Media Server','streaming','https://www.plex.tv/plex-pass/','plex.tv'],
|
||||
[281,'Emby Premiere','Media Server','streaming','https://emby.media/premiere/','emby.media'],
|
||||
// ── Network / Homelab ────────────────────────────────────────────────────────
|
||||
[282,'NextDNS','Network & Security','software','https://nextdns.io/','nextdns.io'],
|
||||
[283,'Tailscale','Network & Security','software','https://tailscale.com/','tailscale.com'],
|
||||
// ── Productivity ─────────────────────────────────────────────────────────────
|
||||
[284,'Obsidian Sync','Software & Productivity','software','https://obsidian.md/','obsidian.md'],
|
||||
[285,'Readwise','Software & Productivity','software','https://readwise.io/','readwise.io'],
|
||||
[286,'Loom','Software & Productivity','software','https://www.loom.com/','loom.com'],
|
||||
[287,'Raycast Pro','Software & Productivity','software','https://www.raycast.com/','raycast.com'],
|
||||
// ── Developer Tools ──────────────────────────────────────────────────────────
|
||||
[288,'JetBrains','Developer Tools','software','https://www.jetbrains.com/','jetbrains.com'],
|
||||
[289,'Tabnine','Developer Tools','software','https://www.tabnine.com/','tabnine.com'],
|
||||
// ── Communication ────────────────────────────────────────────────────────────
|
||||
[290,'Telegram Premium','Messaging','software','https://telegram.org/','telegram.org'],
|
||||
];
|
||||
|
||||
module.exports = { SUBSCRIPTION_CATALOG_ROWS, SUBSCRIPTION_CATALOG_V2_ROWS };
|
||||
|
|
@ -1,12 +1,6 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Files this app writes (the SQLite DB + WAL/SHM, backups, exports) hold financial
|
||||
# data and encrypted secrets (SimpleFIN token, sessions, SMTP/OIDC). Create them
|
||||
# owner-only (600 files / 700 dirs) — not world-readable. Inherited by the exec'd
|
||||
# node process so SQLite's -wal/-shm are locked too. (QA-B16-02)
|
||||
umask 077
|
||||
|
||||
APP_USER="${APP_USER:-bill}"
|
||||
APP_GROUP="${APP_GROUP:-bill}"
|
||||
DATA_DIR="${DATA_DIR:-/data}"
|
||||
|
|
@ -19,9 +13,6 @@ mkdir -p "$DATA_DIR" "$DB_DIR" "$BACKUP_DIR" /app/backups
|
|||
if [ "$(id -u)" = "0" ]; then
|
||||
chown -R "$APP_USER:$APP_GROUP" "$DATA_DIR" /app/backups
|
||||
chmod 700 "$DB_DIR" "$BACKUP_DIR" /app/backups
|
||||
# Lock any pre-existing DB files that were created world-readable (644) before
|
||||
# this umask fix — otherwise they keep their old mode across an upgrade.
|
||||
chmod 600 "$DB_FILE" "$DB_FILE"-wal "$DB_FILE"-shm 2>/dev/null || true
|
||||
if [ "${RUN_DB_MIGRATIONS:-true}" = "true" ]; then
|
||||
su-exec "$APP_USER:$APP_GROUP" node scripts/migrate-db.js
|
||||
fi
|
||||
|
|
|
|||
220
docs/QA_PLAN.md
220
docs/QA_PLAN.md
|
|
@ -1,7 +1,6 @@
|
|||
# BillTracker — Master QA Plan (living document)
|
||||
|
||||
**Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-02
|
||||
**Cycle 1: COMPLETE ✅** — all 18 batches (B0→B16 + B-UI) run; **15 findings fixed**, verified & archived (3× S2); automated re-run of existing batches clean (0 new); the added **B16** (migrations/secrets/deploy) surfaced + fixed 1 (version-check opt-out). Guard suite green. External-infra items (live TOTP/WebAuthn/OIDC, SMTP delivery, cross-browser, PWA-offline, load, container build) carried to Cycle 2 as non-blocking.
|
||||
**Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-02 (Cycle 1: 14 findings fixed & archived, 0 open — incl. broken "Send test push" + email XSS)
|
||||
|
||||
This is a **living, operational** QA document, not a static spec. Claude runs it,
|
||||
in **batches**, actively hunting for bugs/errors/rough edges, **fixing** them, and
|
||||
|
|
@ -76,26 +75,6 @@ no matter how tempting or trivial.
|
|||
- **The exit is empirical:** you're done only when an entire find pass (B0→B15) turns up zero new findings — not when you *think* it's clean. Log the cycle result in the [Cycle Log](#11-qa-cycle-log) each time.
|
||||
- Improve THIS plan whenever a pass reveals a missed surface, a better repro, or a batch that should be reordered/split.
|
||||
|
||||
**Improvement lens (not just bug-hunting).** QA here is also about making the product
|
||||
*better*, not only *correct*. On every batch, in addition to logging bugs, actively
|
||||
look through three improvement lenses and log what you find as **IMP** items in the
|
||||
[Improvement Backlog (§2.1)](#21-improvement-backlog):
|
||||
- **Code health & consolidation** — duplication to DRY up, dead code to delete,
|
||||
overlapping modules to merge, oversized files to split, one canonical path per
|
||||
concern. *Consolidate only where it genuinely reduces surface area and is
|
||||
behavior-preserving.* (Dedicated pass: **B17**.)
|
||||
- **User experience** — friction in core flows, unclear states (empty/loading/error),
|
||||
weak feedback/affordances, inconsistent patterns, mobile parity. (Dedicated pass: **B18**.)
|
||||
- **Information architecture / menus** — features that are buried or only reachable by
|
||||
URL, actions that belong in a menu (nav, overflow, context, settings groupings), and
|
||||
groupings that would make the app more discoverable. *Put things where a user would
|
||||
look for them.* (Dedicated pass: **B18**.)
|
||||
|
||||
IMP items are **proposals**, not silent changes: log the candidate with a concrete
|
||||
recommendation, agree the direction, then implement behind a test. They **don't block
|
||||
sign-off** — but a strong QA cycle leaves the code cleaner and the UX clearer, not just
|
||||
green.
|
||||
|
||||
---
|
||||
|
||||
## 1. Batch plan & progress tracker
|
||||
|
|
@ -107,44 +86,26 @@ before cross-cutting; regression last). Update **Status** and **Findings** every
|
|||
|
||||
| # | Batch | Primary surface | Data state | Status | Open / Fixed |
|
||||
|---|-------|-----------------|-----------|--------|--------------|
|
||||
| B0 | Baseline, tooling & **coverage recon** | `npm run ci`/`check`, app boots, console clean, **re-scan routes/pages/API vs plan & update it**, **control census** | any | ✅ | 0 / 1 |
|
||||
| B-UI | **Design-system primitives** | each `client/components/ui/*` × state matrix (default/hover/focus/active/disabled/loading/error/read-only) × light/dark × keyboard | any | ✅ | 0 / 0 |
|
||||
| B1 | Auth & authorization | login (pw/OIDC/TOTP/WebAuthn), roles, single-user, CSRF, data isolation | multi + single user | ✅ | 0 / 0 |
|
||||
| B2 | Tracker (core) | `/` buckets, pay/skip/notes/overrides, balance cards, overdue, ledger, drift | seeded + adversarial | ✅ | 0 / 0 |
|
||||
| B3 | Bills & schedules | `/bills` CRUD, custom schedules, reorder, merchant rules, historical import | adversarial | ✅ | 0 / 0 |
|
||||
| B4 | Subscriptions & Categories | `/subscriptions`, catalog, `/categories`, groups, reorder | seeded | ✅ | 0 / 0 |
|
||||
| B5 | Reporting reconciliation | `/summary`, `/calendar`, `/analytics`, `/health` cross-check totals | seeded + large + **live SimpleFIN DB** | ✅ | 0 / 4 |
|
||||
| B6 | Spending | `/spending` YNAB view, averages, cover-overspending, safe-to-spend | seeded + edge months | ✅ | 0 / 1 |
|
||||
| B7 | Debt planning (math) | `/snowball`, `/payoff` APR/amortization vs hand-calc | edge (APR=0, $0 debt) | ✅ | 0 / 2 |
|
||||
| B8 | Banking & bank sync | `/bank-transactions`, SimpleFIN sync, matching, merchant/store, advisory filter | seeded txns + **live SimpleFIN sync** | ✅ | 0 / 0 |
|
||||
| B9 | Data lifecycle | `/data` import (XLSX/CSV/SQLite), export, ICS feed, backups round-trip | empty + seeded | ✅ | 0 / 1 |
|
||||
| B10 | Notifications & workers | email + ntfy/Gotify/Discord/Telegram, reminders, cron workers | seeded | ✅ | 0 / 1 |
|
||||
| B11 | Admin panel | users, login mode, auth methods, backups, cleanup, status, onboarding | admin | ✅ | 0 / 0 |
|
||||
| B12 | Settings, Profile & global UI | `/settings`, `/profile`, static pages, command palette, sidebar/nav | any | ✅ | 0 / 0 |
|
||||
| B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | ✅ | 0 / 1 |
|
||||
| B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | ✅ | 0 / 4 |
|
||||
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | ✅ | 0 / 0 |
|
||||
| B16 | Migrations, secrets & deploy | migration idempotency/rollback/fresh==migrated, encryption-key lifecycle, `docker-entrypoint` (perms/first-run/migrate), update-check phone-home | scratch + docker | ✅ | 0 / 1 |
|
||||
| B17 | **Code health & consolidation** (IMP) | duplication/DRY, dead code, overlapping modules to merge, oversized files to split, one canonical path per concern | whole repo | ⬜ | 0 / 0 |
|
||||
| B18 | **UX & information architecture** (IMP) | core-flow friction, empty/loading/error states, feedback/affordances, nav/menu discoverability, surfacing actions into sensible menus | any | ⬜ | 0 / 0 |
|
||||
| B0 | Baseline, tooling & **coverage recon** | `npm run ci`/`check`, app boots, console clean, **re-scan routes/pages/API vs plan & update it**, **control census** | any | 🔄 | 0 / 1 |
|
||||
| B-UI | **Design-system primitives** | each `client/components/ui/*` × state matrix (default/hover/focus/active/disabled/loading/error/read-only) × light/dark × keyboard | any | 🔄 | 0 / 0 |
|
||||
| B1 | Auth & authorization | login (pw/OIDC/TOTP/WebAuthn), roles, single-user, CSRF, data isolation | multi + single user | ⬜ | 0 / 0 |
|
||||
| B2 | Tracker (core) | `/` buckets, pay/skip/notes/overrides, balance cards, overdue, ledger, drift | seeded + adversarial | ⬜ | 0 / 0 |
|
||||
| B3 | Bills & schedules | `/bills` CRUD, custom schedules, reorder, merchant rules, historical import | adversarial | ⬜ | 0 / 0 |
|
||||
| B4 | Subscriptions & Categories | `/subscriptions`, catalog, `/categories`, groups, reorder | seeded | ⬜ | 0 / 0 |
|
||||
| B5 | Reporting reconciliation | `/summary`, `/calendar`, `/analytics`, `/health` cross-check totals | seeded + large | 🔄 | 0 / 3 |
|
||||
| B6 | Spending | `/spending` YNAB view, averages, cover-overspending, safe-to-spend | seeded + edge months | 🔄 | 0 / 1 |
|
||||
| B7 | Debt planning (math) | `/snowball`, `/payoff` APR/amortization vs hand-calc | edge (APR=0, $0 debt) | 🔄 | 0 / 2 |
|
||||
| B8 | Banking & bank sync | `/bank-transactions`, SimpleFIN sync, matching, merchant/store, advisory filter | seeded txns | ⬜ | 0 / 0 |
|
||||
| B9 | Data lifecycle | `/data` import (XLSX/CSV/SQLite), export, ICS feed, backups round-trip | empty + seeded | 🔄 | 0 / 1 |
|
||||
| B10 | Notifications & workers | email + ntfy/Gotify/Discord/Telegram, reminders, cron workers | seeded | 🔄 | 0 / 1 |
|
||||
| B11 | Admin panel | users, login mode, auth methods, backups, cleanup, status, onboarding | admin | 🔄 | 0 / 0 |
|
||||
| B12 | Settings, Profile & global UI | `/settings`, `/profile`, static pages, command palette, sidebar/nav | any | 🔄 | 0 / 0 |
|
||||
| B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | 🔄 | 0 / 1 |
|
||||
| B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | 🔄 | 0 / 4 |
|
||||
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | 🔄 | 0 / 0 |
|
||||
|
||||
> After B15, if any batch is 🔁 or has open S1/S2, loop back. Then start a new
|
||||
> cycle from B0 against the next build/version.
|
||||
>
|
||||
> **B17/B18 are improvement (IMP) batches** — they run alongside the correctness
|
||||
> batches but their findings are enhancements, not defects, and don't gate sign-off.
|
||||
|
||||
**✅ means "run complete for this cycle's automatable scope, green, findings archived."**
|
||||
Cycle 1 built a durable, automated guard for every batch (`npm run ci` · `test:e2e` ·
|
||||
`test:e2e:probe` · `smoke:prod`). The following need **external infrastructure or a
|
||||
human** and were **not** exercised — they are **non-blocking** for Cycle 1 sign-off and
|
||||
carried to Cycle 2:
|
||||
|
||||
- **B1** — live TOTP enrollment, WebAuthn/passkeys (browser/OS prompts), OIDC SSO round-trip. (Password login, roles, CSRF, data-isolation, admin authz **are** covered.)
|
||||
- **B10** — real SMTP *delivery* (push delivery + email-HTML building/escaping **are** covered by `tests/notificationDelivery.test.js`).
|
||||
- **B11** — backup create/restore on a scratch instance (authorization + last-admin guards **are** covered).
|
||||
- **B14** — Firefox/Safari cross-browser, PWA install/offline, and large/stress load+perf. (axe a11y on 8 pages, XSS/escaping, and prod-bundle perf **are** covered.)
|
||||
- **B9** — spreadsheet/CSV import *from real files* end-to-end (money-unit handling + SQLite export→import round-trip **are** covered by tests).
|
||||
|
||||
### 1.1 QA Cycle Log
|
||||
|
||||
|
|
@ -154,9 +115,7 @@ until you get a clean cycle.
|
|||
|
||||
| Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result |
|
||||
|-------|---------|----------------|-----------------|------------------|--------|
|
||||
| 1 | 2026-07-02 | `bdbf231`→`5ffe2db` (dev) | 14 | **14 → all fixed, verified & archived** (3× S2 incl. broken "Send test push", email XSS, reconciliation family, seed 100× cents) | 🔁 Phase 2 complete — 0 open. Every batch B0→B15 (+B-UI) run; 16 QA commits; guard suite green. |
|
||||
| 1·re-run | 2026-07-02 | `5ffe2db` (dev) | **0 new** | — | ✅ **Automated re-run clean.** CI (server 109 + client 34, build), UI E2E 27, probe 16 (authz 403, Tracker↔Summary↔Analytics reconcile exactly, seed guard, a11y 8/8), prod-smoke PASS. **All 17 batches ✅ for automatable scope; external-infra residuals listed below are non-blocking and carried to Cycle 2.** |
|
||||
| 1·simplefin-live | 2026-07-03 | `5ffe2db` (dev) vs prod DB | **1** (QA-B5-04) | **1 → fixed, verified & archived** | 🔁 Probed a **copy of the live SimpleFIN DB** (19 MB, v1.06: 3 users, 44 bills, 1,159 txns, 19 accounts, active SimpleFIN source). Integrity checks: dedup (1159/1159 distinct), money=integer cents, no double-match, pending have provider ids, no orphan-account txns — all pass **except** 3 matched txns with NULL bill → QA-B5-04 (retention GC + `ON DELETE SET NULL`). Fixed in `cleanupService`; healing verified on a DB copy (3→0, 0 txns lost). **Also ran a real end-to-end sync** (`syncDataSource`, the Sync-button path) against the live connection off a working copy: token decrypted via db-key fallback (no env key), bridge fetch OK (2.2s), 18 accounts upserted, 145 fetched txns **skipped not duplicated**, 0 new, 1159→1159 distinct — **dedup/upsert idempotency proven on the real connection.** |
|
||||
| 1 | 2026-07-02 | `bdbf231`→(dev) | 13 | **13 → all fixed, verified & archived** (…, +B10-01 broken "Send test push") | ✅ **0 open.** Post seed-fix reconciliation caught the **occurrence-gating family** — Summary (S2), Analytics, and SimpleFIN bank-tracking all counted non-monthly bills every month; all fixed via `resolveDueDate` and guarded (probe reconciliation + `tests/summaryBankTracking.test.js`). Probed B0/B1/B3/B4/B5/B6/B7/B8/B9/B13/B14; solid: auth/isolation, CSRF, payment/date validation, recurrence, matching/dedup, subscription+spending math, XSS, calendar gating. **A full re-run (B0→B15) is still required to declare the cycle clean per exit criteria.** |
|
||||
|
||||
**Result key:** 🔄 in progress · 🔁 findings fixed, re-run required · ✅ clean (zero findings — QA complete)
|
||||
|
||||
|
|
@ -198,29 +157,6 @@ Log console errors, failed network requests, and unhandled rejections as finding
|
|||
|
||||
_All Cycle 1 write-ups have been archived to `HISTORY.md` v0.41.0 (see §3)._
|
||||
|
||||
### 2.1 Improvement backlog
|
||||
|
||||
**IMP-stream, separate from the bug log above.** Enhancement candidates found through
|
||||
the three improvement lenses (code/consolidation, UX, IA/menus — see §0 and batches
|
||||
B17/B18). These are **proposals**: log the candidate + a concrete recommendation, then
|
||||
discuss before implementing. They don't gate sign-off. When one is implemented, archive
|
||||
it to `HISTORY.md` (`### 🧹 QA` / `### ✨` as fits) and remove the row; deferred ideas
|
||||
graduate to `roadmap.md`/`FUTURE.md`.
|
||||
|
||||
**ID:** `IMP-{stream}-{nn}` where stream = `CODE` (health/consolidation), `UX`, or `IA` (menus/nav).
|
||||
**Effort:** S (local, <1h) · M (a file or two) · L (cross-cutting / needs design).
|
||||
|
||||
**Status:** 🔵 Noted (proposal) → 🟡 Doing → then archive to `HISTORY.md` on implement.
|
||||
|
||||
| ID | Lens | Area (`file`/page) | Proposal (what & why) | Effort | Status |
|
||||
|----|------|--------------------|-----------------------|--------|--------|
|
||||
| IMP-CODE-01 | Code | `client/lib/money.js` (+16 files) | ~~No shared client money formatter.~~ **Shipped `a15f00c`:** added `client/lib/money.js` (`formatUSD`/`formatUSDWhole`/`formatCentsUSD`); `lib/utils.fmt` delegates to it and 15 local formatters were removed. `null`/`NaN`/`-0` all handled. Test `client/lib/money.test.js`; full client suite + build green. | M | ✅ Shipped |
|
||||
| IMP-CODE-02 | Code | `db/database.js` (4,174→1,297 ln) | ~~Oversized module.~~ **Shipped `7f2faea`,`026c6a5`,`12d9d4c`:** split into `subscriptionCatalogSeed.js` (data), `migrations/versionedMigrations.js` (~1,740 ln factory), `migrations/legacyReconcileMigrations.js` (~830 ln factory) — db + helpers injected, behavior byte-identical. Verified: suite 125, fresh DB 79 migrations idempotent, real prod DB no-op with data intact. Test `tests/migrationModules.test.js`. | L | ✅ Shipped |
|
||||
| IMP-CODE-03 | Code | `services/transactionMatchState.js` | ~~Overlapping match logic.~~ **Shipped `fa24322`:** added canonical `markMatched`/`markUnmatched`/`markIgnored`; routed the 6 single-transaction transitions through it (guarded bulk sweeps keep their own queries by design). Test `tests/transactionMatchState.test.js`. | M | ✅ Shipped |
|
||||
| IMP-IA-01 | IA | Sidebar · `/data` | ~~Central features under an overflow menu.~~ **Shipped `0b1c6a8`:** Data moved into the main app nav (desktop dropdown + mobile) alongside Bills/Categories/Spending; same default-admin gate preserved; removed the redundant account-dropdown entry. | S | ✅ Shipped |
|
||||
| IMP-UX-01 | UX | Bills delete | ~~Retention isn't surfaced.~~ **Shipped `aace5a4`:** Bills shows a "Recently deleted (N)" button opening a restore dialog (amount, category, days-left); `GET /api/bills/deleted` (30-day window). Test `tests/billsDeletedRoute.test.js`. _Categories/payments could get the same treatment later._ | M | ✅ Shipped |
|
||||
| IMP-UX-02 | UX | all list pages | **Audited — no gaps.** Checked all 11 main pages (Tracker/Bills/Subscriptions/Categories/Spending/Banking/Snowball/Payoff/Analytics/Summary/Calendar): each has a skeleton/`animate-pulse` **loading** state, an **empty** state, and a **rendered recoverable error** panel with a Retry/Try-again button (e.g. Summary 459-463, Calendar 899-903, BankTransactions 671-750, Payoff 276-302). No dead ends found; no change warranted. Keep as a standing B18/B14 check for new pages. | M | ✅ Audited |
|
||||
|
||||
---
|
||||
|
||||
## 3. Archiving fixed findings to HISTORY.md
|
||||
|
|
@ -366,8 +302,6 @@ Run these, then compare the output to the batch playbooks (§7) and the [route m
|
|||
- [ ] **API route mounts** — `grep -nE "app.use\('/api" server.js` — every mounted route group is in B13's list and mapped in Appendix C.
|
||||
- [ ] **Services & components** — `ls services/` and `ls client/components/**/` — new service/component families have a home in a playbook.
|
||||
- [ ] **UI primitives** — `ls client/components/ui/` — every shared primitive is covered by the [B-UI](#b-ui--design-system-primitives) playbook; a new primitive gets a row there.
|
||||
- [ ] **Middleware & workers** — `ls middleware/ workers/` (+ `services/*Worker*`, `*Scheduler*`) — each is covered (csrf/rateLimiter/securityHeaders/requireAuth → B13; dailyWorker/bankSyncWorker/backupScheduler → B10).
|
||||
- [ ] **Migrations & deploy** — new `db/database.js` migrations, `Dockerfile`/`docker-entrypoint.sh` changes, and `encryptionService`/`updateCheckService` behavior are covered by [B16](#b16--migrations-secrets--deployment).
|
||||
- [ ] **Interactive-control census (makes "every button tested" *provable*)** — for each page, enumerate every button, link, toggle/switch, checkbox, select, text/number/date/file input, tab, menu, and filter control, and record it in a per-page control checklist (template: [Appendix E](#appendix-e--per-page-control-census)). A control that isn't on a checklist hasn't been tested — the census is the completeness guarantee the batch playbooks alone don't give you. Quick starting inventory: `grep -rnoE "type=[\"'][a-z]+[\"']" client/pages client/components` and `grep -rn "onClick=" client/pages/<Page>.jsx`.
|
||||
- [ ] **Feature flags / conditional surfaces** — search for `Only`, `enabled`, `featureFlag`, env gates that hide/show pages; ensure each state is tested.
|
||||
- [ ] **What changed since last cycle** — skim `git log`/`HISTORY.md` since the previous cycle's commit (see [Cycle Log](#11-qa-cycle-log)) for new features/pages.
|
||||
|
|
@ -519,104 +453,6 @@ Run on the **production build** (`npm start`), not dev:
|
|||
- [ ] Bogus URL → 404; logout → login redirect. Console clean throughout.
|
||||
- [ ] Confirm [exit criteria](#appendix-b--exit--sign-off-criteria).
|
||||
|
||||
### B16 — Migrations, secrets & deployment
|
||||
Added Cycle 1 (previously uncovered). These run on every boot / container start and
|
||||
touch money columns and at-rest secrets — a bug here corrupts data or leaks/breaks
|
||||
secrets silently.
|
||||
|
||||
**Migrations** (`db/database.js` migration system, `scripts/migrate-db.js`, `schema_migrations`, `rollbackMigration`)
|
||||
- [ ] **Idempotent:** boot twice on the same DB → second run applies nothing ("Skipping already applied"), no errors, no duplicate rows/columns.
|
||||
- [ ] **Fresh == migrated:** a brand-new DB (schema.sql + all migrations) has the same schema as a DB migrated up from an old version — same tables/columns/indexes, money columns are **integer cents**.
|
||||
- [ ] **Rollback:** `rollbackMigration` on the latest migration reverts cleanly and re-applying works; partial/failed migration leaves the DB consistent (transactions per migration).
|
||||
- [ ] **Money conversions correct:** v1.03 (dollars→cents) and v1.04 (template JSON) convert exact values, no ×100 drift, run once only.
|
||||
- [ ] Migrating a large/real DB doesn't lose or duplicate bills/payments/categories.
|
||||
|
||||
**Encryption-key lifecycle** (`services/encryptionService.js`, `TOKEN_ENCRYPTION_KEY`, HKDF v1/v2)
|
||||
- [ ] **Key present:** secrets (SMTP pw, OIDC secret, push tokens, login IP/UA) encrypt at rest and decrypt correctly.
|
||||
- [ ] **Key missing:** app boots; secret features degrade gracefully (no crash); confirm secrets are **not** silently stored/served in plaintext.
|
||||
- [ ] **Key rotated/wrong:** old ciphertext fails to decrypt **gracefully** (no crash, no stack leak); `safeDecrypt` fallback path is sane; re-encryption migrations (v0.77–0.79) behave.
|
||||
- [ ] Encryption key is never committed, logged, or returned in any API response.
|
||||
|
||||
**Container / deploy** (`Dockerfile`, `docker-compose.yml`, `docker-entrypoint.sh`, `deploy.sh`)
|
||||
- [ ] Image **builds**; container **starts**; app reachable; `/api/version` responds.
|
||||
- [ ] Entrypoint: creates `DATA_DIR`/`DB_DIR`/`BACKUP_DIR`, sets **`chmod 700`** (not world-readable), `chown`s to the non-root `bill` user, runs migrations when `RUN_DB_MIGRATIONS=true`.
|
||||
- [ ] Data **persists** across container restart (mounted volume); DB not re-created.
|
||||
- [ ] Runs as **non-root**; secrets come from env, not baked into the image.
|
||||
|
||||
**Update check / phone-home** (`services/updateCheckService.js`)
|
||||
- [ ] Confirm the external request to `REPO_API_URL` (default `dream.scheller.ltd`) is **disclosed** (privacy page) and **opt-out-able**; it must send no user data, only fetch the latest release; failure/offline degrades silently.
|
||||
|
||||
**Rate-limiter completeness** (`middleware/rateLimiter.js`) — beyond B13's list
|
||||
- [ ] `backupOperationLimiter` throttles admin backup/restore/cleanup; `skipRateLimitIfNoUsers` only relaxes limits on a genuinely empty instance (first-run), never afterward.
|
||||
|
||||
### B17 — Code health & consolidation (IMP)
|
||||
|
||||
An **improvement** batch: hunt for ways to make the codebase smaller, clearer, and more
|
||||
consistent — *without changing behavior*. Every candidate is logged as an `IMP-CODE-*`
|
||||
row in §2.1 with a concrete proposal; nothing is refactored silently. Consolidation
|
||||
lands only when it's behavior-preserving **and** covered by existing or added tests.
|
||||
|
||||
- [ ] **Duplication / DRY:** find logic copy-pasted across services/routes/components and
|
||||
propose a shared helper. Known hot spots: money formatting/rounding (`utils/money.js`
|
||||
vs inline), the `resolveDueDate` occurrence gate (must stay one implementation),
|
||||
error-response shaping (`utils/apiError.js` vs ad-hoc), React data-fetch patterns
|
||||
(repeated `useQuery` + toast + error handling → shared hooks).
|
||||
- [ ] **Dead / unused code:** unused exports, unreachable branches, orphaned files,
|
||||
commented-out blocks, unused deps (`depcheck`), unused UI components/CSS, leftover
|
||||
scaffolding. Propose deletion (verify no dynamic/`require`-by-string use first).
|
||||
- [ ] **Overlapping modules:** services that do similar work and could merge or share a
|
||||
core — e.g. the matching family (`matchSuggestionService`, `transactionMatchService`,
|
||||
`merchantStoreMatchService`), the bank-sync family (`bankSyncService`,
|
||||
`bankSyncWorker`, `bankSyncConfigService`, `simplefinService`). Map responsibilities;
|
||||
propose a consolidation only where it removes real duplication, not just moves it.
|
||||
- [ ] **Oversized / low-cohesion files:** split by concern where it aids navigation
|
||||
(e.g. `db/database.js` is very large — migrations vs query helpers vs settings could
|
||||
be separate modules). Propose the seams; don't split for its own sake.
|
||||
- [ ] **One canonical path per concern:** cents handling, date/tz, CSRF, error shape,
|
||||
pagination — confirm there's a single blessed way and flag divergences.
|
||||
- [ ] **Consistency:** naming, file layout, async patterns, import ordering; a lint rule
|
||||
that would prevent a class of the bugs found in earlier batches is itself an IMP.
|
||||
- [ ] **Test/infra dedupe:** repeated test setup → shared fixtures/helpers; flag coverage
|
||||
gaps a consolidation would risk.
|
||||
|
||||
### B18 — UX & information architecture / menus (IMP)
|
||||
|
||||
An **improvement** batch focused on the person using the app: is every feature
|
||||
*discoverable*, is every core flow *smooth*, and does every action live *where a user
|
||||
would look for it*? Candidates are logged as `IMP-UX-*` or `IMP-IA-*` in §2.1 with a
|
||||
concrete before/after proposal. Walk the app as a real user (both `user` and `admin`,
|
||||
desktop and mobile, light and dark), not just as a tester.
|
||||
|
||||
**Information architecture & menus**
|
||||
- [ ] **Discoverability:** is any feature buried, orphaned, or reachable only by typing a
|
||||
URL? Everything should be reachable from the nav, a menu, or a clear in-page entry.
|
||||
- [ ] **Navigation structure:** sidebar/nav grouping is logical; related pages sit
|
||||
together; admin vs user separation is clear; active state + page titles are correct.
|
||||
- [ ] **Menus where they belong:** actions that today are loose buttons or hidden should
|
||||
be grouped into sensible menus — overflow (`⋯`) menus on rows/cards, context menus,
|
||||
a consolidated **Settings** grouping, an account menu. Put related actions in one menu
|
||||
rather than scattering them. Flag anything that would be easier to find as a menu item.
|
||||
- [ ] **Command palette (`Ctrl+K`) coverage:** every page/primary action is reachable;
|
||||
no dead entries.
|
||||
- [ ] **Redundancy:** the same action offered in three places with different labels, or
|
||||
two pages that do nearly the same thing — propose consolidating.
|
||||
|
||||
**Experience quality**
|
||||
- [ ] **Core-flow friction:** count the clicks for the top tasks (pay a bill, add a bill,
|
||||
connect SimpleFIN, run a sync, import data). Propose shortcuts where a step is wasted.
|
||||
- [ ] **States:** every list/page has a clear **empty state with a next-step CTA**, a
|
||||
**loading** state (skeleton/spinner, no layout jump), and a **recoverable error**
|
||||
state — no dead ends, no silent failures.
|
||||
- [ ] **Feedback & safety:** state changes confirm (toast); destructive actions confirm
|
||||
and, where feasible, offer undo (bills already soft-delete — surface a restore path);
|
||||
long actions show progress.
|
||||
- [ ] **Consistency:** primary-action placement, button hierarchy, iconography,
|
||||
terminology, and confirmation patterns are consistent across pages.
|
||||
- [ ] **Mobile parity:** every action available on desktop is reachable on mobile; touch
|
||||
targets adequate; menus/overflow work on touch.
|
||||
- [ ] **Onboarding:** first-run and empty-account guidance explains the next step; advanced
|
||||
features (SimpleFIN, debt planning, backups) have a short in-context explanation.
|
||||
|
||||
---
|
||||
|
||||
## 8. Appendices
|
||||
|
|
@ -633,15 +469,15 @@ desktop and mobile, light and dark), not just as a tester.
|
|||
|
||||
### Appendix B — Exit / sign-off criteria
|
||||
|
||||
A cycle is release-ready when: **(Cycle 1 — all met ✅)**
|
||||
- [x] All batches B0–B15 ✅ (Chromium desktop + mobile via the E2E projects; light + dark, `user` + `admin` exercised). Cross-browser Firefox/Safari carried to Cycle 2.
|
||||
- [x] B15 smoke green on the **production build** (`npm run smoke:prod`).
|
||||
- [x] **Zero open S1/S2** in the Findings Log; S3/S4/IMP all fixed & archived.
|
||||
- [x] `npm run ci` green (server 109 + client 34 + build); no new console errors (verified in prod-smoke).
|
||||
- [x] Data export→import round-trip verified with no loss (`tests/exportImportRoundTrip.test.js`).
|
||||
- [x] Auth/authorization + data-isolation all pass (probe: IDOR → 404, CSRF → 403, admin/status → 403).
|
||||
- [x] Money and date/period correctness verified vs hand-calculated examples (`tests/money.test.js`, `aprService`, recurrence probe, reconciliation guards).
|
||||
- [x] All 14 fixes archived to `HISTORY.md` v0.41.0; cycle summary recorded (Cycle Log §1.1).
|
||||
A cycle is release-ready when:
|
||||
- [ ] All batches B0–B15 ✅ on the primary matrix (Chrome desktop + mobile, light + dark, `user` + `admin`).
|
||||
- [ ] B15 smoke green on the **production build**.
|
||||
- [ ] **Zero open S1/S2** in the Findings Log; S3/S4/IMP triaged.
|
||||
- [ ] `npm run ci` green; no new console errors.
|
||||
- [ ] Data export→import round-trip verified with no loss.
|
||||
- [ ] Auth/authorization + data-isolation all pass.
|
||||
- [ ] Money and date/period correctness verified vs hand-calculated examples.
|
||||
- [ ] All fixes for the cycle archived to `HISTORY.md`; cycle summary recorded (date, build/commit, environment).
|
||||
|
||||
### Appendix C — Page ↔ route ↔ API quick map
|
||||
|
||||
|
|
|
|||
|
|
@ -487,22 +487,4 @@ router.post('/check-updates', requireAuth, requireAdmin, async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// QA-B16-01: opt-out control for the external version check.
|
||||
const { getSetting, setSetting } = require('../db/database');
|
||||
|
||||
// GET /api/about-admin/update-check-setting — is the external version check enabled?
|
||||
router.get('/update-check-setting', requireAuth, requireAdmin, (req, res) => {
|
||||
res.json({ enabled: getSetting('update_check_enabled') !== 'false' });
|
||||
});
|
||||
|
||||
// PUT /api/about-admin/update-check-setting — enable/disable the external version check
|
||||
router.put('/update-check-setting', requireAuth, requireAdmin, (req, res) => {
|
||||
const { enabled } = req.body || {};
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
}
|
||||
setSetting('update_check_enabled', enabled ? 'true' : 'false');
|
||||
res.json({ enabled });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -49,31 +49,6 @@ router.get('/', (req, res) => {
|
|||
res.json(bills.map(serializeBill));
|
||||
});
|
||||
|
||||
// ── GET /api/bills/deleted ────────────────────────────────────────────────────
|
||||
// Soft-deleted bills still inside the 30-day recovery window (before the
|
||||
// retention GC purges them), newest deletion first. Powers the "Recently
|
||||
// deleted" restore view. Must be declared before GET /:id.
|
||||
const BILL_RETENTION_DAYS = 30; // matches pruneSoftDeletedFinancialRecords()
|
||||
router.get('/deleted', (req, res) => {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(`
|
||||
SELECT b.*, c.name AS category_name,
|
||||
CAST(julianday(b.deleted_at, '+${BILL_RETENTION_DAYS} days') - julianday('now') AS INTEGER) AS days_left
|
||||
FROM bills b
|
||||
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
|
||||
WHERE b.user_id = ?
|
||||
AND b.deleted_at IS NOT NULL
|
||||
AND b.deleted_at >= datetime('now', '-${BILL_RETENTION_DAYS} days')
|
||||
ORDER BY b.deleted_at DESC
|
||||
`).all(req.user.id);
|
||||
res.json(rows.map(row => ({
|
||||
...serializeBill(row),
|
||||
category_name: row.category_name,
|
||||
deleted_at: row.deleted_at,
|
||||
days_left: Math.max(0, row.days_left),
|
||||
})));
|
||||
});
|
||||
|
||||
// ── PUT /api/bills/reorder ───────────────────────────────────────────────────
|
||||
router.put('/reorder', (req, res) => {
|
||||
const db = getDb();
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ const {
|
|||
rejectMatchSuggestion,
|
||||
} = require('../services/matchSuggestionService');
|
||||
const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService');
|
||||
const { markMatched, markUnmatched } = require('../services/transactionMatchState');
|
||||
const { serializePayment } = require('../services/paymentValidation');
|
||||
const { todayLocal } = require('../utils/dates');
|
||||
|
||||
|
|
@ -70,7 +69,11 @@ router.post('/confirm', (req, res) => {
|
|||
const paymentForAccounting = db.prepare('SELECT * FROM payments WHERE id = ?').get(payResult.lastInsertRowid);
|
||||
applyBankPaymentAsSourceOfTruth(db, bill, paymentForAccounting);
|
||||
|
||||
markMatched(db, req.user.id, txId, billId);
|
||||
db.prepare(`
|
||||
UPDATE transactions
|
||||
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(billId, txId, req.user.id);
|
||||
|
||||
// Learn a merchant→bill rule from this explicit confirmation so future
|
||||
// synced transactions from the same merchant auto-match. Best-effort.
|
||||
|
|
@ -120,7 +123,11 @@ router.post('/:transactionId/unmatch', (req, res) => {
|
|||
UPDATE payments SET deleted_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE transaction_id = ? AND payment_source = 'transaction_match' AND deleted_at IS NULL
|
||||
`).run(txId);
|
||||
markUnmatched(db, req.user.id, txId);
|
||||
db.prepare(`
|
||||
UPDATE transactions
|
||||
SET matched_bill_id = NULL, match_status = 'unmatched', updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(txId, req.user.id);
|
||||
db.exec('COMMIT');
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ const { getDb } = require('../db/database');
|
|||
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService');
|
||||
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation');
|
||||
const { getCycleRange, resolveDueDate } = require('../services/statusService');
|
||||
const { markUnmatched } = require('../services/transactionMatchState');
|
||||
const {
|
||||
markProvisionalManualPaymentsOverridden,
|
||||
reactivatePaymentsOverriddenBy,
|
||||
|
|
@ -178,7 +177,11 @@ router.post('/:id/undo-auto', (req, res) => {
|
|||
}
|
||||
reactivatePaymentsOverriddenBy(db, payment.id);
|
||||
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id);
|
||||
markUnmatched(db, req.user.id, payment.transaction_id);
|
||||
db.prepare(`
|
||||
UPDATE transactions
|
||||
SET match_status = 'unmatched', matched_bill_id = NULL, updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(payment.transaction_id, req.user.id);
|
||||
})();
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ router.get('/', (req, res) => {
|
|||
items: [
|
||||
'The only external communication performed by the application is an optional version check to determine whether the latest software release is installed.',
|
||||
'This communication does not include your bill data or personal information.',
|
||||
'An administrator can disable the version check entirely in the admin panel; when disabled, the application makes no external requests.',
|
||||
'Version check information is not tracked or stored server-side and is used solely to determine software update availability.',
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ const {
|
|||
} = require('../services/transactionService');
|
||||
const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService');
|
||||
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
|
||||
const { markUnmatched } = require('../services/transactionMatchState');
|
||||
const {
|
||||
ignoreTransaction,
|
||||
matchTransactionToBill,
|
||||
|
|
@ -795,7 +794,11 @@ router.post('/unmatch-bulk', (req, res) => {
|
|||
reactivatePaymentsOverriddenBy(db, payment.id);
|
||||
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id);
|
||||
}
|
||||
markUnmatched(db, userId, txId);
|
||||
db.prepare(`
|
||||
UPDATE transactions
|
||||
SET match_status = 'unmatched', matched_bill_id = NULL, updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(txId, userId);
|
||||
results.push({ transaction_id: txId, ok: true });
|
||||
} else {
|
||||
// Standard service unmatch (restores balance for transaction_match payments)
|
||||
|
|
|
|||
|
|
@ -135,29 +135,14 @@ function pruneImportHistory(maxAgeDays) {
|
|||
/**
|
||||
* Permanently purge soft-deleted bills and categories after a 30-day recovery
|
||||
* window. Bill deletion cascades to bill-owned records via foreign keys.
|
||||
*
|
||||
* transactions.matched_bill_id is ON DELETE SET NULL, so purging a bill nulls the
|
||||
* pointer on any matched transaction but would leave match_status='matched' — a
|
||||
* limbo row excluded from spending (match_status != 'matched') yet attributed to no
|
||||
* bill. Release those matches back to 'unmatched' in the same transaction (and
|
||||
* self-heal any pre-existing orphans) so purged bills don't silently drop spend.
|
||||
*/
|
||||
function pruneSoftDeletedFinancialRecords(maxAgeDays = 30) {
|
||||
const db = getDb();
|
||||
const cutoff = `-${maxAgeDays} days`;
|
||||
const purge = db.transaction(() => {
|
||||
const releasedMatches = db.prepare(`
|
||||
UPDATE transactions
|
||||
SET match_status = 'unmatched', matched_bill_id = NULL, updated_at = datetime('now')
|
||||
WHERE match_status = 'matched'
|
||||
AND (matched_bill_id IS NULL
|
||||
OR matched_bill_id IN (
|
||||
SELECT id FROM bills WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?)
|
||||
))
|
||||
`).run(cutoff).changes;
|
||||
const bills = db.prepare("DELETE FROM bills WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?)").run(cutoff).changes;
|
||||
const categories = db.prepare("DELETE FROM categories WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?)").run(cutoff).changes;
|
||||
return { bills, categories, releasedMatches };
|
||||
return { bills, categories };
|
||||
});
|
||||
return purge();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ const {
|
|||
getTransactionForUser,
|
||||
} = require('./transactionService');
|
||||
const { serializePayment } = require('./paymentValidation');
|
||||
const { markMatched, markUnmatched, markIgnored } = require('./transactionMatchState');
|
||||
|
||||
const MATCH_PAYMENT_SOURCE = 'transaction_match';
|
||||
const MATCH_PAYMENT_METHOD = 'transaction_match';
|
||||
|
|
@ -256,7 +255,14 @@ function matchTransactionToBill(userId, transactionId, billId, opts = {}) {
|
|||
const bill = getOwnedBill(db, userId, billId);
|
||||
const paymentId = createOrUpdateMatchPayment(db, userId, transaction, bill);
|
||||
|
||||
markMatched(db, userId, transaction.id, bill.id, { resetIgnored: true });
|
||||
db.prepare(`
|
||||
UPDATE transactions
|
||||
SET matched_bill_id = ?,
|
||||
match_status = 'matched',
|
||||
ignored = 0,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(bill.id, transaction.id, userId);
|
||||
|
||||
if (opts.learnMerchant) {
|
||||
const { learnMerchantRuleFromMatch } = require('./billMerchantRuleService');
|
||||
|
|
@ -275,7 +281,14 @@ function unmatchTransaction(userId, transactionId) {
|
|||
const transaction = getOwnedTransaction(db, userId, transactionId);
|
||||
const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id);
|
||||
|
||||
markUnmatched(db, userId, transaction.id, { resetIgnored: true });
|
||||
db.prepare(`
|
||||
UPDATE transactions
|
||||
SET matched_bill_id = NULL,
|
||||
match_status = 'unmatched',
|
||||
ignored = 0,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(transaction.id, userId);
|
||||
|
||||
return responseForTransaction(db, userId, transaction.id, null, { removed_payment: removedPayment });
|
||||
});
|
||||
|
|
@ -289,7 +302,14 @@ function ignoreTransaction(userId, transactionId) {
|
|||
const transaction = getOwnedTransaction(db, userId, transactionId);
|
||||
const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id);
|
||||
|
||||
markIgnored(db, userId, transaction.id);
|
||||
db.prepare(`
|
||||
UPDATE transactions
|
||||
SET ignored = 1,
|
||||
match_status = 'ignored',
|
||||
matched_bill_id = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(transaction.id, userId);
|
||||
|
||||
return responseForTransaction(db, userId, transaction.id, null, { removed_payment: removedPayment });
|
||||
});
|
||||
|
|
@ -302,7 +322,14 @@ function unignoreTransaction(userId, transactionId) {
|
|||
const tx = db.transaction(() => {
|
||||
const transaction = getOwnedTransaction(db, userId, transactionId);
|
||||
|
||||
markUnmatched(db, userId, transaction.id, { resetIgnored: true });
|
||||
db.prepare(`
|
||||
UPDATE transactions
|
||||
SET ignored = 0,
|
||||
match_status = 'unmatched',
|
||||
matched_bill_id = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(transaction.id, userId);
|
||||
|
||||
return responseForTransaction(db, userId, transaction.id);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// Canonical writers for a transaction's match state.
|
||||
//
|
||||
// A transaction's match state lives in three columns that must move together:
|
||||
// match_status 'unmatched' | 'matched' | 'ignored'
|
||||
// matched_bill_id the bill when matched, else NULL
|
||||
// ignored 1 when explicitly ignored, else 0
|
||||
//
|
||||
// These were previously updated by copy-pasted inline UPDATEs across routes and
|
||||
// services, which is exactly how they drift out of sync — e.g. QA-B5-04, where a
|
||||
// bill purge left match_status='matched' with a NULL bill. Routing every
|
||||
// single-transaction transition through these helpers keeps the columns
|
||||
// consistent by construction. All are ownership-scoped (user_id) and return the
|
||||
// number of rows changed. Bulk transitions (auto-match sweep, retention purge)
|
||||
// stay in their own services where the WHERE clause is fundamentally different.
|
||||
|
||||
/**
|
||||
* Mark a transaction matched to a bill.
|
||||
* @param {boolean} [opts.resetIgnored] also clear the ignored flag (used when
|
||||
* matching a transaction directly, which implicitly un-ignores it).
|
||||
*/
|
||||
function markMatched(db, userId, transactionId, billId, { resetIgnored = false } = {}) {
|
||||
return db.prepare(`
|
||||
UPDATE transactions
|
||||
SET matched_bill_id = ?, match_status = 'matched'${resetIgnored ? ', ignored = 0' : ''}, updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(billId, transactionId, userId).changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a transaction's match — back to 'unmatched' with no bill.
|
||||
* @param {boolean} [opts.resetIgnored] also clear the ignored flag (un-ignore).
|
||||
*/
|
||||
function markUnmatched(db, userId, transactionId, { resetIgnored = false } = {}) {
|
||||
return db.prepare(`
|
||||
UPDATE transactions
|
||||
SET matched_bill_id = NULL, match_status = 'unmatched'${resetIgnored ? ', ignored = 0' : ''}, updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(transactionId, userId).changes;
|
||||
}
|
||||
|
||||
/** Mark a transaction ignored — dropped from matching, no bill. */
|
||||
function markIgnored(db, userId, transactionId) {
|
||||
return db.prepare(`
|
||||
UPDATE transactions
|
||||
SET ignored = 1, match_status = 'ignored', matched_bill_id = NULL, updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(transactionId, userId).changes;
|
||||
}
|
||||
|
||||
module.exports = { markMatched, markUnmatched, markIgnored };
|
||||
|
|
@ -4,18 +4,9 @@
|
|||
* 5 minutes for errors) so the status page stays fast under load.
|
||||
*/
|
||||
|
||||
const { getSetting } = require('../db/database');
|
||||
|
||||
const REPO_API_BASE = process.env.REPO_API_URL
|
||||
|| 'https://dream.scheller.ltd/api/v1/repos/null/BillTracker';
|
||||
|
||||
// QA-B16-01: the version check is opt-out-able. Admins can disable the external
|
||||
// request via the `update_check_enabled` setting (default on). When off, no
|
||||
// network call is made and a `disabled` status is returned.
|
||||
function updateCheckEnabled() {
|
||||
return getSetting('update_check_enabled') !== 'false';
|
||||
}
|
||||
|
||||
const TTL_OK_MS = 60 * 60 * 1000; // 1 hour on success
|
||||
const TTL_ERROR_MS = 5 * 60 * 1000; // 5 min on error (avoid hammering)
|
||||
const FETCH_TIMEOUT_MS = 8_000;
|
||||
|
|
@ -48,22 +39,6 @@ function compareVersions(a, b) {
|
|||
async function checkForUpdates(force = false) {
|
||||
const now = Date.now();
|
||||
|
||||
// Opt-out: no external request when disabled by the admin.
|
||||
if (!updateCheckEnabled()) {
|
||||
return {
|
||||
current_version: getCurrentVersion(),
|
||||
latest_version: null,
|
||||
up_to_date: null,
|
||||
has_update: false,
|
||||
latest_release_url: null,
|
||||
published_at: null,
|
||||
last_checked_at: null,
|
||||
error: null,
|
||||
disabled: true,
|
||||
cached: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!force && _cache.result && now < _cache.expiresAt) {
|
||||
return { ..._cache.result, cached: true };
|
||||
}
|
||||
|
|
@ -138,4 +113,4 @@ async function checkForUpdates(force = false) {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { checkForUpdates, updateCheckEnabled };
|
||||
module.exports = { checkForUpdates };
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const backupPath = path.join(os.tmpdir(), `${testId}-backups`);
|
|||
process.env.DB_PATH = dbPath;
|
||||
process.env.BACKUP_PATH = backupPath;
|
||||
|
||||
const { closeDb, setSetting, getDb } = require('../db/database');
|
||||
const { closeDb, setSetting } = require('../db/database');
|
||||
const {
|
||||
BACKUP_DIR,
|
||||
createBackup,
|
||||
|
|
@ -34,7 +34,6 @@ const {
|
|||
const {
|
||||
pruneOrphanedBackupPartials,
|
||||
pruneStaleExportFiles,
|
||||
pruneSoftDeletedFinancialRecords,
|
||||
} = require('../services/cleanupService');
|
||||
|
||||
// ── Teardown ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -313,75 +312,3 @@ test('pruneStaleExportFiles ignores non-bill-tracker files in tmpdir', () => {
|
|||
|
||||
try { fs.unlinkSync(other); } catch {}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// cleanupService — pruneSoftDeletedFinancialRecords (QA-B5-04)
|
||||
// Purging a soft-deleted bill fires the matched_bill_id ON DELETE SET NULL FK.
|
||||
// Without releasing the match, the transaction would be left match_status='matched'
|
||||
// with matched_bill_id=NULL — a limbo row excluded from spending yet tied to no bill.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function seedMatchedTxn(db, { deletedAt }) {
|
||||
const userId = db.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)",
|
||||
).run(`prune-${Math.random().toString(36).slice(2)}`).lastInsertRowid;
|
||||
const billId = db.prepare(
|
||||
"INSERT INTO bills (user_id, name, due_day, expected_amount, active, deleted_at) VALUES (?, 'Purge Me', 1, 1000, 1, ?)",
|
||||
).run(userId, deletedAt).lastInsertRowid;
|
||||
const txId = db.prepare(
|
||||
"INSERT INTO transactions (user_id, source_type, provider_transaction_id, amount, match_status, matched_bill_id, pending, ignored) VALUES (?, 'manual', ?, -1000, 'matched', ?, 0, 0)",
|
||||
).run(userId, `prune-tx-${billId}`, billId).lastInsertRowid;
|
||||
return { userId, billId, txId };
|
||||
}
|
||||
|
||||
test('pruneSoftDeletedFinancialRecords releases matches on purged bills (QA-B5-04)', () => {
|
||||
const db = getDb();
|
||||
db.pragma('foreign_keys = ON');
|
||||
const { billId, txId } = seedMatchedTxn(db, { deletedAt: '2020-01-01 00:00:00' });
|
||||
|
||||
const before = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(txId);
|
||||
assert.equal(before.match_status, 'matched');
|
||||
assert.equal(before.matched_bill_id, billId);
|
||||
|
||||
const result = pruneSoftDeletedFinancialRecords(30);
|
||||
assert.ok(result.bills >= 1, 'the old soft-deleted bill was purged');
|
||||
assert.ok(result.releasedMatches >= 1, 'the match was released');
|
||||
|
||||
assert.equal(db.prepare('SELECT id FROM bills WHERE id = ?').get(billId), undefined, 'bill is gone');
|
||||
const after = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(txId);
|
||||
assert.equal(after.match_status, 'unmatched', 'transaction is no longer stuck as matched');
|
||||
assert.equal(after.matched_bill_id, null, 'matched_bill_id cleared');
|
||||
});
|
||||
|
||||
test('pruneSoftDeletedFinancialRecords self-heals pre-existing orphans (matched + NULL bill)', () => {
|
||||
const db = getDb();
|
||||
db.pragma('foreign_keys = ON');
|
||||
const userId = db.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)",
|
||||
).run(`orphan-${Math.random().toString(36).slice(2)}`).lastInsertRowid;
|
||||
const txId = db.prepare(
|
||||
"INSERT INTO transactions (user_id, source_type, provider_transaction_id, amount, match_status, matched_bill_id, pending, ignored) VALUES (?, 'manual', 'orphan-tx', -500, 'matched', NULL, 0, 0)",
|
||||
).run(userId).lastInsertRowid;
|
||||
|
||||
pruneSoftDeletedFinancialRecords(30);
|
||||
|
||||
const after = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(txId);
|
||||
assert.equal(after.match_status, 'unmatched', 'orphan healed back to unmatched');
|
||||
assert.equal(after.matched_bill_id, null);
|
||||
});
|
||||
|
||||
test('pruneSoftDeletedFinancialRecords leaves active matches untouched', () => {
|
||||
const db = getDb();
|
||||
db.pragma('foreign_keys = ON');
|
||||
// A recently soft-deleted bill (inside the recovery window) must NOT be purged,
|
||||
// and its match must be preserved.
|
||||
const recent = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||
const { billId, txId } = seedMatchedTxn(db, { deletedAt: recent });
|
||||
|
||||
pruneSoftDeletedFinancialRecords(30);
|
||||
|
||||
assert.ok(db.prepare('SELECT id FROM bills WHERE id = ?').get(billId), 'recent soft-deleted bill retained');
|
||||
const after = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(txId);
|
||||
assert.equal(after.match_status, 'matched', 'match within recovery window preserved');
|
||||
assert.equal(after.matched_bill_id, billId);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// IMP-UX-01: GET /api/bills/deleted lists soft-deleted bills still inside the
|
||||
// 30-day recovery window (newest first, with days_left), so the "Recently
|
||||
// deleted" view can offer a restore beyond the transient undo toast. Bills
|
||||
// purged past the window (or another user's) must not appear.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-bills-deleted-route-${process.pid}.sqlite`);
|
||||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database');
|
||||
|
||||
function createUser(db, suffix) {
|
||||
return db.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)",
|
||||
).run(`deleted-bills-${suffix}`).lastInsertRowid;
|
||||
}
|
||||
|
||||
// daysAgo === null → an active (not deleted) bill; otherwise soft-deleted N days ago.
|
||||
function insertBill(db, userId, name, daysAgo) {
|
||||
const id = db.prepare(
|
||||
'INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, ?, 1, 1000, ?)',
|
||||
).run(userId, name, daysAgo == null ? 1 : 0).lastInsertRowid;
|
||||
if (daysAgo != null) {
|
||||
db.prepare("UPDATE bills SET deleted_at = datetime('now', ?) WHERE id = ?").run(`-${daysAgo} days`, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function callGetDeleted(userId) {
|
||||
const router = require('../routes/bills');
|
||||
const layer = router.stack.find(item => item.route?.path === '/deleted' && item.route.methods.get);
|
||||
assert.ok(layer, 'GET /deleted route should exist');
|
||||
const handler = layer.route.stack[0].handle;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = { query: {}, params: {}, user: { id: userId, role: 'user' } };
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(data) { resolve({ status: this.statusCode, data }); },
|
||||
};
|
||||
try { handler(req, res); } catch (err) { reject(err); }
|
||||
});
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
test('GET /bills/deleted returns only recoverable soft-deleted bills, newest first', async () => {
|
||||
const db = getDb();
|
||||
const userId = createUser(db, 'a');
|
||||
|
||||
insertBill(db, userId, 'Active Bill', null); // not deleted
|
||||
insertBill(db, userId, 'Recently Deleted', 2); // 2 days ago
|
||||
insertBill(db, userId, 'Older Deleted', 20); // 20 days ago
|
||||
insertBill(db, userId, 'Purgeable', 40); // past the 30-day window
|
||||
|
||||
const { status, data } = await callGetDeleted(userId);
|
||||
assert.equal(status, 200);
|
||||
|
||||
const names = data.map(b => b.name);
|
||||
assert.deepEqual(names, ['Recently Deleted', 'Older Deleted'], 'only in-window bills, newest first');
|
||||
assert.ok(!names.includes('Active Bill'), 'active bill excluded');
|
||||
assert.ok(!names.includes('Purgeable'), 'past-window bill excluded');
|
||||
|
||||
const recentRow = data.find(b => b.name === 'Recently Deleted');
|
||||
assert.ok(recentRow.days_left >= 27 && recentRow.days_left <= 28, `~28 days left, got ${recentRow.days_left}`);
|
||||
assert.ok(recentRow.deleted_at, 'exposes deleted_at');
|
||||
assert.equal(recentRow.expected_amount, 10, 'money serialized to dollars (cents/100)');
|
||||
});
|
||||
|
||||
test('GET /bills/deleted isolates by user', async () => {
|
||||
const db = getDb();
|
||||
const me = createUser(db, 'me');
|
||||
const other = createUser(db, 'other');
|
||||
insertBill(db, me, 'Mine', 1);
|
||||
insertBill(db, other, 'Theirs', 1);
|
||||
|
||||
const { data } = await callGetDeleted(me);
|
||||
const names = data.map(b => b.name);
|
||||
assert.ok(names.includes('Mine'));
|
||||
assert.ok(!names.includes('Theirs'), "never leaks another user's deleted bills");
|
||||
});
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// IMP-CODE-02: the migration arrays were extracted from db/database.js into
|
||||
// factory modules. These guard the invariants db/database.js relies on:
|
||||
// - both modules load and build their arrays (run/check bodies aren't invoked
|
||||
// at construction, so empty deps are fine);
|
||||
// - the versioned list has no duplicate versions;
|
||||
// - every legacy-reconcile version exists in the versioned list (reconcile only
|
||||
// marks known migrations as applied — a reconcile version with no versioned
|
||||
// counterpart is the drift the in-app assertion warns about).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const buildVersioned = require('../db/migrations/versionedMigrations');
|
||||
const buildReconcile = require('../db/migrations/legacyReconcileMigrations');
|
||||
|
||||
test('both migration modules build their arrays with no deps needed at construction', () => {
|
||||
const versioned = buildVersioned({});
|
||||
const reconcile = buildReconcile({});
|
||||
assert.ok(Array.isArray(versioned) && versioned.length > 0, 'versioned array non-empty');
|
||||
assert.ok(Array.isArray(reconcile) && reconcile.length > 0, 'reconcile array non-empty');
|
||||
for (const m of versioned) assert.equal(typeof m.run, 'function', `${m.version} has a run()`);
|
||||
for (const m of reconcile) assert.equal(typeof m.check, 'function', `${m.version} has a check()`);
|
||||
});
|
||||
|
||||
test('versioned migration versions are unique', () => {
|
||||
const versions = buildVersioned({}).map(m => m.version);
|
||||
assert.equal(new Set(versions).size, versions.length, 'no duplicate versions');
|
||||
});
|
||||
|
||||
test('every legacy-reconcile version exists in the versioned list (no drift)', () => {
|
||||
const versionedSet = new Set(buildVersioned({}).map(m => m.version));
|
||||
const orphans = buildReconcile({}).map(m => m.version).filter(v => !versionedSet.has(v));
|
||||
assert.deepEqual(orphans, [], `reconcile versions with no versioned counterpart: ${orphans.join(', ')}`);
|
||||
});
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// IMP-CODE-03: the canonical match-state writers keep match_status,
|
||||
// matched_bill_id and ignored moving together, so no path can leave the columns
|
||||
// inconsistent (the QA-B5-04 class of bug).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-match-state-${process.pid}.sqlite`);
|
||||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database');
|
||||
const { markMatched, markUnmatched, markIgnored } = require('../services/transactionMatchState');
|
||||
|
||||
let db, userId, otherId, billId;
|
||||
|
||||
function newTxn(uid, { status = 'unmatched', bill = null, ignored = 0 } = {}) {
|
||||
return db.prepare(
|
||||
'INSERT INTO transactions (user_id, source_type, provider_transaction_id, amount, match_status, matched_bill_id, ignored) VALUES (?, ?, ?, -1000, ?, ?, ?)',
|
||||
).run(uid, 'manual', `ms-${Math.random().toString(36).slice(2)}`, status, bill, ignored).lastInsertRowid;
|
||||
}
|
||||
const row = (id) => db.prepare('SELECT match_status, matched_bill_id, ignored FROM transactions WHERE id = ?').get(id);
|
||||
|
||||
test.before(() => {
|
||||
db = getDb();
|
||||
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('ms-user','x','user',1)").run().lastInsertRowid;
|
||||
otherId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('ms-other','x','user',1)").run().lastInsertRowid;
|
||||
billId = db.prepare('INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, ?, 1, 1000, 1)').run(userId, 'Bill').lastInsertRowid;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + suffix); } catch {} }
|
||||
});
|
||||
|
||||
test('markMatched sets status + bill together', () => {
|
||||
const id = newTxn(userId);
|
||||
const changes = markMatched(db, userId, id, billId);
|
||||
assert.equal(changes, 1);
|
||||
assert.deepEqual(row(id), { match_status: 'matched', matched_bill_id: billId, ignored: 0 });
|
||||
});
|
||||
|
||||
test('markMatched leaves the ignored flag unless resetIgnored is set', () => {
|
||||
const kept = newTxn(userId, { ignored: 1, status: 'ignored' });
|
||||
markMatched(db, userId, kept, billId);
|
||||
assert.equal(row(kept).ignored, 1, 'ignored preserved by default');
|
||||
|
||||
const cleared = newTxn(userId, { ignored: 1, status: 'ignored' });
|
||||
markMatched(db, userId, cleared, billId, { resetIgnored: true });
|
||||
assert.equal(row(cleared).ignored, 0, 'resetIgnored clears it');
|
||||
});
|
||||
|
||||
test('markUnmatched clears the bill and status (never leaves matched+NULL)', () => {
|
||||
const id = newTxn(userId, { status: 'matched', bill: billId });
|
||||
markUnmatched(db, userId, id);
|
||||
assert.deepEqual(row(id), { match_status: 'unmatched', matched_bill_id: null, ignored: 0 });
|
||||
});
|
||||
|
||||
test('markUnmatched with resetIgnored un-ignores', () => {
|
||||
const id = newTxn(userId, { status: 'ignored', ignored: 1 });
|
||||
markUnmatched(db, userId, id, { resetIgnored: true });
|
||||
assert.deepEqual(row(id), { match_status: 'unmatched', matched_bill_id: null, ignored: 0 });
|
||||
});
|
||||
|
||||
test('markIgnored sets ignored + status, clears bill', () => {
|
||||
const id = newTxn(userId, { status: 'matched', bill: billId });
|
||||
markIgnored(db, userId, id);
|
||||
assert.deepEqual(row(id), { match_status: 'ignored', matched_bill_id: null, ignored: 1 });
|
||||
});
|
||||
|
||||
test('all writers are ownership-scoped — never touch another user\'s transaction', () => {
|
||||
const foreign = newTxn(otherId, { status: 'matched', bill: null });
|
||||
assert.equal(markMatched(db, userId, foreign, billId), 0);
|
||||
assert.equal(markUnmatched(db, userId, foreign), 0);
|
||||
assert.equal(markIgnored(db, userId, foreign), 0);
|
||||
assert.equal(row(foreign).match_status, 'matched', 'foreign row untouched');
|
||||
});
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// B16 / QA-B16-01: the external version check must be opt-out-able. When the
|
||||
// `update_check_enabled` setting is 'false', checkForUpdates must make NO network
|
||||
// request and report `disabled`. Default (unset/'true') performs the check.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-updatecheck-test-${process.pid}.sqlite`);
|
||||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, setSetting, closeDb } = require('../db/database');
|
||||
const svc = require('../services/updateCheckService');
|
||||
|
||||
test('disabled: makes no external request and returns disabled', async () => {
|
||||
getDb();
|
||||
setSetting('update_check_enabled', 'false');
|
||||
assert.equal(svc.updateCheckEnabled(), false);
|
||||
|
||||
const orig = global.fetch;
|
||||
let called = false;
|
||||
global.fetch = async () => { called = true; throw new Error('fetch must not be called when disabled'); };
|
||||
try {
|
||||
const r = await svc.checkForUpdates(true);
|
||||
assert.equal(r.disabled, true, 'reports disabled');
|
||||
assert.equal(called, false, 'no external fetch when disabled');
|
||||
} finally {
|
||||
global.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('enabled (default): performs the version check', async () => {
|
||||
setSetting('update_check_enabled', 'true');
|
||||
assert.equal(svc.updateCheckEnabled(), true);
|
||||
|
||||
const orig = global.fetch;
|
||||
global.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ tag_name: 'v99.0.0', html_url: 'https://example/rel', published_at: '2026-01-01' }),
|
||||
});
|
||||
try {
|
||||
const r = await svc.checkForUpdates(true);
|
||||
assert.notEqual(r.disabled, true);
|
||||
assert.equal(r.latest_version, '99.0.0');
|
||||
assert.equal(r.has_update, true);
|
||||
} finally {
|
||||
global.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.rmSync(dbPath + suffix); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue