Files
accounted/app/api/v1/companies/[companyId]/reports/vat-declaration/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

136 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GET /api/v1/companies/{companyId}/reports/vat-declaration
*
* Computes the Swedish momsdeklaration for a period (monthly, quarterly,
* or yearly). Returns all 12 declaration rutor (05/06/07/10/11/12/30/31/32/39/40/48/49)
* mapped from the BAS accounts (2611/2621/2631/3001/3002/3003/etc.).
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ValidationError } from '@/lib/api/v1/errors'
import { safeGenerate } from '@/lib/api/v1/report-period'
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
import type { VatPeriodType } from '@/types'
const VatPeriodTypeEnum = z.enum(['monthly', 'quarterly', 'yearly'])
const AccountingMethodEnum = z.enum(['accrual', 'cash'])
registerEndpoint({
operation: 'reports.vat-declaration',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/vat-declaration',
summary: 'Swedish VAT declaration (momsdeklaration) for a period.',
description:
'Computes momsdeklaration rutor for the given period_type / year / period. The result includes ruta 05 (domestic taxable sales), 10-12 (output VAT 25/12/6%), 20-24 (EU acquisitions of goods + tax on services from EU/non-EU), 30-32 (reverse-charge output VAT 25/12/6%), 39 (export), 40 (EU-services / momsfri försäljning), 48 (input VAT), 50 (import beskattningsunderlag), 60-62 (calculated output VAT on imports 25/12/6%), and 49 (moms att betala/återfå: the bottom line). Mapping rules match SKV 4700.',
useWhen:
'Submitting momsdeklaration to Skatteverket, reconciling VAT balances at month/quarter end, or building a VAT-payable dashboard.',
doNotUseFor:
'Specific transaction VAT lookups (use /transactions/{id}). Period-mismatch reconciliation (use /reports/general-ledger filtered to 26xx accounts).',
pitfalls: [
'`period_type` (monthly|quarterly|yearly), `year`, and `period` are all required.',
'For monthly: period is 1-12. For quarterly: period is 1-4. For yearly: period is 1.',
'`accounting_method` is accepted for backward compatibility but has no effect on the figures: the declaration is a pure ledger projection, and the method (faktureringsmetoden vs kontantmetoden per ML 15 kap 8-11 §§, ML 2023:200) is already reflected in when VAT-bearing journal entries are posted.',
'Output ruta 49 = (10+11+12+30+31+32+60+61+62) 48. Positive = pay; negative = refund.',
],
example: {
response: {
data: {
period_type: 'monthly',
year: 2026,
period: 4,
rutor: {
ruta05: 0,
ruta10: 0,
ruta11: 0,
ruta12: 0,
ruta20: 0,
ruta21: 0,
ruta22: 0,
ruta23: 0,
ruta24: 0,
ruta30: 0,
ruta31: 0,
ruta32: 0,
ruta39: 0,
ruta40: 0,
ruta48: 0,
ruta50: 0,
ruta60: 0,
ruta61: 0,
ruta62: 0,
ruta49: 0,
},
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: dataEnvelope(z.unknown()) },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.vat-declaration',
async (request, ctx) => {
const url = new URL(request.url)
const FiltersSchema = z
.object({
period_type: VatPeriodTypeEnum,
year: z.coerce.number().int().min(2000).max(2100),
period: z.coerce.number().int().min(1).max(12),
accounting_method: AccountingMethodEnum.optional(),
})
// Cross-field bounds: monthly accepts 1-12, quarterly 1-4, yearly only 1.
// Without this guard a caller could pass period_type=quarterly + period=7
// and silently get a nonsensical declaration that they might submit to
// Skatteverket.
.superRefine((data, ctx) => {
if (data.period_type === 'quarterly' && (data.period < 1 || data.period > 4)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['period'],
message: 'For quarterly period_type, period must be 1-4.',
})
}
if (data.period_type === 'yearly' && data.period !== 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['period'],
message: 'For yearly period_type, period must be 1.',
})
}
})
const filters = FiltersSchema.safeParse({
period_type: url.searchParams.get('period_type'),
year: url.searchParams.get('year'),
period: url.searchParams.get('period'),
accounting_method: url.searchParams.get('accounting_method') ?? undefined,
})
if (!filters.success) return v1ValidationError(ctx, filters.error)
// accounting_method is still accepted (public API back-compat) but has no
// effect: see the invariant note on calculateVatDeclaration.
const { period_type, year, period } = filters.data
const gen = await safeGenerate(
() =>
calculateVatDeclaration(
ctx.supabase,
ctx.companyId!,
period_type as VatPeriodType,
year,
period,
),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'vat-declaration' },
)
if (!gen.ok) return gen.response
return ok(gen.result, { requestId: ctx.requestId })
},
)