refactor(ts): type the subscriptions API responses (Track C)

Promoted SubscriptionsPage's Subscription/SubData/Recommendation/
CatalogMatch/SubTx (+ deps) into @/types and wired subscriptions/
recommendations/transaction-matches/create/match-bill with get<T>/
post<T>. Removed the (r: any) in useSubscriptionRecommendations, the 5
page casts, and an unused CatalogMatch import. typecheck/lint/build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 12:11:36 -05:00
parent 8265b4a97f
commit 6bb8c63d39
5 changed files with 121 additions and 108 deletions

View File

@ -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<T>`/`post<T>`. 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<T>`/`post<T>`. `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<BankLedger>`, `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.

View File

@ -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<SubscriptionsResponse>('/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<SubscriptionTx[] | { transactions?: SubscriptionTx[] }>(`/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,

View File

@ -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,
});
}

View File

@ -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));

View File

@ -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';