From 83e6afa9e6c4b35fa6fcc33dd1d941d701ad4bb1 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 6 Jun 2026 22:07:55 -0500 Subject: [PATCH] feat(subscriptions): simplified SubscriptionsPage, inline actions, improved matching card, Service Catalog route - Extracted known-service catalog to dedicated /subscriptions/catalog route - Simplified main Subscriptions page to focus on tracked services + bank-backed recommendations - Replaced inline Pause/Resume with Edit + MoreHorizontal dropdown on subscription rows - Added 'Improve Matching' card linking to Service Catalog - Vite proxy respects API_PORT env var for dev flexibility - Added top_200_us_subscriptions_researched dataset - Updated HISTORY.md with v0.35.0 changes --- HISTORY.md | 14 + client/pages/SubscriptionsPage.jsx | 260 +- ...s_subscriptions_researched_2026-06-06.json | 6117 +++++++++++++++++ package-lock.json | 267 +- 4 files changed, 6573 insertions(+), 85 deletions(-) create mode 100644 docs/top_200_us_subscriptions_researched_2026-06-06.json diff --git a/HISTORY.md b/HISTORY.md index 1b8c674..f5f0f42 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -5,6 +5,8 @@ - **Improved unmatch flow — choice dialog + bulk deselect** — Clicking Unmatch on a linked transaction in the Bill modal now opens a two-option choice dialog instead of immediately removing the match. Option 1 ("Unmatch this payment only") confirms via a single AlertDialog and removes only that transaction. Option 2 ("Review all similar matches") fetches all linked transactions for the bill whose payee normalizes to the same prefix and opens a checklist dialog where each similar match is pre-checked. Users can deselect individual transactions to keep them matched, use All/None quick-selects, and optionally check a "Remove merchant rule" checkbox (shown only when a merchant rule matching the payee pattern exists on the bill). The confirm button shows the count of selected transactions and is disabled when nothing is selected. New backend endpoint `POST /api/transactions/unmatch-bulk` handles mixed `provider_sync` (restores balance + soft-deletes payment) and `transaction_match` (standard unmatch service) entries in a single database transaction. +- **Service Catalog page for subscription matching** — The full known-service catalog moved out of the main Subscriptions page and into its own dedicated route at `/subscriptions/catalog`. The catalog now acts as an advanced matching tool instead of a second subscriptions list: tracked entries appear under a "Tracking" header with price drift indicators, each linked entry can be edited in BillModal, Re-link opens a searchable dialog to swap or remove the catalog link, and Custom bank descriptors let users add exact payee strings from their bank statements to improve future matching. Untracked catalog entries remain searchable/filterable and can still be tracked individually or in bulk. The Subscriptions page now shows a compact "Improve Matching" card that links to the Service Catalog when users need to tune descriptors, fix a wrong service link, or connect an existing bill to a known service. Catalog load failures now show both inline error state and toast feedback. New migration v0.96 adds `bills.catalog_id FK` (backfilled for existing subscriptions via name matching) and the `user_catalog_descriptors` table for per-user custom payee strings; user descriptors are merged into `loadCatalog` so they improve auto-matching for only that user's account. + - **Subscription catalog: bank descriptors + pricing (2026 researched dataset)** — `subscription_catalog` now carries researched bank statement descriptor strings and common nicknames/slang from a 200-service dataset. Migration v0.95 adds four columns (`subcategory`, `starting_monthly_usd`, `starting_annual_usd`, `price_notes`) and a new `subscription_catalog_descriptors` table (1,501 rows: 1,069 bank descriptors + 432 slang terms). `loadCatalog` joins descriptors into each catalog entry at load time. `lookupCatalog` now checks bank statement descriptors first (score 2000+) before falling back to name/domain fuzzy-match (1000/500); this resolves cases where bank payee strings like "NETFLIX *SUBSCRIPTION" or "AMZN PRIME VIDEO" bore no obvious similarity to the service name. `catalogMatchPayload` now includes `starting_monthly_usd`. - **Improved unmatch flow — choice dialog + bulk deselect** — Clicking Unmatch on a linked transaction in the Bill modal now opens a two-option choice dialog. Option 1 ("Unmatch this payment only") shows a confirmation AlertDialog and removes just that transaction. Option 2 ("Review all similar matches") fetches all linked transactions for the bill whose payee normalizes to the same prefix and opens a checklist dialog: each similar match is pre-checked, users can deselect ones to keep, All/None quick-selects are available, and an optional "Remove merchant rule" checkbox appears when a matching merchant rule exists. Confirm shows the count of selected transactions. New backend endpoint `POST /api/transactions/unmatch-bulk` handles mixed `provider_sync` (restores balance + soft-deletes payment) and `transaction_match` entries in a single database transaction. @@ -26,6 +28,18 @@ ### 🔧 Changed +- **Subscription recommendations narrowed to bank-backed known services** — The Recommendations panel now only shows high-confidence (`90%+`) bank transaction matches that resolve to a known subscription catalog entry. Unknown recurring merchant patterns are no longer mixed into primary recommendations; those can be reviewed separately later without diluting the "known service" signal. Recommendation confidence now separates identity, amount, and cadence evidence instead of relying on a single recurrence score: user-added descriptors and researched bank descriptors score strongest, service name/domain/slang matches score lower, catalog starting monthly/annual pricing is used to judge amount plausibility, and recurring cadence/amount stability add confidence when multiple charges exist. A single exact known bank descriptor with a plausible amount can still appear as a 90%+ recommendation, but weak one-off name/domain matches no longer do. Recommendation cards now expose the evidence to the user with badges and reason chips for descriptor type, price check, recurring cadence, amount range, catalog starting price, account, last seen date, and average amount. + +- **Subscription recommendation review details** — Recommendation scoring now includes an ambiguity evidence bucket for broad merchants and very short service names such as Amazon, Apple, Google, Walmart, Disney, Microsoft, Target, and Max. Exact known/user bank descriptors remain strong, but weaker broad name/domain/slang matches receive a confidence penalty and surface a "Review" badge with reasons. Recommendation payloads now include the matched transaction previews, and the Subscriptions page has a Details dialog showing identity evidence, price evidence, cadence evidence, ambiguity notes, amount range, catalog pricing, source accounts, and the specific bank transactions behind the recommendation before the user tracks, declines, or links it to an existing bill. + +- **Subscription recommendations learn and prefer existing bills** — Recommendations now inspect the user's existing bills instead of hiding matches when a bill name already exists. If an existing bill matches the known service or bank merchant, and especially when amount and due day line up, the recommendation's preferred action becomes "Link existing" rather than "Track new"; linking can also backfill the bill's `catalog_id` when the bill was named correctly but not yet connected to the catalog. New migration v0.97 adds `subscription_recommendation_feedback` so accepts, declines, existing-bill links, catalog relinks/unlinks, and descriptor additions/removals become per-user learning signals. Future scoring gets a modest user-specific boost or penalty from that history while hard-declined recommendations remain suppressed. Edge-case coverage now pins broad Apple/Amazon/Google one-off purchases, annual known-service charges, exact user descriptors, existing-bill link preference, and feedback-driven confidence changes. + +- **Subscription page actions simplified** — Recommendation cards now show one clear primary action (`Track Subscription` or `Link Existing Bill`), a Details icon, and a compact More menu for secondary actions such as choosing another bill, tracking as new, or dismissing. Tracked subscription rows now use a cleaner Edit + More menu pattern for pause/resume, and dialog/header/search/catalog action buttons use more consistent sizing and icon spacing. The noisy full reason-chip list was removed from the recommendation card surface and remains available in the Details dialog, making the page easier to scan. + +- **Subscriptions page simplified** — Removed the full known-service catalog from the main Subscriptions page to reduce overload. The page now focuses on tracked subscriptions, strict known-service recommendations from bank data, transaction search, and a small "Improve Matching" link to the dedicated Service Catalog. Supporting failures that were previously quiet now surface toasts: loading bills for the link-to-bill dialog, transaction search errors, and catalog load errors. + +- **Vite API proxy respects alternate API ports** — `vite.config.mjs` now reads `API_PORT` or `PORT` when configuring the `/api` proxy instead of hardcoding port `3000`. This lets local development run the Bill Tracker API on another port when `3000` is already occupied. + - **SimpleFIN transaction deduplication stable across disconnect/reconnect** — `provider_transaction_id` was built as `simplefin:{data_source_id}:{account_id}:{tx_id}`. When a user disconnected (deleting the data source) and reconnected (creating a new data source with a new ID), the ID in the key changed, so nothing matched the old orphaned rows and the full transaction history was duplicated. Changed the key format to `simplefin:{account_id}:{tx_id}` (no data_source_id) — the SimpleFIN account ID and transaction ID are stable identifiers assigned by the financial institution. The unique dedupe index was changed from `(data_source_id, provider_transaction_id)` to `(user_id, provider_transaction_id)` to match the new scope. Migration v0.93 rewrites all existing keys (stripping the numeric data_source_id segment), deduplicates any rows that are now identical after the key change (preserving the linked row over the orphan), and replaces the index atomically. - **Transaction currency from account, not hardcoded USD** — `normalizeTransaction` hardcoded `currency: 'USD'` for every imported transaction even though `normalizeAccount` correctly reads `rawAccount.currency`. Non-USD users' transactions were always mislabeled. The currency is now read from the account's own currency field; the `'USD'` fallback only applies when the account has no currency data. diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx index 02a7b5d..6c658bf 100644 --- a/client/pages/SubscriptionsPage.jsx +++ b/client/pages/SubscriptionsPage.jsx @@ -14,6 +14,7 @@ import { Info, Link2, Loader2, + MoreHorizontal, Pause, Plus, RefreshCw, @@ -32,6 +33,15 @@ import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, +} from '@/components/ui/tooltip'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import BillModal from '@/components/BillModal'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; @@ -69,7 +79,43 @@ function StatCard({ icon: Icon, label, value, hint }) { ); } -function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps }) { +function SubscriptionRowActions({ item, onEdit, onToggle, busy }) { + return ( +
+ + + + + + + onToggle(item)}> + {item.active ? : } + {item.active ? 'Pause' : 'Resume'} + + + +
+ ); +} + +function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy }) { return (
-
- - -
+ ); } @@ -232,10 +262,13 @@ function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, bus ))} - - - + @@ -277,7 +310,7 @@ function TxResultRow({ tx, onTrack }) { ${dollars} {!isMatched && ( )} @@ -285,6 +318,62 @@ function TxResultRow({ tx, onTrack }) { ); } +function RecommendationIconButton({ label, icon: Icon, onClick, disabled, className, variant = 'outline' }) { + return ( + + + + + {label} + + ); +} + +function RecommendationMoreMenu({ recommendation, existingBill, busy, onAccept, onDecline, onMatch, categoryId }) { + return ( + + + + + + {existingBill && ( + onAccept({ ...recommendation, category_id: categoryId })}> + + Track as new + + )} + onMatch(recommendation)}> + + {existingBill ? 'Choose different bill' : 'Link existing bill'} + + onDecline(recommendation)}> + + Dismiss + + + + ); +} + function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onQuickLink, onDetails, busy }) { const identity = recommendation.evidence?.identity; const amount = recommendation.evidence?.amount; @@ -294,7 +383,8 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o const existingBill = recommendation.existing_bill_match; return ( -
+ +
@@ -372,61 +462,49 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o Range {fmt(amountRange.min)}-{fmt(amountRange.max)} )} - {recommendation.reasons?.map(reason => ( - - {reason} - - ))}
-
+
+ onDetails(recommendation)} - > - - Details - - - - + /> +
-
+
+ ); } @@ -592,19 +670,33 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
)} - + - - +
+ {existingBill && ( + + )} + + +
@@ -915,11 +1007,11 @@ export default function SubscriptionsPage() {

- - @@ -960,6 +1052,7 @@ export default function SubscriptionsPage() { item={item} onEdit={bill => setModal({ bill })} onToggle={toggleSubscription} + busy={busyId === `toggle-${item.id}`} moveControls={moveControlsForGroup(active, true)(item, index)} dragProps={dragPropsForGroup(active, true)(item, index)} /> @@ -970,6 +1063,7 @@ export default function SubscriptionsPage() { item={item} onEdit={bill => setModal({ bill })} onToggle={toggleSubscription} + busy={busyId === `toggle-${item.id}`} moveControls={moveControlsForGroup(paused, false)(item, index)} dragProps={dragPropsForGroup(paused, false)(item, index)} /> @@ -1117,7 +1211,7 @@ export default function SubscriptionsPage() {

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

-