Files
accounted/app/api/v1/companies/[companyId]/transactions/route.ts
T
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

178 lines
7.0 KiB
TypeScript

/**
* GET /api/v1/companies/{companyId}/transactions
*
* Cursor-paginated transaction list. Filters: status (booked/unbooked),
* date range, currency, cash_account_id (one bank account), search
* (description ilike). Default sort: (date DESC, id ASC): newest first,
* deterministic tie-break.
*/
import { z } from 'zod'
import { paginated } from '@/lib/api/v1/response'
import {
decodeDefaultCursor,
encodeDefaultCursor,
parsePaginationParams,
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ValidationError } from '@/lib/api/v1/errors'
const TransactionSummary = z.object({
id: z.string().uuid(),
date: z.string(),
description: z.string().nullable(),
amount: z.number(),
currency: z.string(),
reference: z.string().nullable(),
merchant_name: z.string().nullable(),
journal_entry_id: z.string().uuid().nullable(),
invoice_id: z.string().uuid().nullable(),
supplier_invoice_id: z.string().uuid().nullable(),
is_business: z.boolean().nullable(),
category: z.string().nullable(),
import_source: z.string().nullable(),
cash_account_id: z.string().uuid().nullable(),
created_at: z.string(),
})
const TransactionListResponse = listEnvelope(TransactionSummary)
// Explicit projection: no SELECT *. created_at is required for cursor
// stability (see ordering rationale in the GET handler).
const TRANSACTION_SUMMARY_COLUMNS =
'id, date, description, amount, currency, reference, merchant_name, ' +
'journal_entry_id, invoice_id, supplier_invoice_id, is_business, category, ' +
'import_source, cash_account_id, created_at'
registerEndpoint({
operation: 'transactions.list',
method: 'GET',
path: '/api/v1/companies/:companyId/transactions',
summary: 'List transactions for a company.',
description:
'Cursor-paginated transaction list ordered by created_at DESC, id ASC (newest-imported first; the `date` column is the transaction date and is filterable but not the sort key). Filter by ?status=booked|unbooked, ?currency, ?date_from / ?date_to, ?search (description ilike).',
useWhen:
'You need to walk a company\'s bank ledger: building a categorization queue, reconciling against external statements, or sampling for audit.',
doNotUseFor:
'Looking up one transaction by id (use the detail endpoint). Reconciliation status (use /reconciliation/bank/status).',
pitfalls: [
'Default page size is 50. Pass ?limit=100 for the maximum. Cursor pagination: pass ?cursor=<next_cursor> from the previous response.',
'A booked transaction has a non-null journal_entry_id. is_business / category live on the transaction row even before booking.',
'reverse-charge or storno entries can leave a transaction with journal_entry_id pointing at a cancelled JE: check status on the JE separately.',
],
example: {
response: {
data: [
{
id: 'a8f1…',
date: '2026-05-12',
description: 'ICA MAXI',
amount: -349.5,
currency: 'SEK',
merchant_name: 'ICA MAXI',
journal_entry_id: null,
is_business: null,
category: null,
},
],
meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null },
},
},
scope: 'transactions:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: TransactionListResponse },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'transactions.list',
async (request, ctx) => {
const url = new URL(request.url)
const { limit, cursor } = parsePaginationParams(url)
const decoded = decodeDefaultCursor(cursor)
const FiltersSchema = z.object({
status: z.enum(['booked', 'unbooked']).optional(),
currency: z.string().min(1).max(8).optional(),
date_from: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
date_to: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
search: z.string().min(1).max(200).optional(),
cash_account_id: z.string().uuid().optional(),
})
const filtersResult = FiltersSchema.safeParse({
status: url.searchParams.get('status') ?? undefined,
currency: url.searchParams.get('currency') ?? undefined,
date_from: url.searchParams.get('date_from') ?? undefined,
date_to: url.searchParams.get('date_to') ?? undefined,
search: url.searchParams.get('search') ?? undefined,
cash_account_id: url.searchParams.get('cash_account_id') ?? undefined,
})
if (!filtersResult.success) return v1ValidationError(ctx, filtersResult.error)
const f = filtersResult.data
// Sort by (created_at DESC, id ASC). created_at is the stable cursor
// anchor: it's a real timestamp (passes ISO-8601 validation in
// decodeDefaultCursor), unique within a company at the row insertion
// grain, and total-orderable. Sorting by `date` directly broke the
// cursor (date is YYYY-MM-DD only, decoder rejects it). For users
// who care about transaction-date ordering specifically, the date
// is still in every row and ?date_from / ?date_to filters work.
let query = ctx.supabase
.from('transactions')
.select(TRANSACTION_SUMMARY_COLUMNS)
.eq('company_id', ctx.companyId!)
.order('created_at', { ascending: false })
.order('id', { ascending: true })
.limit(limit + 1)
if (f.status === 'booked') query = query.not('journal_entry_id', 'is', null)
else if (f.status === 'unbooked') query = query.is('journal_entry_id', null)
if (f.currency) query = query.eq('currency', f.currency)
if (f.cash_account_id) query = query.eq('cash_account_id', f.cash_account_id)
if (f.date_from) query = query.gte('date', f.date_from)
if (f.date_to) query = query.lte('date', f.date_to)
if (f.search) {
// Two-step escape (PostgREST .or delimiters, then LIKE wildcards). Same
// pattern as customers list.
const term = f.search.replace(/[,()]/g, '').replace(/[%_\\]/g, '\\$&')
query = query.or(`description.ilike.%${term}%,merchant_name.ilike.%${term}%`)
}
if (decoded) {
// Cursor is on (created_at DESC, id ASC). created_at moves backward;
// id breaks ties.
query = query.or(
`created_at.lt.${decoded.ts},and(created_at.eq.${decoded.ts},id.gt.${decoded.id})`,
)
}
const { data, error } = await query
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
type Row = { id: string; created_at: string } & Record<string, unknown>
const rows = (data ?? []) as unknown as Row[]
const trimmed = rows.slice(0, limit)
const hasMore = rows.length > limit
const last = trimmed[trimmed.length - 1]
const nextCursor =
hasMore && last
? encodeDefaultCursor({ id: last.id, created_at: last.created_at })
: null
return paginated(trimmed, {
requestId: ctx.requestId,
nextCursor: nextCursor ?? undefined,
})
},
)