refactor(frontend): call backend routes through Wayfinder instead of literal URLs (#772)

Follow-up to the Inertia v3 upgrade (#769). While auditing whether the
frontend used Wayfinder's current API, I found **32 HTTP call sites
across 16 files that built their URL by hand** — every one of which
already had a generated Wayfinder action sitting unused.

## Why this matters

Wayfinder exists so a route rename fails at **compile** time. A
hand-written `'/api/transactions/bulk'` fails at **runtime**, in
production, on a path a unit test can't catch because the tests mock
`axios`. The worst offender was `services/transaction-sync.ts` — the
offline sync service — which had five of them.

I verified each URL against `php artisan route:list` before changing it;
there were no missing routes, only unused generated ones.

## What changed

| File | Sites | Now uses |
| --- | --- | --- |
| `services/transaction-sync.ts` | 6 | `TransactionController` +
`Sync/TransactionSyncController` |
| `components/transactions/saved-filters.tsx` | 4 |
`Api/SavedFilterController` |
| `hooks/use-cashflow-data.ts` | 5 | `Api/CashflowAnalyticsController` |
| `components/transactions/import-transactions-drawer.tsx` | 3 |
`TransactionController@categorize` |
| `components/accounts/account-balance-chart.tsx` | 2 |
`Api/DashboardAnalyticsController` |
| `hooks/use-decrypt-account-names.ts` | 2 | `Api/AccountController` |
| `components/dashboard/net-worth-chart.tsx` | 2 | the two net-worth
preference controllers |
| `pages/transactions/index.tsx` | 2 |
`TransactionController@bulkUpdate` |
| `lib/import-config-storage.ts` | 2 |
`Api/AccountImportConfigController` |
| `app.tsx`, `use-decrypt-transactions.ts`,
`encryption-key-context.tsx`, `import-step-preview.tsx`,
`import-transactions-button.tsx`, `category-analysis-drawer.tsx`,
`settings/appearance.tsx` | 1 each | respective controllers |

## Query strings got simpler

The endpoints with parameters were assembling `URLSearchParams` by hand.
The generated `.url({ query })` helper does it, so that scaffolding is
gone:

```diff
-const periodParams = new URLSearchParams({ from: fromStr, to: toStr });
-const periodQuery = `?${periodParams.toString()}`;
-fetch(`/api/cashflow/breakdown${periodQuery}&type=income`)
+const periodQuery = { from: fromStr, to: toStr };
+fetch(cashflowBreakdown.url({ query: { ...periodQuery, type: 'income' } }))
```

`lib/import-config-storage.ts` also loses its local `configUrl()`
helper, which only existed to interpolate an account id.

## Two aliases, on purpose

`transaction-sync.ts` and `import-transactions-button.tsx` import with
`as` aliases because the plain names collide with a method (`update`,
`store`, `destroy`) and a `useState` variable (`importData`) already in
those files. Named imports are kept everywhere so tree-shaking still
works.

## Verification

- `bun run test` — **356/356, with zero test changes.** That is the
useful signal here: `transaction-sync.test.ts` asserts `axios.delete`
was called with the literal `'/transactions/txn-1'`, and it still
passes, so the generated URLs are byte-identical to the strings they
replaced.
- `bun run types` — no new errors. (`transaction-sync.ts:45` and the
`.test.tsx` matcher errors are pre-existing; the former just shifted
line number as imports were added.)
- `bun run build`, `bun run lint`, `bun run format`, `bun run dry` —
green.

### Browser check

Tests mock `axios`, so a wrong URL would still pass them. I exercised
the rewritten endpoints in a real browser and captured the actual
network traffic — all **200**, query strings identical to what the
manual code produced:

```
200 /api/cashflow/summary?from=2026-08-01&to=2026-08-31
200 /api/cashflow/trend?months=12&to=2026-08-31
200 /api/cashflow/breakdown?from=2026-08-01&to=2026-08-31&type=income
200 /api/cashflow/breakdown?from=2026-08-01&to=2026-08-31&type=expense
200 /api/dashboard/account/{id}/balance-evolution?from=2025-08-11&to=2026-08-11
200 /api/saved-filters
```

0 failed requests, 0 console errors across cashflow, transactions,
accounts, account detail and appearance.

## Out of scope

11 hardcoded **navigation** URLs remain (`href="/register"`,
`href="/privacy"`, `router.visit('/dashboard')`), mostly on the
marketing pages. They are static routes with a much lower rename risk,
so I left them for a separate pass rather than widen this diff.
This commit is contained in:
Víctor Falcón 2026-08-11 15:26:10 +02:00 committed by GitHub
parent fa6f6e2be3
commit ed157f6f8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 98 additions and 56 deletions

View File

@ -18,6 +18,7 @@ import {
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { toast, Toaster } from 'sonner';
import { update as updateTimezone } from './actions/App/Http/Controllers/Settings/TimezoneController';
import { AppErrorBoundary } from './components/app-error-boundary';
import { EncryptionKeyProvider } from './contexts/encryption-key-context';
import { PrivacyModeProvider } from './contexts/privacy-mode-context';
@ -214,7 +215,7 @@ createInertiaApp({
hasAttemptedTimezoneBackfill = true;
try {
await axios.patch('/settings/timezone', {
await axios.patch(updateTimezone.url(), {
timezone: detectedTimezone,
});
} catch {

View File

@ -1,3 +1,7 @@
import {
accountBalanceEvolution,
accountDailyBalanceEvolution,
} from '@/actions/App/Http/Controllers/Api/DashboardAnalyticsController';
import { AccountName } from '@/components/accounts/account-name';
import {
type ChartCurrencyMode,
@ -378,9 +382,10 @@ export function AccountBalanceChart({
if (currentGranularity === 'daily') {
// Fetch DAILY_DAYS + 1 days (extra day for DoD baseline)
const from = format(subDays(now, DAILY_DAYS), 'yyyy-MM-dd');
const params = new URLSearchParams({ from, to });
const response = await fetch(
`/api/dashboard/account/${account.id}/daily-balance-evolution?${params.toString()}`,
accountDailyBalanceEvolution.url(account.id, {
query: { from, to },
}),
);
const data: AccountDailyBalanceData = await response.json();
// Normalize daily data so the rest of the component works uniformly
@ -391,9 +396,10 @@ export function AccountBalanceChart({
});
} else {
const from = format(subMonths(now, 12), 'yyyy-MM-dd');
const params = new URLSearchParams({ from, to });
const response = await fetch(
`/api/dashboard/account/${account.id}/balance-evolution?${params.toString()}`,
accountBalanceEvolution.url(account.id, {
query: { from, to },
}),
);
const data = await response.json();
setBalanceData(data);

View File

@ -1,3 +1,4 @@
import monthlyBreakdown from '@/actions/App/Http/Controllers/Api/CategoryMonthlyBreakdownController';
import { CategoryCombobox } from '@/components/shared/category-combobox';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Card, CardContent } from '@/components/ui/card';
@ -130,7 +131,7 @@ export function CategoryAnalysisDrawer({
setIsLoading(true);
setError(null);
fetch(`/api/categories/${categoryId}/monthly-breakdown`, {
fetch(monthlyBreakdown.url(categoryId), {
headers: { Accept: 'application/json' },
})
.then((response) => {

View File

@ -1,3 +1,5 @@
import { update as updateLoanPreference } from '@/actions/App/Http/Controllers/Settings/NetWorthChartLoanPreferenceController';
import { update as updateRealEstatePreference } from '@/actions/App/Http/Controllers/Settings/NetWorthChartRealEstatePreferenceController';
import { AccountName } from '@/components/accounts/account-name';
import {
type ChartGranularity,
@ -439,7 +441,7 @@ export function NetWorthChart({
const handleIncludeLoansChange = useCallback((includeLoans: boolean) => {
router.patch(
'/settings/net-worth-chart-loan-preference',
updateLoanPreference.url(),
{
include_loans_in_net_worth_chart: includeLoans,
},
@ -454,7 +456,7 @@ export function NetWorthChart({
const handleIncludeRealEstateChange = useCallback(
(includeRealEstate: boolean) => {
router.patch(
'/settings/net-worth-chart-real-estate-preference',
updateRealEstatePreference.url(),
{
include_real_estate_in_net_worth_chart: includeRealEstate,
},

View File

@ -1,3 +1,4 @@
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/Api/TransactionController';
import { TransactionDescription } from '@/components/transactions/transaction-description';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Badge } from '@/components/ui/badge';
@ -58,9 +59,11 @@ export function ImportStepPreview({
}
axios
.get('/api/transactions', {
params: { account_id: accountId, per_page: 10 },
})
.get(
transactionsIndex.url({
query: { account_id: accountId, per_page: 10 },
}),
)
.then((response) => {
setExistingTransactions(response.data.data ?? []);
})

View File

@ -1,3 +1,4 @@
import { index as importDataRoute } from '@/actions/App/Http/Controllers/Api/ImportDataController';
import { Button } from '@/components/ui/button';
import {
Tooltip,
@ -30,7 +31,7 @@ export function ImportTransactionsButton() {
// Fetch data on-demand when drawer opens
setLoading(true);
try {
const response = await fetch('/api/import/data');
const response = await fetch(importDataRoute.url());
if (!response.ok) {
throw new Error('Failed to load import data');
}

View File

@ -2,6 +2,7 @@ import {
index as indexBalances,
store as storeBalance,
} from '@/actions/App/Http/Controllers/AccountBalanceController';
import { categorize } from '@/actions/App/Http/Controllers/TransactionController';
import AlertError from '@/components/alert-error';
import {
Drawer,
@ -687,8 +688,7 @@ export function ImportTransactionsDrawer({
uncategorizedCount > 0
? {
label: 'Categorize',
onClick: () =>
router.visit('/transactions/categorize'),
onClick: () => router.visit(categorize.url()),
}
: undefined,
});
@ -703,8 +703,7 @@ export function ImportTransactionsDrawer({
uncategorizedCount > 0
? {
label: 'Categorize',
onClick: () =>
router.visit('/transactions/categorize'),
onClick: () => router.visit(categorize.url()),
}
: undefined,
});
@ -718,8 +717,7 @@ export function ImportTransactionsDrawer({
uncategorizedCount > 0
? {
label: 'Categorize',
onClick: () =>
router.visit('/transactions/categorize'),
onClick: () => router.visit(categorize.url()),
}
: undefined,
});

View File

@ -1,3 +1,9 @@
import {
destroy as destroySavedFilter,
index as savedFiltersIndex,
store as storeSavedFilter,
update as updateSavedFilter,
} from '@/actions/App/Http/Controllers/Api/SavedFilterController';
import {
AlertDialog,
AlertDialogAction,
@ -113,7 +119,7 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
let active = true;
axios
.get<{ data: SavedFilter[] }>('/api/saved-filters')
.get<{ data: SavedFilter[] }>(savedFiltersIndex.url())
.then((response) => {
if (active) {
setSavedFilters(response.data.data);
@ -143,7 +149,7 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
}
try {
await axios.delete(`/api/saved-filters/${savedFilter.id}`);
await axios.delete(destroySavedFilter.url(savedFilter.id));
} catch (error) {
console.error('Failed to delete saved filter:', error);
setSavedFilters(previous);
@ -154,7 +160,7 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
async function handleUpdate(savedFilter: SavedFilter) {
try {
const response = await axios.patch<{ data: SavedFilter }>(
`/api/saved-filters/${savedFilter.id}`,
updateSavedFilter.url(savedFilter.id),
{ filters: serializeFilters(filters) },
);
@ -180,7 +186,7 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
setIsSaving(true);
try {
const response = await axios.post<{ data: SavedFilter }>(
'/api/saved-filters',
storeSavedFilter.url(),
{
name: trimmedName,
filters: serializeFilters(filters),

View File

@ -1,3 +1,4 @@
import { getMessage } from '@/actions/App/Http/Controllers/EncryptionController';
import { clearKey, getStoredKey } from '@/lib/key-storage';
import axios from 'axios';
import {
@ -56,7 +57,7 @@ export function EncryptionKeyProvider({
try {
const response = await axios.get<EncryptedMessageData>(
'/api/encryption/message',
getMessage.url(),
);
setEncryptedMessageData(response.data);
} catch (err) {

View File

@ -1,3 +1,9 @@
import {
breakdown as cashflowBreakdown,
sankey as cashflowSankey,
summary as cashflowSummary,
trend as cashflowTrend,
} from '@/actions/App/Http/Controllers/Api/CashflowAnalyticsController';
import { Category } from '@/types/category';
import { endOfMonth, format, startOfMonth } from 'date-fns';
import { useCallback, useEffect, useState } from 'react';
@ -109,30 +115,32 @@ export function useCashflowData({
const fromStr = format(from, 'yyyy-MM-dd');
const toStr = format(to, 'yyyy-MM-dd');
const periodParams = new URLSearchParams({
from: fromStr,
to: toStr,
});
const periodQuery = `?${periodParams.toString()}`;
const periodQuery = { from: fromStr, to: toStr };
const trendQuery =
periodType === 'month' ? `?months=12&to=${toStr}` : periodQuery;
periodType === 'month'
? { months: 12, to: toStr }
: periodQuery;
const [summary, sankey, trend, incomeBreakdown, expenseBreakdown] =
await Promise.all([
fetch(`/api/cashflow/summary${periodQuery}`).then((r) =>
r.json(),
fetch(cashflowSummary.url({ query: periodQuery })).then(
(r) => r.json(),
),
fetch(`/api/cashflow/sankey${periodQuery}`).then((r) =>
r.json(),
fetch(cashflowSankey.url({ query: periodQuery })).then(
(r) => r.json(),
),
fetch(`/api/cashflow/trend${trendQuery}`).then((r) =>
fetch(cashflowTrend.url({ query: trendQuery })).then((r) =>
r.json(),
),
fetch(
`/api/cashflow/breakdown${periodQuery}&type=income`,
cashflowBreakdown.url({
query: { ...periodQuery, type: 'income' },
}),
).then((r) => r.json()),
fetch(
`/api/cashflow/breakdown${periodQuery}&type=expense`,
cashflowBreakdown.url({
query: { ...periodQuery, type: 'expense' },
}),
).then((r) => r.json()),
]);

View File

@ -1,3 +1,7 @@
import {
index as accountsIndex,
update as updateAccount,
} from '@/actions/App/Http/Controllers/Api/AccountController';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
@ -32,8 +36,9 @@ export function useDecryptAccountNames() {
return;
}
const { data: accounts } =
await axios.get<EncryptedAccount[]>('/api/accounts');
const { data: accounts } = await axios.get<EncryptedAccount[]>(
accountsIndex.url(),
);
const encryptedAccounts = accounts.filter(
(a) => a.encrypted && a.name_iv,
@ -53,7 +58,7 @@ export function useDecryptAccountNames() {
account.name_iv!,
);
await axios.put(`/api/accounts/${account.id}`, {
await axios.put(updateAccount.url(account.id), {
name: decryptedName,
encrypted: false,
});

View File

@ -1,3 +1,4 @@
import { bulkUpdate } from '@/actions/App/Http/Controllers/Api/TransactionController';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
@ -127,7 +128,7 @@ export function useDecryptTransactions() {
for (let i = 0; i < batch.length; i += 50) {
const chunk = batch.slice(i, i + 50);
await withRetry(() =>
axios.patch('/api/transactions/bulk', {
axios.patch(bulkUpdate.url(), {
transactions: chunk,
}),
);

View File

@ -1,3 +1,7 @@
import {
show as showImportConfig,
update as updateImportConfig,
} from '@/actions/App/Http/Controllers/Api/AccountImportConfigController';
import type { BalanceColumnMapping } from '@/types/balance-import';
import { type ColumnMapping, DateFormat } from '@/types/import';
import { type UUID } from '@/types/uuid';
@ -15,17 +19,13 @@ interface BalanceImportConfig {
type ImportConfigType = 'transaction' | 'balance';
function configUrl(accountId: UUID): string {
return `/api/accounts/${accountId}/import-config`;
}
async function saveConfig(
accountId: UUID,
type: ImportConfigType,
config: ImportConfig | BalanceImportConfig,
): Promise<void> {
try {
await axios.put(configUrl(accountId), { type, config });
await axios.put(updateImportConfig.url(accountId), { type, config });
} catch (error) {
console.error(`Failed to save ${type} import configuration:`, error);
}
@ -37,8 +37,7 @@ async function loadConfig<T extends ImportConfig | BalanceImportConfig>(
): Promise<T | null> {
try {
const { data } = await axios.get<{ data: T | null }>(
configUrl(accountId),
{ params: { type } },
showImportConfig.url(accountId, { query: { type } }),
);
const config = data.data;

View File

@ -1,3 +1,4 @@
import { update as updateChartColorScheme } from '@/actions/App/Http/Controllers/Settings/ChartColorSchemeController';
import { __ } from '@/utils/i18n';
import { Head, router } from '@inertiajs/react';
@ -38,7 +39,7 @@ export default function Appearance() {
updateScheme(newScheme);
router.patch(
'/settings/chart-color-scheme',
updateChartColorScheme.url(),
{ chart_color_scheme: newScheme },
{ preserveScroll: true },
);

View File

@ -1,3 +1,4 @@
import { bulkUpdate as bulkUpdateTransactions } from '@/actions/App/Http/Controllers/TransactionController';
import { useLocale } from '@/hooks/use-locale';
import { usePollJobStatus } from '@/hooks/use-poll-job-status';
import { __ } from '@/utils/i18n';
@ -1121,7 +1122,7 @@ export default function Transactions({
if (isSelectingAll) {
const toastId = toast.loading(__('Updating transactions...'));
const response = await axios.patch<{ count: number }>(
'/transactions/bulk',
bulkUpdateTransactions.url(),
{
filters: clientFiltersToBackendFilters(filters),
category_id: categoryId,
@ -1262,7 +1263,7 @@ export default function Transactions({
if (isSelectingAll) {
const toastId = toast.loading(__('Updating transactions...'));
const response = await axios.patch<{ count: number }>(
'/transactions/bulk',
bulkUpdateTransactions.url(),
{
filters: clientFiltersToBackendFilters(filters),
label_ids: labelIds,

View File

@ -1,3 +1,11 @@
// Aliased because this service's own methods share these names.
import { index as syncTransactions } from '@/actions/App/Http/Controllers/Sync/TransactionSyncController';
import {
bulkUpdate as bulkUpdateRoute,
destroy as destroyRoute,
store as storeRoute,
update as updateRoute,
} from '@/actions/App/Http/Controllers/TransactionController';
import { db, withDb } from '@/lib/dexie-db';
import { TransactionSyncManager } from '@/lib/sync-manager';
import type { LearnedRuleNotice } from '@/types/automation-rule';
@ -32,7 +40,7 @@ class TransactionSyncService {
constructor() {
this.syncManager = new TransactionSyncManager({
endpoint: '/api/sync/transactions',
endpoint: syncTransactions.url(),
transformFromServer: (data) => {
const label_ids = data.labels?.map((l: { id: string }) => l.id);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -69,7 +77,7 @@ class TransactionSyncService {
data: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>,
options?: { updateBalance?: boolean },
): Promise<Transaction> {
const response = await axios.post('/transactions', {
const response = await axios.post(storeRoute.url(), {
...data,
...(options?.updateBalance ? { update_balance: true } : {}),
});
@ -106,7 +114,7 @@ class TransactionSyncService {
): Promise<UpdatedTransaction> {
const { label_ids, ...transactionData } = data;
const response = await axios.patch(`/transactions/${id}`, {
const response = await axios.patch(updateRoute.url(id), {
...transactionData,
label_ids,
...(options?.updateBalance ? { update_balance: true } : {}),
@ -134,7 +142,7 @@ class TransactionSyncService {
): Promise<void> {
const { label_ids, ...transactionData } = data;
await axios.patch('/transactions/bulk', {
await axios.patch(bulkUpdateRoute.url(), {
transaction_ids: ids,
label_ids: label_ids,
...transactionData,
@ -178,7 +186,7 @@ class TransactionSyncService {
requestFilters.debtor_name = filters.debtorName;
}
const response = await axios.patch('/transactions/bulk', {
const response = await axios.patch(bulkUpdateRoute.url(), {
filters: requestFilters,
label_ids: label_ids,
...transactionData,
@ -191,7 +199,7 @@ class TransactionSyncService {
id: string,
options?: { updateBalance?: boolean },
): Promise<void> {
await axios.delete(`/transactions/${id}`, {
await axios.delete(destroyRoute.url(id), {
data: options?.updateBalance ? { update_balance: true } : undefined,
});
// The API delete above is authoritative; the local cache eviction is