* feat: add option to exclude year-end closing entries in SIE export and related reports * delete docs * fix: allow Chrome's PDF viewer in verifikat document preview The /api/documents/:id/inline route shipped with `object-src 'none'` in its CSP, which blocked Chrome's built-in PDF viewer (it renders inline PDFs via an internal <embed>). Users on Chrome saw "Det här innehållet har blockerats" when expanding a PDF attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own viewer) were unaffected, and JPGs worked because <img> isn't subject to object-src. Drops the CSP for this route to the minimum needed for embeddability: `frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the fixed Content-Type from the handler already block MIME confusion; X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(auth): add webmail deep link to email confirmation screens Mirrors Stripe's signup UX: after asking the user to verify their email, detect their webmail provider from the domain and show a button that opens the inbox in a new tab. Gmail gets a from:<sender> search pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly. Unknown / custom domains fall back to the existing copy. Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM (default noreply@gnubok.se) so white-label installs can match their Supabase Auth SMTP config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): unblock first-time password set for BankID users with MFA Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session is required" whenever a TOTP factor is enrolled. BankID magic-link logins produce AAL1, and middleware skips MFA enforcement for bankid_linked users, so they had no path to AAL2 — leaving them unable to set a backup password or disable MFA without going through the email-recovery escape hatch. - /api/account/password: branch on app_metadata.has_password. First-time set writes via service.auth.admin.updateUserById (no existing credential to protect, AAL2 guard does not apply). Change-password keeps the user-session updateUser so AAL2 still fires for credential rotation. - /mfa/verify: accept a safeReturnTo query param and route there after successful verify, so step-up flows can land back where they came from. - SecuritySettings: detect the AAL2 error from both change-password and mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account instead of toasting a dead-end error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add tests and rounding utility for öre precision in bokslut calculations - Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations. - Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries. - Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency. - Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies. - Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios. * fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility * fix: enhance security by rejecting data URIs in safeReturnTo function tests * fix: improve rounding logic in roundOre function and add customer_type migration * fix: add customer_type column to customers and enforce CHECK constraint --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
6.7 KiB
TypeScript
179 lines
6.7 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'
|
||
|
||
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')
|
||
const limit = parseInt(searchParams.get('limit') || '50')
|
||
const offset = parseInt(searchParams.get('offset') || '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
|
||
// '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) {
|
||
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,
|
||
})
|
||
|
||
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 (dateFrom) {
|
||
query = query.gte('entry_date', dateFrom)
|
||
}
|
||
|
||
if (dateTo) {
|
||
query = query.lte('entry_date', dateTo)
|
||
}
|
||
|
||
if (seriesFilter) {
|
||
query = query.eq('voucher_series', seriesFilter)
|
||
}
|
||
|
||
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 }
|
||
)
|
||
}
|
||
}
|