From 3c7a55c3a183b2f7ebf65251b60513500d15257a Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 22:21:45 -0500 Subject: [PATCH] feat(ts): type the Tracker API response with branded Dollars (A1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New client/types.ts domain-types module. Types the full /tracker response — TrackerResponse { summary, bank_tracking, cashflow, rows } — with every money field branded `Dollars` (the server serializes cents→dollars, verified against statusService.buildTrackerRow + trackerService.getTracker + buildBankTracking + buildSafeToSpend). api.tracker() now returns Promise. TrackerRow + TrackerStatus move to @/types (canonical) and are re-exported from @/lib/trackerUtils for compat; trackerUtils' row money fields upgrade from plain `number` to branded `Dollars` (rowThreshold -> Dollars, paymentSummary re-brands its computed amounts via asDollars so callers can fmt() them directly). This is the first endpoint where the money brand flows end-to-end: fetch layer -> row helpers, and (in phase B) into the components that format them. Nested payloads no typed consumer reads yet (summary.trend, autopay_suggestion/ _stats, sparkline) are left `unknown`, to refine when consumed. asDollars is an identity at runtime, so behavior is unchanged: typecheck 0, lint 0 errors, build green, 48 client tests pass. Co-Authored-By: Claude Opus 4.8 --- client/api.ts | 4 +- client/lib/trackerUtils.ts | 51 ++++------- client/types.ts | 172 +++++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 37 deletions(-) create mode 100644 client/types.ts diff --git a/client/api.ts b/client/api.ts index 0acf133..607b948 100644 --- a/client/api.ts +++ b/client/api.ts @@ -1,3 +1,5 @@ +import type { TrackerResponse } from '@/types'; + // Fetch CSRF token from the server once and cache in memory. // The cookie is httpOnly so document.cookie cannot access it directly. let _csrfFetch: Promise | null = null; @@ -217,7 +219,7 @@ export const api = { profileImportHistory: () => get('/profile/import-history'), // Tracker - tracker: (y: number, m: number, params: QueryParams = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`), + tracker: (y: number, m: number, params: QueryParams = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`), upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`), overdueCount: () => get('/tracker/overdue-count'), snoozeOverdue: (id: Id, data: Body) => put(`/bills/${id}/monthly-state`, data), diff --git a/client/lib/trackerUtils.ts b/client/lib/trackerUtils.ts index 0772b7a..34be542 100644 --- a/client/lib/trackerUtils.ts +++ b/client/lib/trackerUtils.ts @@ -1,4 +1,10 @@ import { todayStr } from '@/lib/utils'; +import { asDollars, type Dollars } from '@/lib/money'; +import type { TrackerRow, TrackerStatus } from '@/types'; + +// Re-export the shared domain types so existing `@/lib/trackerUtils` importers +// keep resolving them (the canonical definitions live in `@/types`). +export type { TrackerRow, TrackerStatus } from '@/types'; export const MONTHS = [ 'January','February','March','April','May','June', @@ -53,35 +59,6 @@ export const STATUS_META = { skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' }, }; -// A tracker month's effective status for a bill. -export type TrackerStatus = - | 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped'; - -// The subset of a tracker row these helpers read. Rows come from the (untyped) -// tracker API; money fields are dollars (the server serializes cents→dollars). -// Branding them as `Dollars` is a later increment (when the API is typed) — for -// now the computed money fields are plain `number`. Fields the aggregation -// always populates are required; the rest are optional/nullable. -export interface TrackerRow { - name?: string | null; - status: TrackerStatus; - is_skipped?: boolean; - expected_amount: number; - actual_amount?: number | null; - total_paid: number; - paid_toward_due?: number | null; - overpaid_amount?: number | null; - previous_month_paid?: number | null; - autopay_suggestion?: unknown; - category_name?: string | null; - current_balance?: number | null; - minimum_payment?: number | null; - due_date?: string | null; - due_day?: number | null; - last_paid_date?: string | null; - payments?: unknown[]; -} - export function paymentDateForTrackerMonth( year: number, month: number, @@ -110,7 +87,7 @@ export function amountSearchText(...values: unknown[]): string { .join(' '); } -export function rowThreshold(row: TrackerRow): number { +export function rowThreshold(row: TrackerRow): Dollars { return row.actual_amount != null ? row.actual_amount : row.expected_amount; } @@ -245,7 +222,7 @@ export function moveInArray(items: T[], fromIndex: number, toIndex: number): return next; } -export function paymentSummary(row: TrackerRow, threshold: number) { +export function paymentSummary(row: TrackerRow, threshold: Dollars) { const target = Number(threshold) || 0; const paid = Number(row.total_paid) || 0; const paidTowardDue = Number.isFinite(Number(row.paid_toward_due)) @@ -256,12 +233,14 @@ export function paymentSummary(row: TrackerRow, threshold: number) { : Math.max(paid - target, 0); const remaining = Math.max(target - paidTowardDue, 0); const percent = target > 0 ? Math.min(100, Math.round((paidTowardDue / target) * 100)) : 0; + // Re-brand the computed amounts as dollars (arithmetic on branded numbers + // yields a plain number) so callers can pass them straight to fmt/formatUSD. return { - target, - paid, - paidTowardDue, - overpaid, - remaining, + target: asDollars(target), + paid: asDollars(paid), + paidTowardDue: asDollars(paidTowardDue), + overpaid: asDollars(overpaid), + remaining: asDollars(remaining), percent, count: Array.isArray(row.payments) ? row.payments.length : 0, partial: paid > 0 && remaining > 0, diff --git a/client/types.ts b/client/types.ts new file mode 100644 index 0000000..bb8ef50 --- /dev/null +++ b/client/types.ts @@ -0,0 +1,172 @@ +// Domain types for the API responses. Money fields are branded `Dollars` — the +// server stores integer cents and serializes dollars over the wire (via +// utils/money.fromCents), so every amount a component receives is dollars. Typing +// them `Dollars` lets `fmt`/`formatUSD` accept them while rejecting raw cents +// (e.g. bank-transaction `*_cents` fields), catching the cents-as-dollars bug +// class at compile time. +// +// Shapes are typed incrementally (endpoint-by-endpoint). Complex nested payloads +// that no typed consumer reads yet (trend series, autopay suggestion/stats, +// sparkline) are left `unknown` and refined when a consumer needs them. +import type { Dollars } from '@/lib/money'; + +// A bill's month status. Mirrors the server's statusService. +export type TrackerStatus = + | 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped'; + +// A payment record as serialized by the server (money in dollars). serializePayment +// spreads the full DB row, so extra columns pass through as `unknown`. +export interface Payment { + id: number; + bill_id?: number; + amount: Dollars; + paid_date: string; + payment_source?: string | null; + notes?: string | null; + balance_delta?: Dollars | null; + interest_delta?: Dollars | null; + created_at?: string; + deleted_at?: string | null; + [key: string]: unknown; +} + +// One tracker row: a bill's state for a given month. Built by +// statusService.buildTrackerRow and augmented by trackerService.getTracker. +export interface TrackerRow { + id: number; + name: string; + category_id: number | null; + category_name: string | null; + due_date: string; + due_day: number | null; + bucket: string; + expected_amount: Dollars; + notes: string | null; + total_paid: Dollars; + paid_toward_due: Dollars; + overpaid_amount: Dollars; + balance: Dollars; + has_payment: boolean; + is_settled: boolean; + last_paid_date: string | null; + last_payment_amount: Dollars | null; + status: TrackerStatus; + autopay_enabled: boolean; + autodraft_status: string | null; + auto_mark_paid: boolean; + billing_cycle: string | null; + cycle_type: string | null; + cycle_day: string | number | null; + current_balance: Dollars | null; + minimum_payment: Dollars | null; + interest_rate: number | null; + is_subscription: boolean; + has_2fa: boolean; + has_merchant_rule: boolean; + has_linked_transactions: boolean; + website: string | null; + autopay_verified_at: string | null; + inactive_reason: string | null; + inactivated_at: string | null; + sparkline: unknown; + autopay_stats: unknown; + payments: Payment[]; + // Augmented by getTracker: + actual_amount: Dollars | null; + is_skipped: boolean; + snoozed_until: string | null; + autopay_suggestion?: unknown; + previous_month_paid: Dollars; + // Bank-mode augmentation (present only when bank_tracking.enabled): + pending_cleared?: boolean; + bank_pending_count?: number; +} + +export interface TrackerSummary { + total_expected: Dollars; + total_starting: Dollars; + has_starting_amounts: boolean; + total_paid: Dollars; + paid_toward_due: Dollars; + remaining: Dollars; + total_remaining: Dollars; + remaining_period: string; + remaining_label: string; + remaining_hint: string; + overdue: Dollars; + count_paid: number; + count_upcoming: number; + count_late: number; + count_autodraft: number; + previous_month_total: Dollars; + trend: unknown; +} + +// Bank tracking is off (`{ enabled: false }`) or on with the account snapshot. +export type BankTracking = + | { enabled: false } + | { + enabled: true; + account_id: number; + account_name: string; + org_name: string; + balance: Dollars; + pending_payments: Dollars; + pending_days: number; + effective_balance: Dollars; + unpaid_this_month: Dollars; + remaining: Dollars; + last_updated: string | null; + }; + +export interface SafeToSpendUpcoming { + id: number; + name: string; + due_date: string; + amount: Dollars; + status: TrackerStatus; +} + +export interface CashflowTimelinePoint { + date: string; + balance: number; // running dollar balance; cashflowUtils treats it loosely + bills: unknown[]; + payday?: boolean; +} + +export interface TrackerCashflow { + next_payday: string | null; + days_until_payday: number; + available: Dollars; + safe_to_spend: Dollars; + still_due_total: Dollars; + still_due_count: number; + upcoming: SafeToSpendUpcoming[]; + timeline: CashflowTimelinePoint[]; + has_data: boolean; + uses_bank_balance: boolean; + period: string; + period_end_label: string; + period_starting: Dollars; + period_bills_total: Dollars; + period_paid: Dollars; + period_paid_count: number; + period_total_count: number; + period_projected: Dollars; + month_starting: Dollars; + month_bills_total: Dollars; + month_paid: Dollars; + month_paid_count: number; + month_total_count: number; + month_projected: Dollars; +} + +export interface TrackerResponse { + year: number; + month: number; + today: string; + summary: TrackerSummary; + bank_tracking: BankTracking; + cashflow: TrackerCashflow; + rows: TrackerRow[]; +}