f266c386f3
* 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>
178 lines
7.0 KiB
TypeScript
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,
|
|
})
|
|
},
|
|
)
|