* fix(reports): periodisk sammanställning nets a stornoed or corrected EU invoice voucher (#2351) The PS reads entries with status posted or reversed, so a stornoed EU invoice voucher kept its 3308/3108 credit while the storno that nets it was dropped: its source_type is 'storno' and no register row points at it. A makulerad EU sale was over-reported while the account-based ruta 39 was zero. Which invoice explains an entry is now resolved by one composed helper, getInvoicesExplainingJournalEntries (lib/core/bookkeeping/ journal-entry-references.ts): the engine's own source_id, the invoice-side rows, and the rättelse chain through correction_of_id / reverses_id, walked upwards under the MAX_CHAIN_WALK cap correction-chain.ts already uses. Parents outside the batch are fetched by id, company-scoped. The link columns are followed rather than the storno's copied source_id because correctEntry() copies none onto its storno or correction, and a storno's copied source_id is polymorphic (a bank row on a bank booking). getInvoiceReferencesForJournalEntries (the RPC mirror behind the underlag surfaces) is unchanged: storno and correction are not doc-requiring source types, so those surfaces have no such hole. - INVOICE_SOURCED_ENTRY_TYPES / LINK_LOOKUP_CHUNK move to the helper module (the set now names every engine type whose source_id is an invoice). - PS: one resolver call; reverses_id, correction_of_id in the select; the ZERO_NET_EXCLUDED text names makulering beside kreditfaktura. - Tests: resolver cases (chain inheritance, out-of-batch fetch, own link wins, mirror, cycle, cap) and PS cases (the issue's storno nets to ZERO_NET_EXCLUDED, rättelse chain, later-period storno, mirror, linked import, gone invoice). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * fix(bookkeeping): PR #2354 review: bound in-batch chain propagation at MAX_CHAIN_WALK getInvoicesExplainingJournalEntries attributed an in-batch chain of stornos/corrections all the way down through a recursive assign over waitingOn, while parents fetched from outside the batch stopped at MAX_CHAIN_WALK. The two paths now agree: every attribution carries its depth from the root (0 for an entry resolved by its own source_id or invoice-side link), propagation is an explicit queue instead of recursion, and a descendant more than MAX_CHAIN_WALK links below the root resolves to no invoice, both when it inherits from an already attributed parent and when it is reached by propagation. Test: an in-batch chain of MAX_CHAIN_WALK + 1 links, in both batch orders, attributes the links within the cap and not the one beyond it, with no parent fetch. 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>
645 lines
22 KiB
TypeScript
645 lines
22 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import { chunk } from '@/lib/utils'
|
|
import {
|
|
INVOICE_SOURCED_ENTRY_TYPES,
|
|
LINK_LOOKUP_CHUNK,
|
|
getInvoicesExplainingJournalEntries,
|
|
} from '@/lib/core/bookkeeping/journal-entry-references'
|
|
import { calculatePeriodDates, formatPeriodLabel } from './period-dates'
|
|
import { calculateVatDeclaration } from './vat-declaration'
|
|
import { normalizeCountryCode } from '@/lib/vat/country-codes'
|
|
|
|
/**
|
|
* Periodisk sammanställning (EC Sales List / SKV 5740).
|
|
*
|
|
* Pure projection from the general ledger: posted journal entry lines on the
|
|
* three EU revenue accounts (3308 services, 3108 goods, 3107 triangulation)
|
|
* are joined back to the originating invoice and customer to produce one row
|
|
* per (country, vat_number) with three amount buckets.
|
|
*
|
|
* Shares its source data with vat-declaration.ts so the PS totals and the
|
|
* momsdeklaration Ruta 35/38/39 can never drift. See §1.2 of the plan.
|
|
*
|
|
* Notes:
|
|
* - Which invoice a posting belongs to is resolved through every link the
|
|
* register keeps (the engine's source_id, invoices.journal_entry_id,
|
|
* invoice_payments.journal_entry_id), so a SIE-imported sale matched to
|
|
* its invoice afterwards and a kontantmetod inbetalning are filed too
|
|
* (#2298). A storno or rättelse of such a posting is filed under the
|
|
* same invoice by following reverses_id / correction_of_id (#2351), so a
|
|
* makulerad sale nets to zero here exactly as it does in ruta 39. A
|
|
* 3308/3108 posting no invoice points at is not filed (there is no
|
|
* customer to name); the momsdeklaration reconciliation (ruta 35/38/39)
|
|
* is where such a gap shows.
|
|
* - Account 3305/3105 (non-EU export) are NOT in this report: they go to
|
|
* Ruta 36/40 only.
|
|
* - Trepartshandel (3107) is included so the report works if someone posts
|
|
* there manually; the invoicing UI doesn't post there today (v2).
|
|
* - Avropslager codes X/Y/Z are deferred to v2: the CSV serializer asserts
|
|
* only numeric amounts in v1.
|
|
*/
|
|
|
|
export type PsPeriodType = 'monthly' | 'quarterly'
|
|
|
|
export interface PsRow {
|
|
country: string // 2-char, EL for Grekland
|
|
vatNumber: string // normalized, no country prefix
|
|
services: number // typ 3 (account 3308), hela kronor
|
|
goods: number // typ 1 (account 3108)
|
|
triangulation: number // typ 2 (account 3107)
|
|
customerId: string | null
|
|
customerName: string | null
|
|
hasBlockingIssue: boolean
|
|
}
|
|
|
|
export type PsWarningCode =
|
|
| 'MISSING_COUNTRY'
|
|
| 'MISSING_VAT_NUMBER'
|
|
| 'VIES_UNVALIDATED'
|
|
| 'COUNTRY_PREFIX_MISMATCH'
|
|
| 'NON_EU_COUNTRY_ON_EU_ACCOUNT'
|
|
| 'CUSTOMER_NOT_FOUND'
|
|
| 'ZERO_NET_EXCLUDED'
|
|
| 'GOODS_SOLD_WITH_QUARTERLY_PERIOD'
|
|
/** One verifikat linked to invoices of different customers: cannot be split per customer. */
|
|
| 'MIXED_CUSTOMER_SETTLEMENT'
|
|
|
|
export interface PsWarning {
|
|
level: 'error' | 'warning'
|
|
code: PsWarningCode
|
|
message: string
|
|
customerId?: string
|
|
customerName?: string
|
|
invoiceId?: string
|
|
/** The verifikat a MIXED_CUSTOMER_SETTLEMENT warning is about. */
|
|
journalEntryId?: string
|
|
amount?: number
|
|
}
|
|
|
|
export interface PeriodiskSammanstallningReport {
|
|
period: {
|
|
type: PsPeriodType
|
|
year: number
|
|
period: number
|
|
start: string
|
|
end: string
|
|
label: string
|
|
}
|
|
rows: PsRow[]
|
|
warnings: PsWarning[]
|
|
totals: {
|
|
services: number
|
|
goods: number
|
|
triangulation: number
|
|
grand: number
|
|
rowCount: number
|
|
}
|
|
reconciliation: {
|
|
ruta39: number | null
|
|
ruta35: number | null
|
|
ruta38: number | null
|
|
matches: boolean | null
|
|
tolerance: number
|
|
}
|
|
}
|
|
|
|
/** ISO 3166-1 alpha-2 codes for EU member states (post-Brexit, excl. UK). */
|
|
const EU_COUNTRIES = new Set([
|
|
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI',
|
|
'FR', 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT',
|
|
'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK',
|
|
])
|
|
|
|
/** Skatteverket uses EL for Greece; ISO uses GR. */
|
|
function toVatCountryCode(iso: string): string {
|
|
return iso === 'GR' ? 'EL' : iso.toUpperCase()
|
|
}
|
|
|
|
const ACCOUNT_TO_BUCKET: Record<string, 'services' | 'goods' | 'triangulation'> = {
|
|
'3308': 'services',
|
|
'3108': 'goods',
|
|
'3107': 'triangulation',
|
|
}
|
|
|
|
const PS_ACCOUNTS = Object.keys(ACCOUNT_TO_BUCKET)
|
|
|
|
interface RawEntryLine {
|
|
account_number: string
|
|
debit_amount: number | string
|
|
credit_amount: number | string
|
|
}
|
|
|
|
interface RawEntry {
|
|
id: string
|
|
voucher_series: string | null
|
|
voucher_number: number | null
|
|
entry_date: string
|
|
status: string
|
|
source_type: string | null
|
|
source_id: string | null
|
|
/** Storno: the entry this one cancels; its invoice explains this one too. */
|
|
reverses_id: string | null
|
|
/** Rättelse: the entry this one replaces; likewise. */
|
|
correction_of_id: string | null
|
|
/** Only the PS-account lines: the embed is filtered on account_number. */
|
|
journal_entry_lines: RawEntryLine[] | null
|
|
}
|
|
|
|
type FlatLine = RawEntryLine & { entry: RawEntry }
|
|
|
|
interface RawInvoice {
|
|
id: string
|
|
customer_id: string | null
|
|
customer: {
|
|
id: string
|
|
name: string
|
|
country: string | null
|
|
vat_number: string | null
|
|
vat_number_validated: boolean | null
|
|
vat_number_validated_at: string | null
|
|
} | null
|
|
}
|
|
|
|
/**
|
|
* Strip optional leading country prefix and whitespace; uppercase the rest.
|
|
*
|
|
* Examples:
|
|
* "SE556677889901" → "556677889901"
|
|
* " de 123456789 " → "123456789"
|
|
* "el123" → "123"
|
|
*/
|
|
export function normalizeVatNumber(raw: string | null | undefined): string {
|
|
if (!raw) return ''
|
|
const stripped = raw.replace(/\s+/g, '').toUpperCase()
|
|
// Skatteverket prefixes are two letters; EL is intentionally treated the same.
|
|
if (/^[A-Z]{2}/.test(stripped)) return stripped.slice(2)
|
|
return stripped
|
|
}
|
|
|
|
function round(value: number): number {
|
|
return Math.round(value)
|
|
}
|
|
|
|
/** Voucher label for messages ("A123"), or the id when the entry has none. */
|
|
function voucherLabel(entry: RawEntry): string {
|
|
return entry.voucher_number != null
|
|
? `${entry.voucher_series ?? ''}${entry.voucher_number}`
|
|
: entry.id
|
|
}
|
|
|
|
/** Net credit of the entry's PS-account lines: what the file would carry. */
|
|
function entryNet(entry: RawEntry): number {
|
|
let net = 0
|
|
for (const line of entry.journal_entry_lines ?? []) {
|
|
net += (Number(line.credit_amount) || 0) - (Number(line.debit_amount) || 0)
|
|
}
|
|
return net
|
|
}
|
|
|
|
/**
|
|
* Who a linked invoice is filed under: the customer row plus the (country,
|
|
* VAT number) pair its PS row would carry. Two invoices agree only when all
|
|
* of it agrees; an invoice that could not be loaded is its own unknown party.
|
|
*/
|
|
function customerIdentity(invoice: RawInvoice | undefined, invoiceId: string): string {
|
|
const customer = invoice?.customer
|
|
if (!customer) return `unknown:${invoiceId}`
|
|
const country = (customer.country ?? '').trim().toUpperCase()
|
|
return `${customer.id}|${country}|${normalizeVatNumber(customer.vat_number)}`
|
|
}
|
|
|
|
interface Accumulator {
|
|
country: string
|
|
vatNumber: string
|
|
customerId: string | null
|
|
customerName: string | null
|
|
services: number
|
|
goods: number
|
|
triangulation: number
|
|
blocking: boolean
|
|
/** True once we've seen any non-zero posting, even if it later nets to zero. */
|
|
sawActivity: boolean
|
|
}
|
|
|
|
export async function generatePeriodiskSammanstallning(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
periodType: PsPeriodType,
|
|
year: number,
|
|
period: number,
|
|
): Promise<PeriodiskSammanstallningReport> {
|
|
if (periodType !== 'monthly' && periodType !== 'quarterly') {
|
|
throw new Error(`Invalid PS periodType: ${periodType}`)
|
|
}
|
|
if (periodType === 'monthly' && (period < 1 || period > 12)) {
|
|
throw new Error(`Invalid monthly period: ${period}`)
|
|
}
|
|
if (periodType === 'quarterly' && (period < 1 || period > 4)) {
|
|
throw new Error(`Invalid quarterly period: ${period}`)
|
|
}
|
|
|
|
const { start, end } = calculatePeriodDates(periodType, year, period)
|
|
|
|
// Driven from journal_entries (company + date indexed) with the EU-revenue
|
|
// condition as an inner embed: the planner probes journal_entry_lines per
|
|
// entry, so only entries carrying a posting on a PS account come back, with
|
|
// just those lines. Never the inverse shape (lines with an entries embed):
|
|
// see lib/bookkeeping/entry-lines.ts. No source_type filter: which register
|
|
// invoice a posting belongs to is resolved below through every link the
|
|
// register keeps, not only the engine's own source columns.
|
|
const entries = await fetchAllRows<RawEntry>(({ from, to }) =>
|
|
supabase
|
|
.from('journal_entries')
|
|
.select('id, voucher_series, voucher_number, entry_date, status, source_type, source_id, reverses_id, correction_of_id, journal_entry_lines!inner(account_number, debit_amount, credit_amount)')
|
|
.eq('company_id', companyId)
|
|
.in('status', ['posted', 'reversed'])
|
|
.gte('entry_date', start)
|
|
.lte('entry_date', end)
|
|
.in('journal_entry_lines.account_number', PS_ACCOUNTS)
|
|
// Stable total order for correct paging (see fetch-all.ts).
|
|
.order('id', { ascending: true })
|
|
.range(from, to) as unknown as PromiseLike<{ data: RawEntry[] | null; error: { message: string } | null }>,
|
|
)
|
|
|
|
// Which register invoice does each posting belong to? Four links; only
|
|
// the first lives on the entry itself:
|
|
// 1. the engine's own entries: source_id IS the invoice id;
|
|
// 2. invoices.journal_entry_id (registration booking, backfilled);
|
|
// 3. invoice_payments.journal_entry_id: kontantmetod inbetalning,
|
|
// delbetalning, and "matcha mot befintligt verifikat", which is how a
|
|
// SIE-imported sale gets its invoice after migration (#2298);
|
|
// 4. reverses_id / correction_of_id: a storno or rättelse is explained by
|
|
// whatever explains the entry it cancels or replaces (#2351). The
|
|
// reversed original stays in the fetch (status 'reversed'), so its
|
|
// storno must be filed under the same invoice or the makulerad sale
|
|
// is over-reported while ruta 39 nets it to zero.
|
|
// Following 1 alone (the old source_type filter) dropped every linked
|
|
// import and every kontantmetod sale from the filing while the
|
|
// account-based momsdeklaration kept showing them in ruta 39.
|
|
// Every invoice each entry resolves to. The engine's own entries name one
|
|
// (source_id); a linked entry may name several when one inbetalning settled
|
|
// several invoices. All of them are loaded so the loop below can tell "two
|
|
// invoices, one customer" from "two customers on one posting".
|
|
const invoiceIdsByEntry = await getInvoicesExplainingJournalEntries(supabase, companyId, entries)
|
|
|
|
const allInvoiceIds = new Set<string>()
|
|
for (const ids of invoiceIdsByEntry.values()) for (const id of ids) allInvoiceIds.add(id)
|
|
|
|
const invoiceMap = new Map<string, RawInvoice>()
|
|
for (const ids of chunk(Array.from(allInvoiceIds), LINK_LOOKUP_CHUNK)) {
|
|
const invoices = await fetchAllRows<RawInvoice>(({ from, to }) =>
|
|
supabase
|
|
.from('invoices')
|
|
.select(`
|
|
id,
|
|
customer_id,
|
|
customer:customers (
|
|
id,
|
|
name,
|
|
country,
|
|
vat_number,
|
|
vat_number_validated,
|
|
vat_number_validated_at
|
|
)
|
|
`)
|
|
.eq('company_id', companyId)
|
|
.in('id', ids)
|
|
// Stable total order for correct paging (see fetch-all.ts).
|
|
.order('id', { ascending: true })
|
|
.range(from, to) as unknown as PromiseLike<{ data: RawInvoice[] | null; error: { message: string } | null }>,
|
|
)
|
|
for (const inv of invoices) invoiceMap.set(inv.id, inv)
|
|
}
|
|
|
|
// One flat line list with its parent entry, in entry-id then line order.
|
|
const lines: FlatLine[] = []
|
|
for (const entry of entries) {
|
|
for (const line of entry.journal_entry_lines ?? []) lines.push({ ...line, entry })
|
|
}
|
|
|
|
const accumulators = new Map<string, Accumulator>()
|
|
const warnings: PsWarning[] = []
|
|
let goodsLineSeen = false
|
|
// Verifikat already reported as MIXED_CUSTOMER_SETTLEMENT: one warning per
|
|
// verifikat, not one per line.
|
|
const mixedReported = new Set<string>()
|
|
|
|
for (const line of lines) {
|
|
const bucket = ACCOUNT_TO_BUCKET[line.account_number]
|
|
if (!bucket) continue
|
|
const invoiceIds = invoiceIdsByEntry.get(line.entry.id)
|
|
// A manual or imported posting no register invoice points at is not
|
|
// filed (see the header). The engine's own entries never take this exit:
|
|
// an engine entry whose invoice is gone is a data defect and falls
|
|
// through to CUSTOMER_NOT_FOUND below.
|
|
if (!invoiceIds && !INVOICE_SOURCED_ENTRY_TYPES.has(line.entry.source_type ?? '')) continue
|
|
if (bucket === 'goods' || bucket === 'triangulation') goodsLineSeen = true
|
|
|
|
// One posting, several invoices (a deposit settling more than one): fine
|
|
// while they are the same customer, undecidable when they are not. The
|
|
// ledger cannot split the line per customer, so the verifikat is kept out
|
|
// of the file and reported as blocking, the way CUSTOMER_NOT_FOUND is.
|
|
if (invoiceIds && invoiceIds.length > 1) {
|
|
const customers = new Set(invoiceIds.map((id) => customerIdentity(invoiceMap.get(id), id)))
|
|
if (customers.size > 1) {
|
|
if (!mixedReported.has(line.entry.id)) {
|
|
mixedReported.add(line.entry.id)
|
|
warnings.push({
|
|
level: 'error',
|
|
code: 'MIXED_CUSTOMER_SETTLEMENT',
|
|
message:
|
|
`Verifikat ${voucherLabel(line.entry)} är kopplat till fakturor från ${customers.size} olika kunder ` +
|
|
'och kan inte fördelas per kund i sammanställningen. Kontrollera kopplingarna innan inlämning.',
|
|
journalEntryId: line.entry.id,
|
|
amount: entryNet(line.entry),
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
const invoice = invoiceIds ? invoiceMap.get(invoiceIds[0]) ?? null : null
|
|
|
|
const debit = Number(line.debit_amount) || 0
|
|
const credit = Number(line.credit_amount) || 0
|
|
const net = credit - debit
|
|
|
|
const customer = invoice?.customer ?? null
|
|
|
|
if (!invoice || !customer) {
|
|
warnings.push({
|
|
level: 'error',
|
|
code: 'CUSTOMER_NOT_FOUND',
|
|
message: 'Kund saknas på faktura. Kontakta support innan inlämning.',
|
|
invoiceId: invoice?.id,
|
|
amount: net,
|
|
})
|
|
bucketRow(accumulators, '??', '??', null, null, bucket, net, true)
|
|
continue
|
|
}
|
|
|
|
// Rows written before 2026-09 may still hold a country name the
|
|
// backfill could not map; a name the register knows becomes its code, an
|
|
// unknown one is kept as typed so the warning names it.
|
|
const isoCountry = normalizeCountryCode(customer.country) ?? (customer.country ?? '').trim().toUpperCase()
|
|
const vatCountry = isoCountry ? toVatCountryCode(isoCountry) : ''
|
|
const rawVat = customer.vat_number ?? ''
|
|
const normalizedVat = normalizeVatNumber(rawVat)
|
|
|
|
let blocking = false
|
|
|
|
if (!isoCountry) {
|
|
warnings.push({
|
|
level: 'error',
|
|
code: 'MISSING_COUNTRY',
|
|
message: `Kund "${customer.name}" saknar land. Uppdatera kunden innan CSV laddas ner.`,
|
|
customerId: customer.id,
|
|
customerName: customer.name,
|
|
invoiceId: invoice.id,
|
|
amount: net,
|
|
})
|
|
blocking = true
|
|
} else if (!EU_COUNTRIES.has(isoCountry)) {
|
|
warnings.push({
|
|
level: 'warning',
|
|
code: 'NON_EU_COUNTRY_ON_EU_ACCOUNT',
|
|
message:
|
|
`Konto ${line.account_number} men kund "${customer.name}" i ${isoCountry} ` +
|
|
'är inte EU-land. Kontrollera bokföringen.',
|
|
customerId: customer.id,
|
|
customerName: customer.name,
|
|
invoiceId: invoice.id,
|
|
amount: net,
|
|
})
|
|
blocking = true
|
|
}
|
|
|
|
if (!normalizedVat) {
|
|
warnings.push({
|
|
level: 'error',
|
|
code: 'MISSING_VAT_NUMBER',
|
|
message: `Kund "${customer.name}" saknar VAT-nummer.`,
|
|
customerId: customer.id,
|
|
customerName: customer.name,
|
|
invoiceId: invoice.id,
|
|
amount: net,
|
|
})
|
|
blocking = true
|
|
} else {
|
|
// VAT prefix check: if the raw VAT-number starts with a country code,
|
|
// it must match the customer.country. Skatteverket uses EL for Greece.
|
|
const rawUpper = rawVat.replace(/\s+/g, '').toUpperCase()
|
|
const prefixMatch = rawUpper.match(/^([A-Z]{2})/)
|
|
if (prefixMatch && isoCountry) {
|
|
const expected = toVatCountryCode(isoCountry)
|
|
if (prefixMatch[1] !== expected) {
|
|
warnings.push({
|
|
level: 'warning',
|
|
code: 'COUNTRY_PREFIX_MISMATCH',
|
|
message:
|
|
`VAT-nr för "${customer.name}" har prefix ${prefixMatch[1]} men ` +
|
|
`kunden är registrerad i ${isoCountry}.`,
|
|
customerId: customer.id,
|
|
customerName: customer.name,
|
|
invoiceId: invoice.id,
|
|
})
|
|
}
|
|
}
|
|
|
|
const validatedAt = customer.vat_number_validated_at
|
|
const stale = validatedAt
|
|
? (Date.now() - new Date(validatedAt).getTime()) / (1000 * 60 * 60 * 24) > 30
|
|
: true
|
|
if (!customer.vat_number_validated || stale) {
|
|
warnings.push({
|
|
level: 'warning',
|
|
code: 'VIES_UNVALIDATED',
|
|
message:
|
|
`Kund "${customer.name}" är inte VIES-validerad (eller validering äldre än 30 dagar). ` +
|
|
'Verifiera mot Skatteverkets VIES-tjänst.',
|
|
customerId: customer.id,
|
|
customerName: customer.name,
|
|
})
|
|
}
|
|
}
|
|
|
|
bucketRow(
|
|
accumulators,
|
|
vatCountry || isoCountry || '??',
|
|
normalizedVat || '??',
|
|
customer.id,
|
|
customer.name,
|
|
bucket,
|
|
net,
|
|
blocking,
|
|
)
|
|
}
|
|
|
|
// Goods-sold-with-quarterly-period: blocking under SFL 35 kap. 2 §.
|
|
// Companies selling goods intra-EU must file PS monthly; a quarterly filing
|
|
// is structurally non-compliant and must not be exportable as CSV.
|
|
if (goodsLineSeen && periodType === 'quarterly') {
|
|
warnings.push({
|
|
level: 'error',
|
|
code: 'GOODS_SOLD_WITH_QUARTERLY_PERIOD',
|
|
message:
|
|
'Du har varuförsäljning i perioden. Periodisk sammanställning för varor ska ' +
|
|
'rapporteras månadsvis (35 kap. 2 § SFL). Byt period eller kontakta Skatteverket.',
|
|
})
|
|
}
|
|
|
|
// Round, drop zero rows, sort.
|
|
const rows: PsRow[] = []
|
|
for (const acc of accumulators.values()) {
|
|
const services = round(acc.services)
|
|
const goods = round(acc.goods)
|
|
const triangulation = round(acc.triangulation)
|
|
if (services === 0 && goods === 0 && triangulation === 0) {
|
|
// Emit a warning only if there was actual rörelse (a credit note nets
|
|
// services back to zero: final values are 0 but we saw activity).
|
|
if (acc.sawActivity) {
|
|
warnings.push({
|
|
level: 'warning',
|
|
code: 'ZERO_NET_EXCLUDED',
|
|
message:
|
|
`Kund "${acc.customerName ?? acc.vatNumber}" nettar till 0 kr för perioden ` +
|
|
'(kreditfaktura eller makulering tar ut originalet). Exkluderad från filen.',
|
|
customerId: acc.customerId ?? undefined,
|
|
customerName: acc.customerName ?? undefined,
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
rows.push({
|
|
country: acc.country,
|
|
vatNumber: acc.vatNumber,
|
|
services,
|
|
goods,
|
|
triangulation,
|
|
customerId: acc.customerId,
|
|
customerName: acc.customerName,
|
|
hasBlockingIssue: acc.blocking,
|
|
})
|
|
}
|
|
|
|
rows.sort((a, b) => {
|
|
if (a.country !== b.country) return a.country.localeCompare(b.country)
|
|
return a.vatNumber.localeCompare(b.vatNumber)
|
|
})
|
|
|
|
const totals = {
|
|
services: rows.reduce((s, r) => s + r.services, 0),
|
|
goods: rows.reduce((s, r) => s + r.goods, 0),
|
|
triangulation: rows.reduce((s, r) => s + r.triangulation, 0),
|
|
grand: 0,
|
|
rowCount: rows.length,
|
|
}
|
|
totals.grand = totals.services + totals.goods + totals.triangulation
|
|
|
|
return {
|
|
period: {
|
|
type: periodType,
|
|
year,
|
|
period,
|
|
start,
|
|
end,
|
|
label: formatPeriodLabel(periodType, year, period),
|
|
},
|
|
rows,
|
|
warnings,
|
|
totals,
|
|
reconciliation: {
|
|
ruta39: null,
|
|
ruta35: null,
|
|
ruta38: null,
|
|
matches: null,
|
|
tolerance: Math.max(1, Math.ceil(rows.length / 2)),
|
|
},
|
|
}
|
|
}
|
|
|
|
function bucketRow(
|
|
map: Map<string, Accumulator>,
|
|
country: string,
|
|
vatNumber: string,
|
|
customerId: string | null,
|
|
customerName: string | null,
|
|
bucket: 'services' | 'goods' | 'triangulation',
|
|
amount: number,
|
|
blocking: boolean,
|
|
): void {
|
|
const key = `${country}|${vatNumber}|${customerId ?? ''}`
|
|
let acc = map.get(key)
|
|
if (!acc) {
|
|
acc = {
|
|
country,
|
|
vatNumber,
|
|
customerId,
|
|
customerName,
|
|
services: 0,
|
|
goods: 0,
|
|
triangulation: 0,
|
|
blocking: false,
|
|
sawActivity: false,
|
|
}
|
|
map.set(key, acc)
|
|
}
|
|
acc[bucket] += amount
|
|
if (amount !== 0) acc.sawActivity = true
|
|
if (blocking) acc.blocking = true
|
|
}
|
|
|
|
/**
|
|
* Cross-check PS totals against momsdeklaration Ruta 35/38/39.
|
|
*
|
|
* Only meaningful when the PS period coincides with the momsdeklaration period.
|
|
* Returns the report with reconciliation populated; matches=null indicates the
|
|
* caller asked for a check that doesn't make sense (different periods).
|
|
*/
|
|
export async function reconcilePsAgainstVatDeclaration(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
report: PeriodiskSammanstallningReport,
|
|
momsPeriod: 'monthly' | 'quarterly' | 'yearly' | null,
|
|
): Promise<PeriodiskSammanstallningReport> {
|
|
// Reconciliation only valid when periods coincide. Yearly is never valid for PS.
|
|
const periodsCoincide =
|
|
(report.period.type === 'monthly' && momsPeriod === 'monthly') ||
|
|
(report.period.type === 'quarterly' && momsPeriod === 'quarterly')
|
|
|
|
if (!periodsCoincide) {
|
|
return report
|
|
}
|
|
|
|
const vat = await calculateVatDeclaration(
|
|
supabase,
|
|
companyId,
|
|
report.period.type,
|
|
report.period.year,
|
|
report.period.period,
|
|
)
|
|
|
|
const ruta35 = Math.round(vat.rutor.ruta35)
|
|
const ruta38 = Math.round(vat.rutor.ruta38 ?? 0)
|
|
const ruta39 = Math.round(vat.rutor.ruta39)
|
|
|
|
const tolerance = report.reconciliation.tolerance
|
|
const matches =
|
|
Math.abs(report.totals.services - ruta39) <= tolerance &&
|
|
Math.abs(report.totals.goods - ruta35) <= tolerance &&
|
|
Math.abs(report.totals.triangulation - ruta38) <= tolerance
|
|
|
|
return {
|
|
...report,
|
|
reconciliation: {
|
|
ruta39,
|
|
ruta35,
|
|
ruta38,
|
|
matches,
|
|
tolerance,
|
|
},
|
|
}
|
|
}
|
|
|
|
export { formatPeriodLabel } from './period-dates'
|