diff --git a/HISTORY.md b/HISTORY.md index 4a1d5e6..f689a80 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3,6 +3,7 @@ ### 🔌 API response typing cleanup (Track C) — promoting page shapes into `@/types`, endpoint by endpoint +- **[Client] Subscriptions domain typed.** Promoted the `SubscriptionsPage` shapes into `@/types` (`Subscription`, `SubscriptionSummary`/`…TopType`, `SubscriptionsResponse`, `Recommendation` + `RecommendationEvidence`/`…Transaction`/`ExistingBillMatch`/`CatalogMatch`, `SubscriptionTx`) and wired `subscriptions`/`subscriptionRecommendations`/`subscriptionTransactionMatches`/`createSubscriptionFromRecommendation`/`matchRecommendationToBill` with `get`/`post`. Dropped the `(r: any)` in `useSubscriptionRecommendations` (now typed) and the 5 page casts; removed an unused `CatalogMatch` import (Track E). - **[Client] Spending domain typed.** Promoted `SpendingPage`'s local response interfaces into `@/types` (`SpendingCategoryEntry`, `SpendingSummary`, `SpendingTransaction`/`…Response`, `SpendingIncomeTx`/`…Response`, `SpendingRule`, `CategoryGroup`, `CopyBudgetsResponse`) and wired `spendingSummary`/`spendingTransactions`/`spendingIncome`/`spendingCategoryRules`/`copySpendingBudgets`/`categoryGroups` with `get`/`post`. `useSpendingSummary`/`useSpendingTransactions`/`useCategoryGroups` now return typed data; the 6 spending `as {…}` casts are gone. Removed 2 now-unused type aliases the cast-removal orphaned (Track E). - **[Client] Bank-ledger domain typed end-to-end.** Promoted the `BankTransactionsPage` local interfaces (`Tx`/`Account`/`Ledger`/`LedgerSummary`/`CategoryBreakdown`/auto-categorize preview) into `@/types` as `BankLedgerTransaction` (extends the existing `BankTransaction`, so raw amounts stay branded `Cents`), `BankAccount`, `BankLedger`, `BankLedgerSummary`, `BankCategoryBreakdown`, `AutoCategorizePreview`. Wired the endpoints in `api.ts` (`bankTransactionsLedger` → `get`, `matchTransaction`/`unmatchTransaction` → `{ transaction }`, `ignoreTransaction`/`unignoreTransaction` → the transaction, `applyTransactionMerchantMatch`/`autoCategorizeTransactions` → their result shapes), so `useBankLedger`'s `data` is now typed and **all 9 `as {…}` casts in the page are gone** — a server field rename now fails in `@/types`, not silently at 9 call sites. Dropped a now-dead `Cents` import (Track E). typecheck/lint/build clean. diff --git a/client/api.ts b/client/api.ts index 2695def..8733b36 100644 --- a/client/api.ts +++ b/client/api.ts @@ -3,6 +3,7 @@ import type { BankLedger, BankLedgerTransaction, AutoCategorizePreview, SpendingSummary, SpendingTransactionsResponse, SpendingIncomeResponse, SpendingRule, CopyBudgetsResponse, CategoryGroup, + SubscriptionsResponse, Recommendation, SubscriptionTx, } from '@/types'; // Fetch CSRF token from the server once and cache in memory. @@ -285,19 +286,19 @@ export const api = { deleteBillTemplate: (id: Id) => del(`/bills/templates/${id}`), // Subscriptions - subscriptions: () => get('/subscriptions'), + subscriptions: () => get('/subscriptions'), confirmTransactionMatch: (transactionId: Id, billId: Id) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }), - matchRecommendationToBill: (transactionIds: unknown, billId: Id, merchant: unknown, catalogId: unknown, confidence: unknown) => post('/subscriptions/recommendations/match-bill', { + matchRecommendationToBill: (transactionIds: unknown, billId: Id, merchant: unknown, catalogId: unknown, confidence: unknown) => post<{ matched_count?: number; bill_name?: string }>('/subscriptions/recommendations/match-bill', { transaction_ids: transactionIds, bill_id: billId, merchant, catalog_id: catalogId, confidence, }), - subscriptionRecommendations: () => get('/subscriptions/recommendations'), - subscriptionTransactionMatches: (params: QueryParams = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`), + subscriptionRecommendations: () => get<{ recommendations?: Recommendation[] }>('/subscriptions/recommendations'), + subscriptionTransactionMatches: (params: QueryParams = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`), updateSubscription: (id: Id, data: Body) => _fetch('PATCH', `/subscriptions/${id}`, data), - createSubscriptionFromRecommendation: (data: Body) => post('/subscriptions/recommendations/create', data), + createSubscriptionFromRecommendation: (data: Body) => post<{ id?: number; name?: string }>('/subscriptions/recommendations/create', data), declineRecommendation: (recommendation: any) => post('/subscriptions/recommendations/decline', { decline_key: recommendation?.decline_key || recommendation, catalog_id: recommendation?.catalog_match?.id, diff --git a/client/hooks/useQueries.ts b/client/hooks/useQueries.ts index ee71991..59ef451 100644 --- a/client/hooks/useQueries.ts +++ b/client/hooks/useQueries.ts @@ -234,7 +234,7 @@ export function useSubscriptions() { export function useSubscriptionRecommendations() { return useQuery({ queryKey: ['subscription-recommendations'], - queryFn: () => api.subscriptionRecommendations().then((r: any) => r.recommendations || []), + queryFn: () => api.subscriptionRecommendations().then(r => r.recommendations || []), staleTime: 1000 * 60 * 5, }); } diff --git a/client/pages/SubscriptionsPage.tsx b/client/pages/SubscriptionsPage.tsx index 8838f77..209d9cf 100644 --- a/client/pages/SubscriptionsPage.tsx +++ b/client/pages/SubscriptionsPage.tsx @@ -53,7 +53,15 @@ import { getLinkImportPref } from '@/pages/SettingsPage'; import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; import { useVirtualizer } from '@tanstack/react-virtual'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; -import type { Bill, Category } from '@/types'; +import type { + Bill, Category, + Subscription, + SubscriptionsResponse as SubData, + SubscriptionSummary as SubSummary, + ExistingBillMatch, + Recommendation, + SubscriptionTx as SubTx, +} from '@/types'; import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow'; function fmt(v: number | null | undefined): string { @@ -93,102 +101,6 @@ interface CadenceLike { [key: string]: unknown; } -interface Subscription { - id: number; - name: string; - active?: boolean | number; - is_subscription?: boolean | number; - category_id?: number | null; - category_name?: string | null; - expected_amount?: number | null; - monthly_equivalent?: number | null; - yearly_equivalent?: number | null; - next_due_date?: string | null; - subscription_type?: string; - due_day?: number | null; - cycle_type?: string | null; - billing_cycle?: string | null; - cycle_day?: number | string | null; - autopay_enabled?: unknown; - has_2fa?: unknown; - has_merchant_rule?: unknown; - has_linked_transactions?: unknown; - reminder_days_before?: number | null; - [key: string]: unknown; -} - -interface TopType { type: string; monthly_total: number } -interface SubSummary { - active_count?: number; - paused_count?: number; - monthly_total?: number; - yearly_total?: number; - top_type?: TopType | null; -} -interface SubData { summary?: SubSummary; subscriptions?: Subscription[] } - -interface CatalogMatch { - id?: number | string; - name: string; - subscription_type?: string; - website?: string; - starting_monthly_usd?: number | null; - starting_annual_usd?: number | null; -} -interface ExistingBillMatch { - id: number; - name: string; - expected_amount?: number | null; - due_day?: number | null; - reasons?: string[]; -} -interface RecEvidence { - identity?: { label?: string }; - amount?: { label?: string; match?: string }; - cadence?: { label?: string; recurring?: boolean }; - amount_range?: { min: number; max: number }; - ambiguity?: { ambiguous?: boolean; label?: string; reasons?: string[] }; -} -interface RecTransaction { id: number; payee?: string; description?: string; memo?: string; date?: string; account?: string; amount?: number } -interface Recommendation { - id: number | string; - name: string; - subscription_type?: string; - occurrence_count?: number; - last_seen_date?: string; - expected_amount?: number | null; - monthly_equivalent?: number | null; - confidence?: number; - cycle_type?: string; - merchant?: string; - decline_key?: string; - transaction_ids?: (number | string)[]; - accounts?: string[]; - reasons?: string[]; - evidence?: RecEvidence; - existing_bill_match?: ExistingBillMatch | null; - catalog_match?: CatalogMatch | null; - transactions?: RecTransaction[]; - [key: string]: unknown; -} - -interface SubTx { - id: number; - amount: number; - payee?: string; - description?: string; - memo?: string; - account_name?: string; - data_source_name?: string; - account?: string; - match_status?: string; - matched_bill_name?: string; - catalog_match?: CatalogMatch | null; - posted_date?: string; - date?: string; - [key: string]: unknown; -} - interface SubDraft { name?: string; category_id?: number | null; @@ -1200,9 +1112,9 @@ type FlatItem = export default function SubscriptionsPage() { const queryClient = useQueryClient(); const { data: subData, isPending: loading } = useSubscriptions(); - const data: SubData = (subData as SubData | undefined) || { summary: {}, subscriptions: [] }; + const data: SubData = subData || { summary: {}, subscriptions: [] }; const { data: recommendationsRaw = [], isPending: recommendationsLoading } = useSubscriptionRecommendations(); - const recommendations = recommendationsRaw as Recommendation[]; + const recommendations = recommendationsRaw; const { data: categories = [] } = useCategories(); const { data: bills = [] } = useBills(); // Optimistic updates write through the query cache so every existing call site @@ -1270,7 +1182,7 @@ export default function SubscriptionsPage() { txDebounce.current = setTimeout(async () => { setTxSearching(true); try { - const result = await api.subscriptionTransactionMatches({ q, limit: 50 }) as SubTx[] | { transactions?: SubTx[] }; + const result = await api.subscriptionTransactionMatches({ q, limit: 50 }); setTxResults(Array.isArray(result) ? result : (result?.transactions ?? [])); } catch (err) { setTxResults([]); @@ -1300,7 +1212,7 @@ export default function SubscriptionsPage() { async function acceptRecommendation(recommendation: Recommendation) { setBusyId(`rec-${recommendation.id}`); try { - const created = await api.createSubscriptionFromRecommendation(recommendation) as { id?: number; name?: string } | null; + const created = await api.createSubscriptionFromRecommendation(recommendation); toast.success(`${recommendation.name} is now tracked.`); await refreshAll(); if (getLinkImportPref() && recommendation.merchant && created?.id) { @@ -1336,7 +1248,7 @@ export default function SubscriptionsPage() { recommendation.merchant, recommendation.catalog_match?.id, recommendation.confidence, - ) as { matched_count?: number; bill_name?: string }; + ); toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`); setMatchTarget(null); setRecommendations(prev => prev.filter(r => r.id !== recommendation.id)); diff --git a/client/types.ts b/client/types.ts index 0e6f49c..ba8963a 100644 --- a/client/types.ts +++ b/client/types.ts @@ -122,6 +122,105 @@ export interface SpendingRule { id: number; merchant: string; category_name?: st export interface CategoryGroup { id: number; name: string } export interface CopyBudgetsResponse { copied: number; budgets?: { category_id: number; amount: number }[] } +// The subscriptions page (GET /subscriptions, /subscriptions/recommendations, +// /subscriptions/transaction-matches). A Subscription is a decorated Bill; +// amounts here are dollars kept as plain `number` (the page uses a local +// formatter), except SubTx.amount which is a raw bank amount in CENTS. +export interface Subscription { + id: number; + name: string; + active?: boolean | number; + is_subscription?: boolean | number; + category_id?: number | null; + category_name?: string | null; + expected_amount?: number | null; + monthly_equivalent?: number | null; + yearly_equivalent?: number | null; + next_due_date?: string | null; + subscription_type?: string; + due_day?: number | null; + cycle_type?: string | null; + billing_cycle?: string | null; + cycle_day?: number | string | null; + autopay_enabled?: unknown; + has_2fa?: unknown; + has_merchant_rule?: unknown; + has_linked_transactions?: unknown; + reminder_days_before?: number | null; + [key: string]: unknown; +} +export interface SubscriptionTopType { type: string; monthly_total: number } +export interface SubscriptionSummary { + active_count?: number; + paused_count?: number; + monthly_total?: number; + yearly_total?: number; + top_type?: SubscriptionTopType | null; +} +export interface SubscriptionsResponse { summary?: SubscriptionSummary; subscriptions?: Subscription[] } + +export interface CatalogMatch { + id?: number | string; + name: string; + subscription_type?: string; + website?: string; + starting_monthly_usd?: number | null; + starting_annual_usd?: number | null; +} +export interface ExistingBillMatch { + id: number; + name: string; + expected_amount?: number | null; + due_day?: number | null; + reasons?: string[]; +} +export interface RecommendationEvidence { + identity?: { label?: string }; + amount?: { label?: string; match?: string }; + cadence?: { label?: string; recurring?: boolean }; + amount_range?: { min: number; max: number }; + ambiguity?: { ambiguous?: boolean; label?: string; reasons?: string[] }; +} +export interface RecommendationTransaction { id: number; payee?: string; description?: string; memo?: string; date?: string; account?: string; amount?: number } +export interface Recommendation { + id: number | string; + name: string; + subscription_type?: string; + occurrence_count?: number; + last_seen_date?: string; + expected_amount?: number | null; + monthly_equivalent?: number | null; + confidence?: number; + cycle_type?: string; + merchant?: string; + decline_key?: string; + transaction_ids?: (number | string)[]; + accounts?: string[]; + reasons?: string[]; + evidence?: RecommendationEvidence; + existing_bill_match?: ExistingBillMatch | null; + catalog_match?: CatalogMatch | null; + transactions?: RecommendationTransaction[]; + [key: string]: unknown; +} +// A raw bank transaction on the subscription-match search — amount in CENTS. +export interface SubscriptionTx { + id: number; + amount: number; + payee?: string; + description?: string; + memo?: string; + account_name?: string; + data_source_name?: string; + account?: string; + match_status?: string; + matched_bill_name?: string; + catalog_match?: CatalogMatch | null; + posted_date?: string; + date?: string; + [key: string]: unknown; +} + // A bill's month status. Mirrors the server's statusService. export type TrackerStatus = | 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';