* feat(api): implement commit functionality for journal entries * fix(extensions): make ExtensionSettings.clear() a real delete so disconnect flows work The 2026-03-30 multi-tenant refactor dropped all RLS policies on extension_data and recreated only SELECT/INSERT/UPDATE. Combined with `value jsonb NOT NULL`, every extension that called `settings.set(key, null)` to clear stored state (cloud-backup disconnect, skatteverket OAuth/AGI cleanup, arcim-migration consent reset) silently failed — the upsert hit the NOT NULL constraint and the error was swallowed, leaving users stuck with stale connection rows. Adds an `extension_data_delete` RLS policy, a `clear(key)` method backed by a real DELETE, switches the four affected handlers, and makes `set()` throw on Supabase error so this class of silent failure can't recur. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(journal-entries): add draft saving functionality to journal entry form * feat: add periodisk sammanställning report generation and CSV export - Implemented period date helpers in `period-dates.ts` for calculating start and end dates based on period type (monthly, quarterly, yearly). - Created `periodisk-sammanstallning.ts` to generate the periodisk sammanställning report, including data fetching, validation, and warning handling. - Developed CSV serializer in `periodisk-sammanstallning-csv.ts` for exporting the report in SKV574008 format. - Added new columns to `company_settings` for storing periodisk sammanställning settings and tax contact information via migration. - Introduced a new migration to add a `paid_with_private_funds` flag to `supplier_invoices` for tracking out-of-pocket expenses. - Updated journal entries to include the new source type for privately paid supplier invoices. * feat(migrations): add paid_with_private_funds flag to supplier_invoices and expand journal_entries.source_type CHECK * fix(ai_requests): drop existing policies and trigger before creating new ones * fix(migrations): ensure extension_data has a proper DELETE policy for ExtensionSettings.clear() * fix(supplier-invoices): update error handling for invalid input in POST request * fix: correct capitalization in project title * fix(migrations): resolve duplicate version 20260513120000 Two migrations shared the same timestamp prefix, causing schema_migrations_pkey collision on Supabase preview branches. Bump extension_data_delete_policy to 20260513120001. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
161 lines
5.9 KiB
TypeScript
161 lines
5.9 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'
|
||
// '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 ?? []
|
||
const entries = rows.map((r: { entry: unknown }) => r.entry)
|
||
const count = rows.length > 0 ? Number((rows[0] as { total_count: number | string }).total_count) : 0
|
||
|
||
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)
|
||
}
|
||
|
||
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 }
|
||
)
|
||
}
|
||
}
|