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>
323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
/**
|
|
* /api/v1/companies/{companyId}/salary-runs: list + create salary runs.
|
|
*
|
|
* GET : list with filters (period_year, status). Cursor pagination on
|
|
* (created_at ASC, id ASC).
|
|
* POST : create a new monthly salary run. New runs start in `draft` status.
|
|
* The line items and per-employee calculations are populated by
|
|
* POST /salary-runs/{id}/calculate. Idempotent (mandatory Idempotency-Key).
|
|
* Dry-runnable.
|
|
*
|
|
* The `(company_id, period_year, period_month)` tuple is uniquely indexed at
|
|
* the DB layer; duplicate creation returns 409 SALARY_RUN_DUPLICATE_PERIOD.
|
|
*/
|
|
|
|
import { z } from 'zod'
|
|
import { created, paginated } from '@/lib/api/v1/response'
|
|
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
|
import {
|
|
decodeDefaultCursor,
|
|
encodeDefaultCursor,
|
|
parsePaginationParams,
|
|
} from '@/lib/api/v1/pagination'
|
|
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
|
|
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
|
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
|
|
import { readV1JsonBody } from '@/lib/api/v1/body'
|
|
import { CreateSalaryRunSchema } from '@/lib/api/schemas'
|
|
import { eventBus } from '@/lib/events'
|
|
|
|
const SalaryRunStatus = z.enum(['draft', 'review', 'approved', 'paid', 'booked', 'corrected'])
|
|
|
|
const SalaryRunSummary = z.object({
|
|
id: z.string().uuid(),
|
|
period_year: z.number().int(),
|
|
period_month: z.number().int(),
|
|
payment_date: z.string(),
|
|
status: SalaryRunStatus,
|
|
voucher_series: z.string(),
|
|
total_gross: z.number(),
|
|
total_tax: z.number(),
|
|
total_net: z.number(),
|
|
total_avgifter: z.number(),
|
|
total_employer_cost: z.number(),
|
|
agi_generated_at: z.string().nullable(),
|
|
agi_submitted_at: z.string().nullable(),
|
|
approved_at: z.string().nullable(),
|
|
paid_at: z.string().nullable(),
|
|
booked_at: z.string().nullable(),
|
|
created_at: z.string(),
|
|
})
|
|
|
|
const SalaryRunsListResponse = listEnvelope(SalaryRunSummary)
|
|
|
|
const SALARY_RUN_SUMMARY_COLUMNS =
|
|
'id, period_year, period_month, payment_date, status, voucher_series, total_gross, total_tax, total_net, total_avgifter, total_employer_cost, agi_generated_at, agi_submitted_at, approved_at, paid_at, booked_at, created_at'
|
|
|
|
registerEndpoint({
|
|
operation: 'salary-runs.list',
|
|
method: 'GET',
|
|
path: '/api/v1/companies/:companyId/salary-runs',
|
|
summary: 'List salary runs.',
|
|
description:
|
|
'Returns salary runs in created-first order with their lifecycle status (draft|review|approved|paid|booked|corrected) and denormalised totals. Filters: ?period_year=YYYY, ?status=draft.',
|
|
useWhen:
|
|
'You need an overview of payroll activity: for building a list view, finding the current open run, or resolving a salary_run_id before invoking a lifecycle verb.',
|
|
doNotUseFor:
|
|
'Per-employee details (those live on the detail endpoint). Salary journal report (use GET /reports/salary-journal in Phase 5 PR-3).',
|
|
pitfalls: [
|
|
'A company has at most one salary run per (period_year, period_month). The unique constraint is at the DB layer.',
|
|
'Totals are denormalised: they are 0 until POST /calculate runs.',
|
|
'`corrected` status is reached via the internal /correct route (not yet exposed on v1): Phase 5 PR-1 ships create/calculate/approve/mark-paid/book/generate-agi only.',
|
|
],
|
|
example: {
|
|
response: {
|
|
data: [
|
|
{
|
|
id: 'run_a8f1…',
|
|
period_year: 2026,
|
|
period_month: 5,
|
|
payment_date: '2026-05-25',
|
|
status: 'draft',
|
|
voucher_series: 'A',
|
|
total_gross: 0,
|
|
total_tax: 0,
|
|
total_net: 0,
|
|
total_avgifter: 0,
|
|
total_employer_cost: 0,
|
|
},
|
|
],
|
|
meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null },
|
|
},
|
|
},
|
|
scope: 'payroll:read',
|
|
risk: 'low',
|
|
idempotent: true,
|
|
reversible: false,
|
|
dryRunSupported: false,
|
|
response: { success: SalaryRunsListResponse },
|
|
})
|
|
|
|
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
|
'salary-runs.list',
|
|
async (request, ctx) => {
|
|
const url = new URL(request.url)
|
|
const { limit, cursor } = parsePaginationParams(url)
|
|
const decoded = decodeDefaultCursor(cursor)
|
|
|
|
const FiltersSchema = z.object({
|
|
period_year: z.coerce.number().int().min(2020).max(2100).optional(),
|
|
status: SalaryRunStatus.optional(),
|
|
})
|
|
const filtersResult = FiltersSchema.safeParse({
|
|
period_year: url.searchParams.get('period_year') ?? undefined,
|
|
status: url.searchParams.get('status') ?? undefined,
|
|
})
|
|
if (!filtersResult.success) return v1ValidationError(ctx, filtersResult.error)
|
|
const filters = filtersResult.data
|
|
|
|
let query = ctx.supabase
|
|
.from('salary_runs')
|
|
.select(SALARY_RUN_SUMMARY_COLUMNS)
|
|
.eq('company_id', ctx.companyId!)
|
|
.order('created_at', { ascending: true })
|
|
.order('id', { ascending: true })
|
|
.limit(limit + 1)
|
|
|
|
if (filters.period_year !== undefined) {
|
|
query = query.eq('period_year', filters.period_year)
|
|
}
|
|
if (filters.status) {
|
|
query = query.eq('status', filters.status)
|
|
}
|
|
|
|
if (decoded) {
|
|
query = query.or(
|
|
`created_at.gt.${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,
|
|
})
|
|
},
|
|
)
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// POST: create salary run
|
|
// ──────────────────────────────────────────────────────────────────
|
|
|
|
const SalaryRunCreated = SalaryRunSummary.extend({
|
|
notes: z.string().nullable(),
|
|
calculation_params: z.unknown().nullable(),
|
|
updated_at: z.string(),
|
|
})
|
|
|
|
const SALARY_RUN_DETAIL_COLUMNS =
|
|
'id, period_year, period_month, payment_date, status, voucher_series, total_gross, total_tax, total_net, total_avgifter, total_vacation_accrual, total_employer_cost, salary_entry_id, avgifter_entry_id, vacation_entry_id, agi_generated_at, agi_submitted_at, calculation_params, approved_by, approved_at, paid_at, booked_at, booked_by, notes, created_at, updated_at'
|
|
|
|
registerEndpoint({
|
|
operation: 'salary-runs.create',
|
|
method: 'POST',
|
|
path: '/api/v1/companies/:companyId/salary-runs',
|
|
summary: 'Create a salary run.',
|
|
description:
|
|
'Creates a draft salary run for the given period (period_year, period_month). The run starts empty: add employees via the internal /salary/runs/{id}/employees endpoints, then POST /salary-runs/{id}/calculate. Requires Idempotency-Key. Dry-runnable.',
|
|
useWhen:
|
|
'You are starting a new month\'s payroll. Use dry-run first to validate the period + voucher_series choice without committing.',
|
|
doNotUseFor:
|
|
'Adding employees to an existing run (that is a separate surface: see internal /salary/runs/{id}/employees for Phase 5 PR-1; promoting it to v1 is deferred to a follow-up).',
|
|
pitfalls: [
|
|
'Idempotency-Key is mandatory.',
|
|
'Duplicate (period_year, period_month) for the same company returns 409 SALARY_RUN_DUPLICATE_PERIOD.',
|
|
'period_month is 1-12. The DB CHECK enforces this: a 0 or 13 returns 400 VALIDATION_ERROR before reaching the DB.',
|
|
'voucher_series defaults to "A". If the company uses a dedicated salary voucher series, set it explicitly.',
|
|
'A newly-created run has no employees: :calculate without employees returns 400 SALARY_RUN_NO_EMPLOYEES.',
|
|
],
|
|
example: {
|
|
request: {
|
|
period_year: 2026,
|
|
period_month: 5,
|
|
payment_date: '2026-05-25',
|
|
voucher_series: 'L',
|
|
},
|
|
response: {
|
|
data: {
|
|
id: 'run_a8f1…',
|
|
period_year: 2026,
|
|
period_month: 5,
|
|
payment_date: '2026-05-25',
|
|
status: 'draft',
|
|
voucher_series: 'L',
|
|
},
|
|
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
|
},
|
|
},
|
|
scope: 'payroll:write',
|
|
risk: 'low',
|
|
idempotent: true,
|
|
reversible: true,
|
|
dryRunSupported: true,
|
|
request: { body: CreateSalaryRunSchema },
|
|
response: { success: dataEnvelope(SalaryRunCreated) },
|
|
})
|
|
|
|
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
|
'salary-runs.create',
|
|
async (request, ctx) => {
|
|
const rawBodyResult = await readV1JsonBody(request, ctx)
|
|
if (!rawBodyResult.ok) return rawBodyResult.response
|
|
const rawBody = rawBodyResult.body
|
|
|
|
const parsed = CreateSalaryRunSchema.safeParse(rawBody)
|
|
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
|
|
const body = parsed.data
|
|
|
|
if (ctx.dryRun) {
|
|
return dryRunPreview(
|
|
{
|
|
id: null,
|
|
period_year: body.period_year,
|
|
period_month: body.period_month,
|
|
payment_date: body.payment_date,
|
|
status: 'draft' as const,
|
|
voucher_series: body.voucher_series,
|
|
total_gross: 0,
|
|
total_tax: 0,
|
|
total_net: 0,
|
|
total_avgifter: 0,
|
|
total_vacation_accrual: 0,
|
|
total_employer_cost: 0,
|
|
notes: body.notes ?? null,
|
|
calculation_params: null,
|
|
approved_by: null,
|
|
approved_at: null,
|
|
paid_at: null,
|
|
booked_at: null,
|
|
booked_by: null,
|
|
agi_generated_at: null,
|
|
agi_submitted_at: null,
|
|
created_at: null,
|
|
updated_at: null,
|
|
},
|
|
{ requestId: ctx.requestId, log: ctx.log },
|
|
)
|
|
}
|
|
|
|
const { data, error } = await ctx.supabase
|
|
.from('salary_runs')
|
|
.insert({
|
|
user_id: ctx.userId,
|
|
company_id: ctx.companyId!,
|
|
period_year: body.period_year,
|
|
period_month: body.period_month,
|
|
payment_date: body.payment_date,
|
|
voucher_series: body.voucher_series,
|
|
notes: body.notes ?? null,
|
|
status: 'draft',
|
|
})
|
|
.select(SALARY_RUN_DETAIL_COLUMNS)
|
|
.single()
|
|
|
|
if (error) {
|
|
// Disambiguate 23505 by constraint name. The salary_runs table has one
|
|
// unique index today: (company_id, period_year, period_month). A future
|
|
// migration could add another; mapping every 23505 here to
|
|
// SALARY_RUN_DUPLICATE_PERIOD would be misleading once that happens.
|
|
if (error.code === '23505') {
|
|
const constraint = (error as { constraint?: string }).constraint
|
|
if (constraint && constraint.includes('period_year')) {
|
|
return v1ErrorResponseFromCode('SALARY_RUN_DUPLICATE_PERIOD', ctx.log, {
|
|
requestId: ctx.requestId,
|
|
details: {
|
|
field: 'period',
|
|
period_year: body.period_year,
|
|
period_month: body.period_month,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
|
}
|
|
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'salary_run.created',
|
|
payload: {
|
|
salaryRunId: (data as { id: string }).id,
|
|
periodYear: body.period_year,
|
|
periodMonth: body.period_month,
|
|
userId: ctx.userId,
|
|
companyId: ctx.companyId!,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
ctx.log.warn('salary_run.created emit failed', err as Error)
|
|
}
|
|
|
|
return created(data, { requestId: ctx.requestId })
|
|
},
|
|
{ requireIdempotencyKey: true },
|
|
)
|