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>
136 lines
5.4 KiB
TypeScript
136 lines
5.4 KiB
TypeScript
/**
|
||
* 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 })
|
||
},
|
||
)
|