* feat(api): test-mode API keys force dry-run on the v1 REST API A key created with mode='test' (prefix gnubok_sk_test_) binds to the real company, but the v1 wrapper forces dry_run on every write so nothing is persisted or sent. Mutations on endpoints that can't be simulated (dryRunSupported=false or unregistered) are refused with 403 TEST_KEY_WRITE_BLOCKED — fail-closed. Reads pass through unchanged and every test-key response carries X-Gnubok-Mode: test. Live keys are unaffected (mode defaults to 'live'). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(invoices): company default "Vår referens" + per-line sales-account override Add company_settings.default_our_reference (settings form, schema, type); the invoice editor pre-fills our_reference from it on new invoices only, never overwriting an edited draft. Separately, add an optional per-line försäljningskonto (class-3) override in the editor — left blank, the engine still derives the revenue account from the VAT rate, and reverse-charge/export lines ignore the override. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(invoices): render a Swish payment QR on invoice PDFs Build the Swish "Type C" QR payload offline (no Swish API call) and embed it as a PNG in the invoice PDF payment box when Swish display is enabled, the invoice is in SEK, and the amount is positive. Also surface the invoice number in the payment box. Wired through every PDF render path: send, mark-sent and pdf routes (both legacy and v1), the recurring-schedule sender, and the staged-send commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): draft exclusion + correction-chain collapse on verifikationslista Extend list_fiscal_period_entries_with_related with two opt-in params: p_exclude_draft (keep drafts off the committed list — they get their own surface) and p_collapse_corrections (render a correction group as the single live correction, hiding the mechanical storno and the reversed original). Both default false; nothing is deleted, every voucher keeps its number, and a "show all" toggle exposes the full chain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reports): link multi-year SIE periods so resultatrapport shows the prior year SIE import now sets fiscal_periods.previous_period_id in both directions when creating a period, so multi-year files chain correctly regardless of #RAR order. A backfill migration repairs periods imported before this (idempotent; only touches NULL links on first-of-month periods). generateResultatrapport falls back to the date-adjacent prior period when the chain is still null, so the comparison column works for legacy data too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(articles): hide the VAT field for non-momsregistrerade companies The article form reads company_settings.vat_registered and, when false, hides the moms field and forces vat_rate to 0 on submit — mirroring the invoice editor so a non-VAT-registered company never sets a rate it can't charge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(import): allow file-based imports in the sandbox Bank-file, CSV/Excel and SIE imports run entirely on uploaded data with no external service, so they're now reachable in the sandbox. Only the API-backed options that need live third-party credentials (PSD2 bank connection, provider migration) stay disabled. Updates the sandbox notice copy to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): add edit draft functionality for journal entries * feat(database): add default "Vår referens" column to company_settings for invoicing * fix(tests): set SHOW_SWISH_ON_INVOICE to false in PDF template mocks * @ fix(payments): use roundOre for Swish amount formatting Replace naive Math.round(x*100)/100 with roundOre from @/lib/money to satisfy the antipattern guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
233 lines
9.6 KiB
TypeScript
233 lines
9.6 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
||
import { NextResponse } from 'next/server'
|
||
import { createDraftEntry, createJournalEntry } from '@/lib/bookkeeping/engine'
|
||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||
import { ensureInitialized } from '@/lib/init'
|
||
import { validateBody } from '@/lib/api/validate'
|
||
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
|
||
import { requireCompanyId } from '@/lib/company/context'
|
||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
|
||
|
||
ensureInitialized()
|
||
|
||
export async function GET(request: Request) {
|
||
const supabase = await createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
}
|
||
|
||
const companyId = await requireCompanyId(supabase, user.id)
|
||
|
||
const { searchParams } = new URL(request.url)
|
||
const periodId = searchParams.get('period_id')
|
||
const status = searchParams.get('status')
|
||
// Drafts get their own surface in the UI; the committed list excludes them.
|
||
const excludeDraft = searchParams.get('exclude_draft') === 'true'
|
||
// Collapse a correction group to the live correction (hide the storno and the
|
||
// reversed original it replaced). The full chain stays reachable.
|
||
const collapseCorrections = searchParams.get('collapse_corrections') === 'true'
|
||
// Clamp pagination to bound DB work against oversized/pathological inputs
|
||
// (compliance A.8.28 / ASVS V1.2.5). The UI page-size selector offers
|
||
// 20/50/100/Alla; "Alla" sends a large limit which is capped at MAX_LIMIT.
|
||
const MAX_LIMIT = 100000
|
||
const rawLimit = parseInt(searchParams.get('limit') || '50', 10)
|
||
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), MAX_LIMIT) : 50
|
||
const rawOffset = parseInt(searchParams.get('offset') || '0', 10)
|
||
const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0
|
||
const dateFrom = searchParams.get('date_from')
|
||
const dateTo = searchParams.get('date_to')
|
||
const sortDate = searchParams.get('sort_date') // 'asc' | 'desc'
|
||
// 'series' optional filter — single uppercase letter A–Z. Ignored if any
|
||
// other value is passed (defense against trivial injection / typos).
|
||
const seriesRaw = searchParams.get('series')
|
||
const seriesFilter = seriesRaw && /^[A-Z]$/.test(seriesRaw) ? seriesRaw : null
|
||
// Free-text search over the voucher description (verifikationstext). When set,
|
||
// we take the direct-query path below (the include_related RPC can't search),
|
||
// which filters strictly by fiscal_period_id. So search is scoped to the
|
||
// selected fiscal period / company and — like voucher sort — does NOT surface
|
||
// cross-period follow-up entries: every result stays inside the selected
|
||
// year's series (the BFL-compliant per-year view). It narrows the period, it
|
||
// never widens it.
|
||
const search = searchParams.get('search')?.trim() || null
|
||
// 'date_desc' (default) | 'date_asc' | 'voucher_asc' | 'voucher_desc'
|
||
// sort_by overrides sort_date when present. sort_date is kept for backwards
|
||
// compatibility with older clients.
|
||
const sortBy = searchParams.get('sort_by')
|
||
const isVoucherSort = sortBy === 'voucher_asc' || sortBy === 'voucher_desc'
|
||
// Default on: when a fiscal period is selected, include follow-up entries
|
||
// booked in later periods whose source aggregate (invoice, supplier invoice)
|
||
// is dated inside the selected period. Pass include_related=false to
|
||
// restore strict fiscal_period_id filtering.
|
||
const includeRelated = searchParams.get('include_related') !== 'false'
|
||
|
||
const dateAscending = sortDate === 'asc' || sortBy === 'date_asc'
|
||
const sortDateParam = sortBy === 'date_asc' || sortDate === 'asc' ? 'asc' : 'desc'
|
||
|
||
// Voucher-sort path: include_related RPC doesn't support voucher ordering,
|
||
// so fall through to the direct query below. This means voucher sort is
|
||
// *strict by fiscal_period_id* — cross-period follow-up entries that the
|
||
// RPC normally surfaces under date sort are excluded under voucher sort.
|
||
// That's intentional: voucher numbers are series-scoped within a fiscal
|
||
// year (BFL 5 kap 6–7 §§), so showing series A1, A2 … alongside entries
|
||
// belonging to a different year's series would be misleading. The trade-off
|
||
// is that the visible row count may differ between sort modes for the same
|
||
// period; the strict count is the BFL-compliant view of that year.
|
||
if (periodId && includeRelated && !isVoucherSort && !search) {
|
||
const { data, error } = await supabase.rpc('list_fiscal_period_entries_with_related', {
|
||
p_company_id: companyId,
|
||
p_period_id: periodId,
|
||
p_include_related: true,
|
||
p_status: status,
|
||
p_date_from: dateFrom,
|
||
p_date_to: dateTo,
|
||
p_sort_date: sortDateParam,
|
||
p_limit: limit,
|
||
p_offset: offset,
|
||
p_exclude_draft: excludeDraft,
|
||
p_collapse_corrections: collapseCorrections,
|
||
})
|
||
|
||
if (error) {
|
||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||
}
|
||
|
||
const rows = data ?? []
|
||
let entries = rows.map((r: { entry: unknown }) => r.entry) as Array<{ voucher_series?: string }>
|
||
let count = rows.length > 0 ? Number((rows[0] as { total_count: number | string }).total_count) : 0
|
||
|
||
// The list_fiscal_period_entries_with_related RPC doesn't accept a series
|
||
// filter, so post-filter here. Recompute count from the filtered set so
|
||
// the paginator stays consistent; consequence: when a series filter is
|
||
// applied, the cross-period follow-up surfacing is still on but the
|
||
// visible total drops to the matching subset.
|
||
if (seriesFilter) {
|
||
entries = entries.filter((e) => (e?.voucher_series ?? 'A') === seriesFilter)
|
||
count = entries.length
|
||
}
|
||
|
||
return NextResponse.json({ data: entries, count })
|
||
}
|
||
|
||
let query = supabase
|
||
.from('journal_entries')
|
||
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
|
||
.eq('company_id', companyId)
|
||
|
||
if (isVoucherSort) {
|
||
const voucherAscending = sortBy === 'voucher_asc'
|
||
query = query
|
||
.order('voucher_series', { ascending: voucherAscending })
|
||
.order('voucher_number', { ascending: voucherAscending })
|
||
} else if (sortDate === 'asc' || sortDate === 'desc' || sortBy === 'date_asc' || sortBy === 'date_desc') {
|
||
query = query
|
||
.order('entry_date', { ascending: dateAscending })
|
||
.order('voucher_number', { ascending: dateAscending })
|
||
} else {
|
||
query = query
|
||
.order('voucher_series', { ascending: true })
|
||
.order('voucher_number', { ascending: true })
|
||
}
|
||
|
||
query = query.range(offset, offset + limit - 1)
|
||
|
||
if (periodId) {
|
||
query = query.eq('fiscal_period_id', periodId)
|
||
}
|
||
|
||
if (status) {
|
||
query = query.eq('status', status)
|
||
} else {
|
||
query = query.neq('status', 'cancelled')
|
||
if (excludeDraft) {
|
||
query = query.neq('status', 'draft')
|
||
}
|
||
}
|
||
|
||
if (dateFrom) {
|
||
query = query.gte('entry_date', dateFrom)
|
||
}
|
||
|
||
if (dateTo) {
|
||
query = query.lte('entry_date', dateTo)
|
||
}
|
||
|
||
if (seriesFilter) {
|
||
query = query.eq('voucher_series', seriesFilter)
|
||
}
|
||
|
||
if (search) {
|
||
// Escape LIKE wildcards (\ % _) so they match literally, and cap the needle
|
||
// length (≤200 chars) — both handled by the shared escapeLikePattern helper.
|
||
// The cap bounds DB work against oversized/pathological inputs (compliance
|
||
// A.8.28 / ASVS V1.2.5); escaping prevents silent over-matching on values
|
||
// like "50%". Supabase parameterises the value, so this is not about SQLi.
|
||
query = query.ilike('description', `%${escapeLikePattern(search)}%`)
|
||
}
|
||
|
||
// Collapse correction groups (voucher-sort / search path): hide the storno
|
||
// and the reversed originals a posted correction replaced, leaving the live
|
||
// correction. Pagination/count stay correct because these are query filters.
|
||
if (collapseCorrections) {
|
||
query = query.neq('source_type', 'storno')
|
||
const { data: corrections } = await supabase
|
||
.from('journal_entries')
|
||
.select('correction_of_id')
|
||
.eq('company_id', companyId)
|
||
.eq('source_type', 'correction')
|
||
.eq('status', 'posted')
|
||
.not('correction_of_id', 'is', null)
|
||
const correctedOriginalIds = Array.from(
|
||
new Set((corrections ?? []).map((r) => r.correction_of_id).filter(Boolean) as string[])
|
||
)
|
||
if (correctedOriginalIds.length > 0) {
|
||
query = query.not('id', 'in', `(${correctedOriginalIds.join(',')})`)
|
||
}
|
||
}
|
||
|
||
const { data, error, count } = await query
|
||
|
||
if (error) {
|
||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||
}
|
||
|
||
return NextResponse.json({ data, count })
|
||
}
|
||
|
||
export async function POST(request: Request) {
|
||
const supabase = await createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
}
|
||
|
||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||
if (!writeCheck.ok) return writeCheck.response
|
||
|
||
const companyId = await requireCompanyId(supabase, user.id)
|
||
|
||
const validation = await validateBody(request, CreateJournalEntrySchema)
|
||
if (!validation.success) return validation.response
|
||
const body = validation.data
|
||
|
||
const { searchParams } = new URL(request.url)
|
||
const asDraft = searchParams.get('as_draft') === 'true'
|
||
|
||
try {
|
||
const entry = asDraft
|
||
? await createDraftEntry(supabase, companyId, user.id, body)
|
||
: await createJournalEntry(supabase, companyId, user.id, body)
|
||
return NextResponse.json({ data: entry })
|
||
} catch (err) {
|
||
const typed = bookkeepingErrorResponse(err)
|
||
if (typed) return typed
|
||
return NextResponse.json(
|
||
{ error: err instanceof Error ? err.message : 'Failed to create journal entry' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
}
|