Files
accounted/app/api/settings/booking-templates/route.ts
T
MattssonandClaude Opus 4.8 c6c86cded4 Mcp/template data feedback (#617)
* fix(booking-templates): scope template list to the active company

GET /api/settings/booking-templates relied solely on the btl_select RLS
policy, which is membership-wide (user_company_ids) and returns templates
from every company the user belongs to. A user who owns multiple companies
saw all their templates merged regardless of which company was active.

Narrow the list in the API layer (mirroring counterparty-templates) to
system + the active company + the active company's team. RLS stays the
security backstop; this fixes the cross-company merge within a single
user's own view (it was never a cross-tenant data leak).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(import): show proper message for duplicate bank file upload

The bank file import page mis-parsed the structured error envelope
({ error: { code, message, details } }), so a BANK_FILE_DUPLICATE
(409) fell through to the generic "Kunde inte läsa filen" fallback.
The upload step also hardcoded that same string as the error heading,
so duplicates were doubly misreported as parse failures.

- Parse the structured envelope by error.code; surface error.message
  for all codes instead of rendering the error object.
- Add a dedicated BANK_FILE_DUPLICATE message using the importedAt /
  importedCount details the route already returns.
- Add an optional errorTitle prop to BankFileUploadStep (defaults to
  the previous text) and pass "Filen är redan importerad" for dupes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tests): add comprehensive tests for recordateEntry, inbox-linking, and external-id handling

- Implemented unit tests for recordateEntry in the bookkeeping module to validate various scenarios including date changes, non-posted entries, and fiscal period restrictions.
- Created tests for inbox-linking status in pending operations to ensure correct handling of invoice inbox items and supplier invoices, addressing historical bugs related to status updates.
- Added tests for external-id utilities to ensure consistent handling of monetary amounts and deduplication keys across different transaction sources.
- Introduced new functions in external-id.ts for stable external ID generation and normalization of imported descriptions, enhancing transaction deduplication reliability.

feat(migrations): add new database migrations for transaction handling

- Created migration to exclude storno and correction vouchers from unmatched GL lines, ensuring accurate reconciliation.
- Added a migration to preserve original bank transaction descriptions in a new immutable column, allowing for user edits while maintaining audit trails and deduplication integrity.

* feat(migrations): add function to exclude storno/correction vouchers from unmatched GL lines

* feat(transactions): enhance transaction handling with improved description normalization and preloaded original entries

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:45:49 +02:00

200 lines
7.0 KiB
TypeScript

import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
// The GET scope below builds a PostgREST .or() filter by string interpolation.
// Guard every interpolated id against a strict UUID shape so a tainted value
// can never inject filter syntax. Both ids are server-derived (companyId from
// membership, teamId from a DB column), so this is defense-in-depth.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const BookingTemplateLineSchema = z.object({
account: z.string().regex(/^\d{4}$/),
label: z.string().min(1),
side: z.enum(['debit', 'credit']),
type: z.enum(['business', 'vat', 'settlement']),
ratio: z.number().min(0).max(10).optional(),
vat_rate: z.number().min(0).max(1).optional(),
})
const CreateBookingTemplateSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).default(''),
category: z.enum([
'eu_trade', 'tax_account', 'private_transfer',
'salary', 'representation', 'year_end',
'vat', 'financial', 'other',
]).default('other'),
entity_type: z.enum(['all', 'enskild_firma', 'aktiebolag']).default('all'),
lines: z.array(BookingTemplateLineSchema).min(2),
team_id: z.string().uuid().optional(),
})
/**
* GET /api/settings/booking-templates
* Returns all templates visible to the current user:
* system + company + team templates.
*
* Ordering: most recently used (per current company) first, then by category
* and name for never-used templates. Usage is tracked in
* booking_template_usage via POST /[id]/touch.
*/
export async function GET() {
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)
// Resolve the team this company belongs to (if any) so team-shared
// templates stay visible while this company is selected.
const { data: company } = await supabase
.from('companies')
.select('team_id')
.eq('id', companyId)
.maybeSingle()
const teamId = company?.team_id ?? null
// requireCompanyId only ever returns a real membership UUID, but assert the
// shape before interpolating it into the .or() filter.
if (!UUID_RE.test(companyId)) {
return NextResponse.json({ error: 'Invalid company context' }, { status: 400 })
}
// Scope to the SELECTED company: system + this company + this company's team.
// RLS (btl_select) is membership-wide — it returns templates from *every*
// company the user belongs to — so the active-company narrowing must happen
// here in the API layer (mirrors counterparty-templates). Without this, a
// user who owns several companies sees all of their templates merged.
// Only interpolate a team id that passes the strict UUID guard.
const scope = [
'is_system.eq.true',
`company_id.eq.${companyId}`,
...(teamId && UUID_RE.test(teamId) ? [`team_id.eq.${teamId}`] : []),
].join(',')
const [templatesRes, usageRes] = await Promise.all([
supabase
.from('booking_template_library')
.select('*')
.eq('is_active', true)
.or(scope)
.order('category')
.order('name'),
supabase
.from('booking_template_usage')
.select('template_id, last_used_at')
.eq('company_id', companyId),
])
if (templatesRes.error) {
return NextResponse.json({ error: templatesRes.error.message }, { status: 500 })
}
// usage lookup failing is non-fatal — we just fall back to default ordering
const usageByTemplate = new Map<string, string>()
if (!usageRes.error && usageRes.data) {
for (const row of usageRes.data) {
usageByTemplate.set(row.template_id, row.last_used_at)
}
}
const templates = templatesRes.data ?? []
const decorated = templates.map((t) => ({
...t,
last_used_at: usageByTemplate.get(t.id) ?? null,
}))
// Stable-sort: templates with last_used_at come first (most-recent first).
// Templates without usage keep their category/name order from the query.
// ISO 8601 timestamps are fixed-width ASCII — plain relational comparison
// is correct and avoids any locale-dependent behaviour from localeCompare.
decorated.sort((a, b) => {
const aUsed = a.last_used_at
const bUsed = b.last_used_at
if (aUsed && bUsed) {
if (bUsed > aUsed) return -1
if (bUsed < aUsed) return 1
return 0
}
if (aUsed) return -1
if (bUsed) return 1
return 0
})
return NextResponse.json({ data: decorated })
}
/**
* POST /api/settings/booking-templates
* Create a company-scoped or team-scoped template.
*/
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 result = await validateBody(request, CreateBookingTemplateSchema)
if (!result.success) return result.response
const body = result.data
const companyId = body.team_id ? null : await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('booking_template_library')
.insert({
company_id: companyId,
team_id: body.team_id ?? null,
created_by: user.id,
name: body.name,
description: body.description,
category: body.category,
entity_type: body.entity_type,
lines: body.lines,
is_system: false,
})
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data }, { status: 201 })
}
/**
* DELETE /api/settings/booking-templates
* Soft-delete a template by id (company or team scope only, never system).
*/
export async function DELETE(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
let id: string | undefined
try {
const body = await request.json()
id = body?.id
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
// RLS prevents deleting system templates (btl_delete policy checks NOT is_system)
const { error } = await supabase
.from('booking_template_library')
.update({ is_active: false })
.eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data: { success: true } })
}