* feat(vat): book the momsrapport as an editable settlement verifikat (#980) Adds a "Bokfor momsrapporten" card under the VAT declaration that builds an editable verifikat proposal from the report and books it through the ordinary journal entry form: - lib/reports/vat-settlement.ts: proposal builder. Clears each 26xx account at exact ore, books the net on 2650 (att betala) or 1650 (att aterfa) at the filed whole-krona amount (buildFiledAmounts, oretal faller bort per SFL 22 kap 1 par), balances the gap on 3740. Surfaces existing vat_settlement entries in the period so the UI can warn before a double booking. - GET /api/reports/vat-declaration/settlement-proposal: same period params as the sibling report routes. - VatBookingCard (reports view): fetches the proposal, warns when the period already has a posted settlement or draft, and opens the JournalEntryForm (bare, prefilled, source_type vat_settlement) in a dialog so every line is editable before committing. Booking uses the existing engine path: balance validation, period locks, voucher series per source type. - vat_settlement entries are excluded from the declaration projection (calculateVatDeclaration via new shared fetchVatAccountTotals, and the MCP computeVatReport for parity): a pure-projection report would otherwise read zero, and a later Skatteverket submission would file zeros, the moment the settlement is booked. No migration needed: the vat_settlement source type shipped in 20260708100000. Closes #980 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(vat): block re-booking a settled period, fail loud on lookup errors (CodeRabbit) The proposal is not delta-aware (it re-clears the FULL period), so a second booking while a posted settlement exists would corrupt the 26xx balances: disable "Skapa verifikat" until that verifikat is annulled (storno restores the balances). And since the existing-settlement lookup now gates that button, a swallowed query error would silently re-enable it: throw instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5d7127a38a
commit
2774e01258
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'order', 'range']) {
|
||||
for (const m of ['select', 'eq', 'neq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'order', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { buildVatSettlementProposal } from '../vat-settlement'
|
||||
|
||||
// ============================================================
|
||||
// Mock: results routed by table + select shape (the builder runs its two
|
||||
// ledger queries and the existing-entries lookup concurrently, so a
|
||||
// sequential result queue would be order-fragile).
|
||||
// ============================================================
|
||||
|
||||
interface MockData {
|
||||
/** journal_entries rows for the entry-scope query (fetchEntryLines step 1). */
|
||||
entries?: Array<{ id: string }>
|
||||
/** journal_entry_lines rows (fetchEntryLines step 2). */
|
||||
lines?: Array<Record<string, unknown>>
|
||||
/** Existing vat_settlement entries in the period. */
|
||||
existing?: Array<Record<string, unknown>>
|
||||
/** Error returned by the existing-settlement lookup. */
|
||||
existingError?: { message: string }
|
||||
/** fiscal_periods row for yearly (helårsmoms) bounds. */
|
||||
fiscalPeriod?: { period_start: string; period_end: string } | null
|
||||
}
|
||||
|
||||
let neqCalls: Array<[string, unknown]>
|
||||
|
||||
function makeClient(data: MockData) {
|
||||
neqCalls = []
|
||||
return {
|
||||
from: vi.fn().mockImplementation((table: string) => {
|
||||
let selectStr = ''
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b: Record<string, any> = {}
|
||||
b.select = vi.fn().mockImplementation((s: string) => {
|
||||
selectStr = s
|
||||
return b
|
||||
})
|
||||
for (const m of ['eq', 'in', 'gte', 'lte', 'order', 'range', 'limit']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.neq = vi.fn().mockImplementation((col: string, val: unknown) => {
|
||||
neqCalls.push([col, val])
|
||||
return b
|
||||
})
|
||||
b.maybeSingle = vi.fn().mockResolvedValue({ data: data.fiscalPeriod ?? null, error: null })
|
||||
b.then = (resolve: (v: unknown) => void) => {
|
||||
if (table === 'journal_entry_lines') return resolve({ data: data.lines ?? [], error: null })
|
||||
// journal_entries serves two queries: the entry scope for the ledger
|
||||
// totals (select 'id') and the existing-settlement lookup (selects
|
||||
// voucher columns).
|
||||
if (selectStr.includes('voucher_series')) {
|
||||
return resolve(
|
||||
data.existingError
|
||||
? { data: null, error: data.existingError }
|
||||
: { data: data.existing ?? [], error: null },
|
||||
)
|
||||
}
|
||||
return resolve({ data: data.entries ?? [], error: null })
|
||||
}
|
||||
return b
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
}
|
||||
|
||||
let lineId = 0
|
||||
function vatLine(account: string, debit: number, credit: number) {
|
||||
lineId += 1
|
||||
return {
|
||||
id: `l${lineId}`,
|
||||
journal_entry_id: 'e1',
|
||||
account_number: account,
|
||||
debit_amount: debit,
|
||||
credit_amount: credit,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
lineId = 0
|
||||
})
|
||||
|
||||
describe('buildVatSettlementProposal', () => {
|
||||
it('clears the 26xx accounts, books the filed whole-krona net on 2650 and the öre gap on 3740', async () => {
|
||||
const supabase = makeClient({
|
||||
entries: [{ id: 'e1' }],
|
||||
lines: [
|
||||
vatLine('2611', 0, 2500.75),
|
||||
vatLine('2641', 1000.5, 0),
|
||||
// Revenue feeds ruta05 but is never part of the settlement entry.
|
||||
vatLine('3001', 0, 10003.0),
|
||||
],
|
||||
})
|
||||
|
||||
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1)
|
||||
|
||||
expect(proposal.period).toEqual({
|
||||
type: 'quarterly', year: 2026, period: 1, start: '2026-01-01', end: '2026-03-31',
|
||||
})
|
||||
expect(proposal.entry_date).toBe('2026-03-31')
|
||||
expect(proposal.description).toBe('Momsredovisning Kvartal 1 2026')
|
||||
expect(proposal.is_empty).toBe(false)
|
||||
// Filed net = trunc(2500.75) - trunc(1000.50) = 1500 (öretal faller bort)
|
||||
expect(proposal.filed_net).toBe(1500)
|
||||
expect(proposal.rounding_amount).toBe(0.25)
|
||||
expect(proposal.lines).toEqual([
|
||||
{ account_number: '2611', debit_amount: 2500.75, credit_amount: 0 },
|
||||
{ account_number: '2641', debit_amount: 0, credit_amount: 1000.5 },
|
||||
{
|
||||
account_number: '2650', debit_amount: 0, credit_amount: 1500,
|
||||
line_description: 'Moms att betala',
|
||||
},
|
||||
{
|
||||
account_number: '3740', debit_amount: 0, credit_amount: 0.25,
|
||||
line_description: 'Öres- och kronutjämning',
|
||||
},
|
||||
])
|
||||
|
||||
// The proposed entry always balances.
|
||||
const debits = proposal.lines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const credits = proposal.lines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
expect(debits).toBeCloseTo(credits, 2)
|
||||
|
||||
// The projection must ignore already-booked settlements, or booking once
|
||||
// would change the next proposal.
|
||||
expect(neqCalls).toContainEqual(['source_type', 'vat_settlement'])
|
||||
})
|
||||
|
||||
it('books a refund period as a 1650 (Momsfordran) debit', async () => {
|
||||
const supabase = makeClient({
|
||||
entries: [{ id: 'e1' }],
|
||||
lines: [
|
||||
vatLine('2611', 0, 100),
|
||||
vatLine('2641', 400, 0),
|
||||
],
|
||||
})
|
||||
|
||||
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'monthly', 2026, 6)
|
||||
|
||||
expect(proposal.filed_net).toBe(-300)
|
||||
expect(proposal.rounding_amount).toBe(0)
|
||||
expect(proposal.lines).toEqual([
|
||||
{ account_number: '2611', debit_amount: 100, credit_amount: 0 },
|
||||
{ account_number: '2641', debit_amount: 0, credit_amount: 400 },
|
||||
{
|
||||
account_number: '1650', debit_amount: 300, credit_amount: 0,
|
||||
line_description: 'Moms att återfå',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('clears an account sitting on the wrong side (credit-note-heavy period)', async () => {
|
||||
const supabase = makeClient({
|
||||
entries: [{ id: 'e1' }],
|
||||
// Output VAT with a net DEBIT balance: credit notes exceeded sales.
|
||||
lines: [vatLine('2611', 50, 0)],
|
||||
})
|
||||
|
||||
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'monthly', 2026, 2)
|
||||
|
||||
expect(proposal.filed_net).toBe(-50)
|
||||
expect(proposal.lines).toEqual([
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 50 },
|
||||
{
|
||||
account_number: '1650', debit_amount: 50, credit_amount: 0,
|
||||
line_description: 'Moms att återfå',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('is empty when the period has no VAT-account activity (revenue alone does not settle)', async () => {
|
||||
const supabase = makeClient({
|
||||
entries: [{ id: 'e1' }],
|
||||
lines: [vatLine('3001', 0, 1000)],
|
||||
})
|
||||
|
||||
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 2)
|
||||
|
||||
expect(proposal.is_empty).toBe(true)
|
||||
expect(proposal.lines).toEqual([])
|
||||
expect(proposal.filed_net).toBe(0)
|
||||
})
|
||||
|
||||
it('uses the räkenskapsår bounds for yearly VAT when a fiscal period is supplied', async () => {
|
||||
const supabase = makeClient({
|
||||
entries: [{ id: 'e1' }],
|
||||
lines: [vatLine('2611', 0, 100), vatLine('2641', 25, 0)],
|
||||
fiscalPeriod: { period_start: '2025-07-01', period_end: '2026-06-30' },
|
||||
})
|
||||
|
||||
const proposal = await buildVatSettlementProposal(
|
||||
supabase, 'company-1', 'yearly', 2026, 1, { fiscalPeriodId: 'fp-1' },
|
||||
)
|
||||
|
||||
expect(proposal.period.start).toBe('2025-07-01')
|
||||
expect(proposal.period.end).toBe('2026-06-30')
|
||||
expect(proposal.entry_date).toBe('2026-06-30')
|
||||
expect(proposal.description).toBe('Momsredovisning Helår 2026')
|
||||
})
|
||||
|
||||
it('surfaces existing vat_settlement entries in the period', async () => {
|
||||
const existing = [{
|
||||
id: 'je-1', status: 'posted', entry_date: '2026-03-31',
|
||||
voucher_series: 'M', voucher_number: 3,
|
||||
}]
|
||||
const supabase = makeClient({
|
||||
entries: [{ id: 'e1' }],
|
||||
lines: [vatLine('2611', 0, 100)],
|
||||
existing,
|
||||
})
|
||||
|
||||
const proposal = await buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1)
|
||||
|
||||
expect(proposal.existing_entries).toEqual(existing)
|
||||
})
|
||||
|
||||
it('throws when the existing-settlement lookup fails (the UI gate depends on it)', async () => {
|
||||
const supabase = makeClient({
|
||||
entries: [{ id: 'e1' }],
|
||||
lines: [vatLine('2611', 0, 100)],
|
||||
existingError: { message: 'boom' },
|
||||
})
|
||||
|
||||
await expect(
|
||||
buildVatSettlementProposal(supabase, 'company-1', 'quarterly', 2026, 1),
|
||||
).rejects.toThrow('existing vat_settlement lookup failed: boom')
|
||||
})
|
||||
})
|
||||
@@ -216,7 +216,7 @@ function round(value: number): number {
|
||||
* can't be resolved we fall back to the calendar span so behaviour degrades
|
||||
* gracefully instead of erroring.
|
||||
*/
|
||||
async function resolvePeriodDates(
|
||||
export async function resolvePeriodDates(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
periodType: VatPeriodType,
|
||||
@@ -238,6 +238,90 @@ async function resolvePeriodDates(
|
||||
return calculatePeriodDates(periodType, year, period)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and aggregate debit/credit totals per VAT-relevant account
|
||||
* (ACCOUNT_RUTA) for a period. Shared by the declaration calculation and the
|
||||
* settlement proposal (lib/reports/vat-settlement.ts) so the two can never
|
||||
* disagree on which ledger lines count.
|
||||
*
|
||||
* Momsredovisning entries (source_type 'vat_settlement': the verifikat that
|
||||
* clears the 26xx accounts to 2650/1650) are excluded. They are bookkeeping
|
||||
* about the declaration, not VAT-bearing business activity; including them
|
||||
* would zero out the rutor the moment the settlement is booked, turning the
|
||||
* report, its exports, and a later Skatteverket submission into an empty
|
||||
* declaration.
|
||||
*/
|
||||
export async function fetchVatAccountTotals(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
start: string,
|
||||
end: string
|
||||
): Promise<Map<string, { debit: number; credit: number }>> {
|
||||
const lines = await fetchEntryLines<{
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}>({
|
||||
supabase,
|
||||
lineColumns: 'account_number, debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.neq('source_type', 'vat_settlement')
|
||||
.gte('entry_date', start)
|
||||
.lte('entry_date', end),
|
||||
filterLines: (q: EntryLinesQuery) => q.in('account_number', VAT_ACCOUNTS),
|
||||
})
|
||||
|
||||
const totals = new Map<string, { debit: number; credit: number }>()
|
||||
for (const line of lines) {
|
||||
const t = totals.get(line.account_number) || { debit: 0, credit: 0 }
|
||||
t.debit += Number(line.debit_amount) || 0
|
||||
t.credit += Number(line.credit_amount) || 0
|
||||
totals.set(line.account_number, t)
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
/**
|
||||
* Map aggregated per-account totals to the momsdeklaration boxes, including
|
||||
* the recomputed ruta 49 net (FK009). Pure projection over ACCOUNT_RUTA.
|
||||
*/
|
||||
export function rutorFromTotals(
|
||||
totals: Map<string, { debit: number; credit: number }>
|
||||
): VatDeclarationRutor {
|
||||
const rutor: VatDeclarationRutor = {
|
||||
ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0,
|
||||
ruta10: 0, ruta11: 0, ruta12: 0,
|
||||
ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0,
|
||||
ruta30: 0, ruta31: 0, ruta32: 0,
|
||||
ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0,
|
||||
ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0,
|
||||
ruta48: 0, ruta49: 0,
|
||||
ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0,
|
||||
}
|
||||
|
||||
for (const [account, mapping] of Object.entries(ACCOUNT_RUTA)) {
|
||||
const t = totals.get(account)
|
||||
if (!t) continue
|
||||
const balance = mapping.side === 'credit'
|
||||
? t.credit - t.debit
|
||||
: t.debit - t.credit
|
||||
rutor[mapping.box] = round(rutor[mapping.box] + balance)
|
||||
}
|
||||
|
||||
// FK009: summaMoms = (10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - 48
|
||||
rutor.ruta49 = round(
|
||||
rutor.ruta10 + rutor.ruta11 + rutor.ruta12 +
|
||||
rutor.ruta30 + rutor.ruta31 + rutor.ruta32 +
|
||||
rutor.ruta60 + rutor.ruta61 + rutor.ruta62 -
|
||||
rutor.ruta48
|
||||
)
|
||||
|
||||
return rutor
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate VAT declaration from the general ledger.
|
||||
*
|
||||
@@ -265,60 +349,11 @@ export async function calculateVatDeclaration(
|
||||
supabase, companyId, periodType, year, period, options.fiscalPeriodId
|
||||
)
|
||||
|
||||
// Fetch all posted journal entry lines on VAT-relevant accounts for the period
|
||||
const lines = await fetchEntryLines<{
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}>({
|
||||
supabase,
|
||||
lineColumns: 'account_number, debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.gte('entry_date', start)
|
||||
.lte('entry_date', end),
|
||||
filterLines: (q: EntryLinesQuery) => q.in('account_number', VAT_ACCOUNTS),
|
||||
})
|
||||
|
||||
// Aggregate debit/credit totals per account
|
||||
const totals = new Map<string, { debit: number; credit: number }>()
|
||||
for (const line of lines) {
|
||||
const t = totals.get(line.account_number) || { debit: 0, credit: 0 }
|
||||
t.debit += Number(line.debit_amount) || 0
|
||||
t.credit += Number(line.credit_amount) || 0
|
||||
totals.set(line.account_number, t)
|
||||
}
|
||||
// Fetch and aggregate posted VAT-account activity for the period
|
||||
const totals = await fetchVatAccountTotals(supabase, companyId, start, end)
|
||||
|
||||
// Map account balances to momsdeklaration boxes
|
||||
const rutor: VatDeclarationRutor = {
|
||||
ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0,
|
||||
ruta10: 0, ruta11: 0, ruta12: 0,
|
||||
ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0,
|
||||
ruta30: 0, ruta31: 0, ruta32: 0,
|
||||
ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0,
|
||||
ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0,
|
||||
ruta48: 0, ruta49: 0,
|
||||
ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0,
|
||||
}
|
||||
|
||||
for (const [account, mapping] of Object.entries(ACCOUNT_RUTA)) {
|
||||
const t = totals.get(account)
|
||||
if (!t) continue
|
||||
const balance = mapping.side === 'credit'
|
||||
? t.credit - t.debit
|
||||
: t.debit - t.credit
|
||||
rutor[mapping.box] = round(rutor[mapping.box] + balance)
|
||||
}
|
||||
|
||||
// FK009: summaMoms = (10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - 48
|
||||
rutor.ruta49 = round(
|
||||
rutor.ruta10 + rutor.ruta11 + rutor.ruta12 +
|
||||
rutor.ruta30 + rutor.ruta31 + rutor.ruta32 +
|
||||
rutor.ruta60 + rutor.ruta61 + rutor.ruta62 -
|
||||
rutor.ruta48
|
||||
)
|
||||
const rutor = rutorFromTotals(totals)
|
||||
|
||||
// Compute per-rate base amounts from individual revenue accounts
|
||||
const revenueByRate = {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import {
|
||||
fetchVatAccountTotals,
|
||||
formatPeriodLabel,
|
||||
resolvePeriodDates,
|
||||
rutorFromTotals,
|
||||
VAT_INPUT_ACCOUNTS,
|
||||
VAT_OUTPUT_ACCOUNTS,
|
||||
} from './vat-declaration'
|
||||
import { buildFiledAmounts } from './vat-manual-filing'
|
||||
import type { VatPeriodType } from '@/types'
|
||||
|
||||
/**
|
||||
* Momsredovisning settlement proposal (issue #980): the verifikat that closes
|
||||
* a VAT period by clearing every 26xx account the momsrapport reads from into
|
||||
* the redovisningskonto.
|
||||
*
|
||||
* Shape of the proposed entry (standard Swedish momsomföring, booked on the
|
||||
* period's last day):
|
||||
* - each output-VAT account (261x/262x/263x incl. reverse charge + import)
|
||||
* is debited by its period balance, each input-VAT account (264x) is
|
||||
* credited, at exact öre so the accounts land on zero for the period;
|
||||
* - the net goes to 2650 (Redovisningskonto för moms, credit = att betala)
|
||||
* or 1650 (Momsfordran, debit = att återfå) at the WHOLE-KRONA amount the
|
||||
* declaration is filed with (buildFiledAmounts: öretal faller bort per
|
||||
* SFL 22 kap 1 §), so 2650/1650 always matches the skattekonto movement;
|
||||
* - the öre gap between the exact clearing lines and the filed net is
|
||||
* balanced on 3740 (Öres- och kronutjämning).
|
||||
*
|
||||
* This is a PROPOSAL: the user reviews and edits the lines in the journal
|
||||
* entry form before committing, and the entry books through the normal
|
||||
* engine (balance validation, period locks, voucher numbering) with
|
||||
* source_type 'vat_settlement'. That source type is excluded from the
|
||||
* declaration projection (see fetchVatAccountTotals), so booking the
|
||||
* settlement never changes the report it was created from.
|
||||
*/
|
||||
|
||||
/** Redovisningskonto för moms: net VAT to pay (credit). */
|
||||
export const VAT_SETTLEMENT_ACCOUNT = '2650'
|
||||
/** Momsfordran: net VAT refund (debit). */
|
||||
export const VAT_REFUND_ACCOUNT = '1650'
|
||||
/** Öres- och kronutjämning: absorbs the filed whole-krona truncation gap. */
|
||||
export const VAT_ROUNDING_ACCOUNT = '3740'
|
||||
|
||||
export interface VatSettlementProposalLine {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
line_description?: string
|
||||
}
|
||||
|
||||
/** A vat_settlement entry already booked (or drafted) inside the period. */
|
||||
export interface VatSettlementExistingEntry {
|
||||
id: string
|
||||
status: string
|
||||
entry_date: string
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
}
|
||||
|
||||
export interface VatSettlementProposal {
|
||||
period: {
|
||||
type: VatPeriodType
|
||||
year: number
|
||||
period: number
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
/** Swedish period label, e.g. "Kvartal 1 2026" (Skatteverket-bound wording). */
|
||||
period_label: string
|
||||
/** Proposed entry date: the period's last day. */
|
||||
entry_date: string
|
||||
/** Proposed verifikationstext, e.g. "Momsredovisning Kvartal 1 2026". */
|
||||
description: string
|
||||
lines: VatSettlementProposalLine[]
|
||||
/** Ruta 49 as filed (whole kronor, signed: positive = att betala). */
|
||||
filed_net: number
|
||||
/** Signed öre gap balanced on 3740 (positive = credited, negative = debited). */
|
||||
rounding_amount: number
|
||||
/** True when the period has no VAT activity to clear. */
|
||||
is_empty: boolean
|
||||
existing_entries: VatSettlementExistingEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the settlement verifikat proposal for a VAT period. Reads the same
|
||||
* aggregated ledger totals as the momsrapport (fetchVatAccountTotals), so the
|
||||
* proposal always ties out with the report on screen and the filed eSKD/PDF
|
||||
* amounts.
|
||||
*/
|
||||
export async function buildVatSettlementProposal(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
periodType: VatPeriodType,
|
||||
year: number,
|
||||
period: number,
|
||||
options: { fiscalPeriodId?: string } = {}
|
||||
): Promise<VatSettlementProposal> {
|
||||
// Yearly (helårsmoms) resolves to the räkenskapsår bounds when a fiscal
|
||||
// period is supplied: same resolution as the declaration itself.
|
||||
const { start, end } = await resolvePeriodDates(
|
||||
supabase, companyId, periodType, year, period, options.fiscalPeriodId
|
||||
)
|
||||
|
||||
const [totals, existingResult] = await Promise.all([
|
||||
fetchVatAccountTotals(supabase, companyId, start, end),
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id, status, entry_date, voucher_series, voucher_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source_type', 'vat_settlement')
|
||||
.in('status', ['draft', 'posted'])
|
||||
.gte('entry_date', start)
|
||||
.lte('entry_date', end)
|
||||
.order('entry_date', { ascending: false })
|
||||
.limit(5),
|
||||
])
|
||||
|
||||
// The existing-settlement lookup gates the UI's "already booked" warning
|
||||
// and its create button; a swallowed error here would silently re-enable
|
||||
// booking a period that already has a settlement, so fail loud instead.
|
||||
if (existingResult.error) {
|
||||
throw new Error(
|
||||
`existing vat_settlement lookup failed: ${existingResult.error.message}`
|
||||
)
|
||||
}
|
||||
|
||||
const rutor = rutorFromTotals(totals)
|
||||
const { net: filedNet } = buildFiledAmounts(rutor)
|
||||
|
||||
// Clear every 26xx account the declaration reads from, at exact öre, so the
|
||||
// accounts land on zero for the period. A positive (credit) balance clears
|
||||
// with a debit and vice versa: the same formula handles credit-note-heavy
|
||||
// periods where an account sits on the "wrong" side.
|
||||
const clearingAccounts = [...new Set([...VAT_OUTPUT_ACCOUNTS, ...VAT_INPUT_ACCOUNTS])].sort()
|
||||
const lines: VatSettlementProposalLine[] = []
|
||||
for (const account of clearingAccounts) {
|
||||
const t = totals.get(account)
|
||||
if (!t) continue
|
||||
const balance = roundOre(t.credit - t.debit)
|
||||
if (balance > 0) {
|
||||
lines.push({ account_number: account, debit_amount: balance, credit_amount: 0 })
|
||||
} else if (balance < 0) {
|
||||
lines.push({ account_number: account, debit_amount: 0, credit_amount: -balance })
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length > 0) {
|
||||
if (filedNet > 0) {
|
||||
lines.push({
|
||||
account_number: VAT_SETTLEMENT_ACCOUNT,
|
||||
debit_amount: 0,
|
||||
credit_amount: filedNet,
|
||||
line_description: 'Moms att betala',
|
||||
})
|
||||
} else if (filedNet < 0) {
|
||||
lines.push({
|
||||
account_number: VAT_REFUND_ACCOUNT,
|
||||
debit_amount: -filedNet,
|
||||
credit_amount: 0,
|
||||
line_description: 'Moms att återfå',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Balance the öre/krona gap left by the whole-krona filed net on 3740.
|
||||
let roundingAmount = 0
|
||||
if (lines.length > 0) {
|
||||
const gap = roundOre(
|
||||
lines.reduce((sum, l) => sum + l.debit_amount - l.credit_amount, 0)
|
||||
)
|
||||
if (gap !== 0) {
|
||||
roundingAmount = gap
|
||||
lines.push({
|
||||
account_number: VAT_ROUNDING_ACCOUNT,
|
||||
debit_amount: gap < 0 ? -gap : 0,
|
||||
credit_amount: gap > 0 ? gap : 0,
|
||||
line_description: 'Öres- och kronutjämning',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const periodLabel = formatPeriodLabel(periodType, year, period)
|
||||
|
||||
return {
|
||||
period: { type: periodType, year, period, start, end },
|
||||
period_label: periodLabel,
|
||||
entry_date: end,
|
||||
description: `Momsredovisning ${periodLabel}`,
|
||||
lines,
|
||||
filed_net: filedNet,
|
||||
rounding_amount: roundingAmount,
|
||||
is_empty: lines.length === 0,
|
||||
existing_entries: (existingResult.data ?? []) as VatSettlementExistingEntry[],
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user