feat(ts): type the Tracker API response with branded Dollars (A1)
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<TrackerResponse>.
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 <noreply@anthropic.com>
This commit is contained in:
parent
d02d3c9c72
commit
3c7a55c3a1
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { TrackerResponse } from '@/types';
|
||||||
|
|
||||||
// Fetch CSRF token from the server once and cache in memory.
|
// Fetch CSRF token from the server once and cache in memory.
|
||||||
// The cookie is httpOnly so document.cookie cannot access it directly.
|
// The cookie is httpOnly so document.cookie cannot access it directly.
|
||||||
let _csrfFetch: Promise<string> | null = null;
|
let _csrfFetch: Promise<string> | null = null;
|
||||||
|
|
@ -217,7 +219,7 @@ export const api = {
|
||||||
profileImportHistory: () => get('/profile/import-history'),
|
profileImportHistory: () => get('/profile/import-history'),
|
||||||
|
|
||||||
// Tracker
|
// Tracker
|
||||||
tracker: (y: number, m: number, params: QueryParams = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`),
|
tracker: (y: number, m: number, params: QueryParams = {}) => get<TrackerResponse>(`/tracker${queryString({ year: y, month: m, ...params })}`),
|
||||||
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
|
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
|
||||||
overdueCount: () => get('/tracker/overdue-count'),
|
overdueCount: () => get('/tracker/overdue-count'),
|
||||||
snoozeOverdue: (id: Id, data: Body) => put(`/bills/${id}/monthly-state`, data),
|
snoozeOverdue: (id: Id, data: Body) => put(`/bills/${id}/monthly-state`, data),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import { todayStr } from '@/lib/utils';
|
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 = [
|
export const MONTHS = [
|
||||||
'January','February','March','April','May','June',
|
'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' },
|
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(
|
export function paymentDateForTrackerMonth(
|
||||||
year: number,
|
year: number,
|
||||||
month: number,
|
month: number,
|
||||||
|
|
@ -110,7 +87,7 @@ export function amountSearchText(...values: unknown[]): string {
|
||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowThreshold(row: TrackerRow): number {
|
export function rowThreshold(row: TrackerRow): Dollars {
|
||||||
return row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
return row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,7 +222,7 @@ export function moveInArray<T>(items: T[], fromIndex: number, toIndex: number):
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function paymentSummary(row: TrackerRow, threshold: number) {
|
export function paymentSummary(row: TrackerRow, threshold: Dollars) {
|
||||||
const target = Number(threshold) || 0;
|
const target = Number(threshold) || 0;
|
||||||
const paid = Number(row.total_paid) || 0;
|
const paid = Number(row.total_paid) || 0;
|
||||||
const paidTowardDue = Number.isFinite(Number(row.paid_toward_due))
|
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);
|
: Math.max(paid - target, 0);
|
||||||
const remaining = Math.max(target - paidTowardDue, 0);
|
const remaining = Math.max(target - paidTowardDue, 0);
|
||||||
const percent = target > 0 ? Math.min(100, Math.round((paidTowardDue / target) * 100)) : 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 {
|
return {
|
||||||
target,
|
target: asDollars(target),
|
||||||
paid,
|
paid: asDollars(paid),
|
||||||
paidTowardDue,
|
paidTowardDue: asDollars(paidTowardDue),
|
||||||
overpaid,
|
overpaid: asDollars(overpaid),
|
||||||
remaining,
|
remaining: asDollars(remaining),
|
||||||
percent,
|
percent,
|
||||||
count: Array.isArray(row.payments) ? row.payments.length : 0,
|
count: Array.isArray(row.payments) ? row.payments.length : 0,
|
||||||
partial: paid > 0 && remaining > 0,
|
partial: paid > 0 && remaining > 0,
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue