* fix(reports): drive report line queries from journal_entries, not the unfiltered lines side
Every report generator fetched journal_entry_lines with a
journal_entries!inner(...) embed and put the tenant filter on the
embedded side (.eq('journal_entries.company_id', ...)). PostgREST
compiles that to a correlated INNER JOIN LATERAL with a parameterized
LIMIT inside, which blocks join reordering: Postgres walked the ENTIRE
journal_entry_lines table (603k rows, all tenants) per report query.
Measured in production: 13.6 s vs 2.7 ms for the equivalent plain join,
against Supabase's 8 s statement_timeout; nightly cloud backups failed
for 5 of 11 companies on 2026-07-09 and a GL report 500'd.
Introduce lib/bookkeeping/entry-lines.ts with a shared two-step fetch:
1. fetch matching journal_entries (id + caller-selected columns)
filtered by company_id / fiscal_period_id / status / entry_date /
source_type, paginated via fetchAllRows;
2. fetch journal_entry_lines with .in('journal_entry_id', chunk) in
chunks of 100 ids (URL-length safety), paginated per chunk;
3. reattach the parent entry to each line under the embed's key shape
(line.journal_entries = {...}, aliasable) and sort lines by id
ascending to preserve the old .order('id') semantics.
Converted call sites (selected columns and filters preserved):
trial-balance (x2), general-ledger, journal-register, sie-export
(reuses its existing entry list via fetchLinesByEntryIds),
vat-declaration, dimension-pnl, opening-balances, monthly-breakdown,
periodisk-sammanstallning, rc-basis-gaps (sibling-line fetch now also
chunked), ar-reconciliation, supplier-reconciliation,
bank-reconciliation, asset-service (x2), bolagsskatt-calculator,
sarskild-loneskatt-calculator.
Tests: unit tests for the helper (chunk size, reattachment shape,
forced id/journal_entry_id columns, empty result, cross-chunk sort,
error propagation); existing report/reconciliation/bokslut test mocks
updated to the two-step query shape, preserving every assertion about
report output.
From the 2026-07-09 production log triage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reports): stop echoing raw error messages from the general-ledger route
The catch handler returned err.message to the client in
details.reason; internal error strings (SQL fragments, table names,
timeout messages) must not reach the browser. The error is already
logged server-side with the request id, so the client envelope keeps
only the REPORT_GENERATION_FAILED code.
From the 2026-07-09 production log triage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
527 lines
16 KiB
TypeScript
527 lines
16 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
|
import { calculatePeriodDates, formatPeriodLabel } from './period-dates'
|
|
import { calculateVatDeclaration } from './vat-declaration'
|
|
|
|
/**
|
|
* 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:
|
|
* - 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'
|
|
|
|
export interface PsWarning {
|
|
level: 'error' | 'warning'
|
|
code: PsWarningCode
|
|
message: string
|
|
customerId?: string
|
|
customerName?: string
|
|
invoiceId?: 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 RawLine {
|
|
account_number: string
|
|
debit_amount: number | string
|
|
credit_amount: number | string
|
|
journal_entries: {
|
|
company_id: string
|
|
entry_date: string
|
|
status: string
|
|
source_type: string
|
|
source_id: string | null
|
|
} | null
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
|
|
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
|
|
const lines = await fetchEntryLines<RawLine>({
|
|
supabase,
|
|
entryColumns: 'company_id, entry_date, status, source_type, source_id',
|
|
lineColumns: 'account_number, debit_amount, credit_amount',
|
|
filterEntries: (q: EntryLinesQuery) =>
|
|
q
|
|
.eq('company_id', companyId)
|
|
.in('status', ['posted', 'reversed'])
|
|
// Cash sales on 3308/3108 are not a real flow (EU reverse-charge sales
|
|
// always go through AR); excluded to avoid phantom rows.
|
|
.in('source_type', ['invoice_created', 'credit_note'])
|
|
.gte('entry_date', start)
|
|
.lte('entry_date', end),
|
|
filterLines: (q: EntryLinesQuery) => q.in('account_number', PS_ACCOUNTS),
|
|
})
|
|
|
|
const invoiceIds = Array.from(
|
|
new Set(
|
|
lines
|
|
.map(l => l.journal_entries?.source_id)
|
|
.filter((id): id is string => typeof id === 'string'),
|
|
),
|
|
)
|
|
|
|
const invoiceMap = new Map<string, RawInvoice>()
|
|
if (invoiceIds.length > 0) {
|
|
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
|
|
)
|
|
`)
|
|
.in('id', invoiceIds)
|
|
// 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)
|
|
}
|
|
|
|
const accumulators = new Map<string, Accumulator>()
|
|
const warnings: PsWarning[] = []
|
|
let goodsLineSeen = false
|
|
|
|
for (const line of lines) {
|
|
const je = line.journal_entries
|
|
if (!je) continue
|
|
const sourceId = je.source_id
|
|
const invoice = sourceId ? invoiceMap.get(sourceId) : null
|
|
const bucket = ACCOUNT_TO_BUCKET[line.account_number]
|
|
if (!bucket) continue
|
|
if (bucket === 'goods' || bucket === 'triangulation') goodsLineSeen = true
|
|
|
|
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
|
|
}
|
|
|
|
const isoCountry = (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 tar ut original). 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'
|