After a migration the chart carries hundreds of accounts nobody ever posted to, and a short kontoplan is what keeps manual bookings off the wrong account. Inactivating them one switch at a time was the only way. - The "Verifikat" column header on Mina konton is now a filter (all / without vouchers / with vouchers), the way the verifikat list filters from its headers. "Without vouchers" means absent from get_account_usage_counts, i.e. never posted to. - Rows get a selection checkbox (rest-muted, solid on hover/checked, same class as the other list pages) with select-all in the header and a bulk bar carrying the count, Inaktivera, select-all-listed and clear. - New POST /api/bookkeeping/accounts/deactivate mirrors /activate: only never-used, non-system, active accounts flip; used accounts are skipped unless include_used is set, system accounts always, and the response says what was skipped. The client partitions the selection first so the confirm states exactly what will happen before anything posts. Closes #2186 Claude-Session: https://claude.ai/code/session_01QPQLwHNEiQfiCNLSMzXMiQ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
759 lines
29 KiB
TypeScript
759 lines
29 KiB
TypeScript
/**
|
|
* Tests for /api/bookkeeping/accounts (list/create), /[number] (update/delete)
|
|
* /activate and /deactivate.
|
|
*
|
|
* The DELETE usage check is asserted with a call-capturing mock: the count
|
|
* query must be scoped to the caller's company via the journal_entries join —
|
|
* without it, another company's use of the same BAS number (same user,
|
|
* multiple memberships under RLS) wrongly blocks deletion.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { NextResponse } from 'next/server'
|
|
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
|
|
|
const requireAuthMock = vi.fn()
|
|
vi.mock('@/lib/auth/require-auth', () => ({
|
|
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
}))
|
|
|
|
const requireWriteMock = vi.fn()
|
|
vi.mock('@/lib/auth/require-write', () => ({
|
|
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
|
}))
|
|
|
|
import { GET as listGET, POST as createPOST } from '../route'
|
|
import { DELETE, PUT } from '../[number]/route'
|
|
import { POST as activatePOST } from '../activate/route'
|
|
import { POST as deactivatePOST } from '../deactivate/route'
|
|
|
|
interface CapturedCall {
|
|
method: string
|
|
args: unknown[]
|
|
}
|
|
|
|
/** Chainable builder recording calls; resolves queued {data,error,count} per from()/rpc(). */
|
|
function createCapturingSupabase(
|
|
results: { data?: unknown; error?: unknown; count?: number | null }[]
|
|
) {
|
|
const calls: CapturedCall[] = []
|
|
let idx = 0
|
|
const makeBuilder = () => {
|
|
const result = results[idx++] ?? { data: null, error: null, count: null }
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const b: any = {}
|
|
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'is', 'order', 'limit', 'range', 'insert', 'update', 'delete', 'maybeSingle', 'single']) {
|
|
b[m] = (...args: unknown[]) => {
|
|
calls.push({ method: m, args })
|
|
return b
|
|
}
|
|
}
|
|
b.then = (resolve: (v: unknown) => void) =>
|
|
resolve({ data: result.data ?? null, error: result.error ?? null, count: result.count ?? null })
|
|
return b
|
|
}
|
|
const supabase = {
|
|
from: (table: string) => {
|
|
calls.push({ method: 'from', args: [table] })
|
|
return makeBuilder()
|
|
},
|
|
rpc: (...args: unknown[]) => {
|
|
calls.push({ method: 'rpc', args })
|
|
const result = results[idx++] ?? { data: null, error: null, count: null }
|
|
return Promise.resolve({ data: result.data ?? null, error: result.error ?? null })
|
|
},
|
|
}
|
|
return { supabase, calls }
|
|
}
|
|
|
|
const routeParams = { params: Promise.resolve({}) }
|
|
const numberParams = { params: Promise.resolve({ number: '5010' }) }
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
requireWriteMock.mockResolvedValue({ ok: true })
|
|
})
|
|
|
|
function auth(supabase: unknown) {
|
|
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
|
}
|
|
|
|
describe('GET /api/bookkeeping/accounts', () => {
|
|
it('returns 401 when not authenticated', async () => {
|
|
requireAuthMock.mockResolvedValue({
|
|
user: null,
|
|
supabase: {},
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
const res = await listGET(createMockRequest('/api/bookkeeping/accounts'), routeParams)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('returns 400 for a non-numeric class filter', async () => {
|
|
const { supabase } = createCapturingSupabase([])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts', { searchParams: { class: 'abc' } })
|
|
const { status } = await parseJsonResponse(await listGET(req, routeParams))
|
|
expect(status).toBe(400)
|
|
})
|
|
|
|
it('lists accounts via the single-round-trip RPC', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }] },
|
|
])
|
|
auth(supabase)
|
|
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
|
|
await listGET(createMockRequest('/api/bookkeeping/accounts'), routeParams)
|
|
)
|
|
expect(status).toBe(200)
|
|
expect(body.data).toHaveLength(1)
|
|
const rpcCall = calls.find((c) => c.method === 'rpc')
|
|
expect(rpcCall?.args).toEqual([
|
|
'list_company_accounts',
|
|
{ p_company_id: 'company-1', p_active_only: true, p_account_class: null },
|
|
])
|
|
// The RPC path must not also hit the table: exactly one round trip.
|
|
expect(calls.some((c) => c.method === 'from')).toBe(false)
|
|
})
|
|
|
|
it('maps ?class=3&active=false onto the RPC arguments', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([{ data: [] }])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts', {
|
|
searchParams: { class: '3', active: 'false' },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
|
|
await listGET(req, routeParams)
|
|
)
|
|
expect(status).toBe(200)
|
|
expect(body.data).toEqual([])
|
|
const rpcCall = calls.find((c) => c.method === 'rpc')
|
|
expect(rpcCall?.args[1]).toEqual({
|
|
p_company_id: 'company-1',
|
|
p_active_only: false,
|
|
p_account_class: 3,
|
|
})
|
|
})
|
|
|
|
it('falls back to the paged fetch when the RPC is not deployed (PGRST202)', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ error: { code: 'PGRST202', message: 'function not found in schema cache' } },
|
|
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }] },
|
|
])
|
|
auth(supabase)
|
|
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
|
|
await listGET(createMockRequest('/api/bookkeeping/accounts'), routeParams)
|
|
)
|
|
expect(status).toBe(200)
|
|
expect(body.data).toHaveLength(1)
|
|
expect(calls.filter((c) => c.method === 'from').map((c) => c.args)).toContainEqual([
|
|
'chart_of_accounts',
|
|
])
|
|
expect(calls.filter((c) => c.method === 'eq').map((c) => c.args)).toContainEqual([
|
|
'company_id',
|
|
'company-1',
|
|
])
|
|
})
|
|
|
|
it('returns the legacy 500 { error: string } on a non-fallback RPC error', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ error: { code: 'XX000', message: 'boom' } },
|
|
])
|
|
auth(supabase)
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(
|
|
await listGET(createMockRequest('/api/bookkeeping/accounts'), routeParams)
|
|
)
|
|
expect(status).toBe(500)
|
|
expect(typeof body.error).toBe('string')
|
|
// A non-deploy error must NOT silently fall back to the paged fetch.
|
|
expect(calls.some((c) => c.method === 'from')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('POST /api/bookkeeping/accounts', () => {
|
|
it('returns 409 with a Swedish message on duplicate account number', async () => {
|
|
const { supabase } = createCapturingSupabase([{ error: { code: '23505', message: 'dup' } }])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts', {
|
|
method: 'POST',
|
|
body: {
|
|
account_number: '5010',
|
|
account_name: 'Lokalhyra',
|
|
account_type: 'expense',
|
|
normal_balance: 'debit',
|
|
},
|
|
})
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(
|
|
await createPOST(req, routeParams)
|
|
)
|
|
expect(status).toBe(409)
|
|
expect(body.error).toContain('5010')
|
|
})
|
|
|
|
it('keeps the plain 409 when the colliding account is active', async () => {
|
|
const { supabase } = createCapturingSupabase([
|
|
{ error: { code: '23505', message: 'dup' } },
|
|
{ data: { is_active: true } },
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts', {
|
|
method: 'POST',
|
|
body: {
|
|
account_number: '5010',
|
|
account_name: 'Lokalhyra',
|
|
account_type: 'expense',
|
|
normal_balance: 'debit',
|
|
},
|
|
})
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(
|
|
await createPOST(req, routeParams)
|
|
)
|
|
expect(status).toBe(409)
|
|
expect(typeof body.error).toBe('string')
|
|
})
|
|
|
|
it('returns ACCOUNT_EXISTS_INACTIVE when the colliding account is deactivated', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ error: { code: '23505', message: 'dup' } },
|
|
{ data: { is_active: false } },
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts', {
|
|
method: 'POST',
|
|
body: {
|
|
account_number: '3910',
|
|
account_name: 'Hyresintäkter egen',
|
|
account_type: 'revenue',
|
|
normal_balance: 'credit',
|
|
},
|
|
})
|
|
const { status, body } = await parseJsonResponse<{
|
|
error: { code: string; message: string; details?: { account_number?: string } }
|
|
}>(await createPOST(req, routeParams))
|
|
|
|
expect(status).toBe(409)
|
|
expect(body.error.code).toBe('ACCOUNT_EXISTS_INACTIVE')
|
|
expect(body.error.message).toContain('3910')
|
|
expect(body.error.details?.account_number).toBe('3910')
|
|
// The is_active lookup must be company-scoped, not a bare account_number
|
|
// match: the same number exists under every other company too.
|
|
const eqArgs = calls.filter((c) => c.method === 'eq').map((c) => c.args)
|
|
expect(eqArgs).toContainEqual(['company_id', 'company-1'])
|
|
expect(eqArgs).toContainEqual(['account_number', '3910'])
|
|
})
|
|
|
|
it('forwards default_vat_rate into the insert', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ data: { account_number: '3740', default_vat_rate: 0 } },
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts', {
|
|
method: 'POST',
|
|
body: {
|
|
account_number: '3740',
|
|
account_name: 'Öres- och kronutjämning',
|
|
account_type: 'revenue',
|
|
normal_balance: 'debit',
|
|
default_vat_rate: 0,
|
|
},
|
|
})
|
|
const { status } = await parseJsonResponse(await createPOST(req, routeParams))
|
|
expect(status).toBe(200)
|
|
const insertArg = calls.find((c) => c.method === 'insert')?.args[0] as {
|
|
default_vat_rate?: number | null
|
|
}
|
|
expect(insertArg?.default_vat_rate).toBe(0)
|
|
})
|
|
|
|
it('forwards default_vat_treatment into the insert', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([{
|
|
data: { account_number: '4056', default_vat_treatment: 'reverse_charge_eu_goods' },
|
|
}])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts', {
|
|
method: 'POST',
|
|
body: {
|
|
account_number: '4056',
|
|
account_name: 'Inköp varor EU',
|
|
account_type: 'expense',
|
|
normal_balance: 'debit',
|
|
default_vat_treatment: 'reverse_charge_eu_goods',
|
|
},
|
|
})
|
|
expect((await createPOST(req, routeParams)).status).toBe(200)
|
|
const insertArg = calls.find((c) => c.method === 'insert')?.args[0] as {
|
|
default_vat_treatment?: string | null
|
|
}
|
|
expect(insertArg.default_vat_treatment).toBe('reverse_charge_eu_goods')
|
|
})
|
|
|
|
it('derives the booking rate when a treatment is set without one', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([{ data: { account_number: '4056' } }])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts', {
|
|
method: 'POST',
|
|
body: {
|
|
account_number: '4056',
|
|
account_name: 'Inköp varor EU',
|
|
account_type: 'expense',
|
|
normal_balance: 'debit',
|
|
default_vat_treatment: 'reverse_charge_eu_goods',
|
|
},
|
|
})
|
|
expect((await createPOST(req, routeParams)).status).toBe(200)
|
|
const insertArg = calls.find((c) => c.method === 'insert')?.args[0] as {
|
|
default_vat_rate?: number | null
|
|
}
|
|
expect(insertArg.default_vat_rate).toBe(0.25)
|
|
})
|
|
|
|
it('rejects a treatment that cannot apply to the account class', async () => {
|
|
const { supabase } = createCapturingSupabase([])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts', {
|
|
method: 'POST',
|
|
body: {
|
|
account_number: '4056',
|
|
account_name: 'Inköp varor EU',
|
|
account_type: 'expense',
|
|
normal_balance: 'debit',
|
|
default_vat_treatment: 'standard_25',
|
|
},
|
|
})
|
|
expect((await createPOST(req, routeParams)).status).toBe(400)
|
|
})
|
|
})
|
|
|
|
describe('DELETE /api/bookkeeping/accounts/[number]', () => {
|
|
it('scopes the usage check to the company via the usage-counts RPC', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ data: { id: 'acc-1', is_system_account: false } }, // account fetch
|
|
{ data: [{ account_number: '4010', usage_count: 7 }] }, // usage counts (not 5010)
|
|
{ data: null }, // delete
|
|
])
|
|
auth(supabase)
|
|
|
|
const { status } = await parseJsonResponse(
|
|
await DELETE(createMockRequest('/api/bookkeeping/accounts/5010'), numberParams)
|
|
)
|
|
|
|
expect(status).toBe(200)
|
|
// The company scope is an RPC argument now, not a filter on an embed:
|
|
// another company's use of the same BAS number can never be counted here.
|
|
const rpcCalls = calls.filter((c) => c.method === 'rpc').map((c) => c.args)
|
|
expect(rpcCalls).toContainEqual(['get_account_usage_counts', { p_company_id: 'company-1' }])
|
|
const selectArgs = calls.filter((c) => c.method === 'select').map((c) => c.args[0])
|
|
expect(selectArgs).not.toContain('id, journal_entries!inner(company_id)')
|
|
})
|
|
|
|
it('refuses deleting an account used in this company with 400', async () => {
|
|
const { supabase } = createCapturingSupabase([
|
|
{ data: { id: 'acc-1', is_system_account: false } },
|
|
{ data: [{ account_number: '5010', usage_count: 3 }] },
|
|
])
|
|
auth(supabase)
|
|
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(
|
|
await DELETE(createMockRequest('/api/bookkeeping/accounts/5010'), numberParams)
|
|
)
|
|
expect(status).toBe(400)
|
|
expect(body.error).toContain('Inaktivera')
|
|
})
|
|
|
|
it('refuses deleting a system account', async () => {
|
|
const { supabase } = createCapturingSupabase([
|
|
{ data: { id: 'acc-1', is_system_account: true } },
|
|
])
|
|
auth(supabase)
|
|
|
|
const { status } = await parseJsonResponse(
|
|
await DELETE(createMockRequest('/api/bookkeeping/accounts/5010'), numberParams)
|
|
)
|
|
expect(status).toBe(400)
|
|
})
|
|
})
|
|
|
|
describe('PUT /api/bookkeeping/accounts/[number]', () => {
|
|
it('returns 400 when the body has nothing to update', async () => {
|
|
const { supabase } = createCapturingSupabase([])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/5010', { method: 'PUT', body: {} })
|
|
const { status } = await parseJsonResponse(await PUT(req, numberParams))
|
|
expect(status).toBe(400)
|
|
})
|
|
|
|
it('maps zero-rows (PGRST116) to 404', async () => {
|
|
const { supabase } = createCapturingSupabase([
|
|
{ error: { code: 'PGRST116', message: 'no rows' } },
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
|
|
method: 'PUT',
|
|
body: { account_name: 'Nytt namn' },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(await PUT(req, numberParams))
|
|
expect(status).toBe(404)
|
|
expect(body.error).toBe('Kontot hittades inte')
|
|
})
|
|
|
|
it('updates the account', async () => {
|
|
const { supabase } = createCapturingSupabase([
|
|
{ data: { account_number: '5010', account_name: 'Nytt namn' } },
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
|
|
method: 'PUT',
|
|
body: { account_name: 'Nytt namn' },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{ data: { account_name: string } }>(
|
|
await PUT(req, numberParams)
|
|
)
|
|
expect(status).toBe(200)
|
|
expect(body.data.account_name).toBe('Nytt namn')
|
|
})
|
|
|
|
it('forwards default_vat_rate into the update', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ data: { account_number: '3740', default_vat_rate: 0 } },
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/3740', {
|
|
method: 'PUT',
|
|
body: { default_vat_rate: 0 },
|
|
})
|
|
const { status } = await parseJsonResponse(
|
|
await PUT(req, { params: Promise.resolve({ number: '3740' }) })
|
|
)
|
|
expect(status).toBe(200)
|
|
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as {
|
|
default_vat_rate?: number | null
|
|
}
|
|
expect(updateArg?.default_vat_rate).toBe(0)
|
|
})
|
|
|
|
it('preserves an existing booking rate when updating only the treatment', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ data: { default_vat_rate: 0.12 } },
|
|
{ data: { account_number: '3041', default_vat_treatment: 'standard_25' } },
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/3041', {
|
|
method: 'PUT',
|
|
body: { default_vat_treatment: 'standard_25' },
|
|
})
|
|
expect((await PUT(req, { params: Promise.resolve({ number: '3041' }) })).status).toBe(200)
|
|
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as {
|
|
default_vat_treatment?: string | null
|
|
default_vat_rate?: number | null
|
|
}
|
|
expect(updateArg.default_vat_treatment).toBe('standard_25')
|
|
expect(updateArg.default_vat_rate).toBeUndefined()
|
|
})
|
|
|
|
it('derives the booking rate when treatment is updated and the stored rate is unset', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ data: { default_vat_rate: null } },
|
|
{
|
|
data: {
|
|
account_number: '4056',
|
|
default_vat_treatment: 'reverse_charge_eu_goods',
|
|
default_vat_rate: 0.25,
|
|
},
|
|
},
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/4056', {
|
|
method: 'PUT',
|
|
body: { default_vat_treatment: 'reverse_charge_eu_goods' },
|
|
})
|
|
|
|
expect((await PUT(req, { params: Promise.resolve({ number: '4056' }) })).status).toBe(200)
|
|
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as {
|
|
default_vat_rate?: number | null
|
|
}
|
|
expect(updateArg.default_vat_rate).toBe(0.25)
|
|
})
|
|
|
|
// The body is spread straight into .update(), so the write set must be
|
|
// exactly what the caller named. UpdateAccountSchema carries no .default()
|
|
// today; these two lock the property in so adding one cannot turn a rename
|
|
// into a silent rewrite of the VAT code and SRU mapping.
|
|
it('writes only the field the caller named', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ data: { account_number: '5010', account_name: 'Nytt namn' } },
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
|
|
method: 'PUT',
|
|
body: { account_name: 'Nytt namn' },
|
|
})
|
|
expect((await parseJsonResponse(await PUT(req, numberParams))).status).toBe(200)
|
|
|
|
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as Record<string, unknown>
|
|
expect(Object.keys(updateArg)).toEqual(['account_name'])
|
|
})
|
|
|
|
it('keeps an explicit null so sru_code can be cleared', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ data: { account_number: '5010', sru_code: null } },
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
|
|
method: 'PUT',
|
|
body: { sru_code: null },
|
|
})
|
|
expect((await parseJsonResponse(await PUT(req, numberParams))).status).toBe(200)
|
|
|
|
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as Record<string, unknown>
|
|
expect(updateArg).toEqual({ sru_code: null })
|
|
})
|
|
|
|
it('drops unknown keys instead of forwarding them to the update', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ data: { account_number: '5010' } },
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/5010', {
|
|
method: 'PUT',
|
|
body: { account_name: 'Nytt namn', is_system_account: true, company_id: 'other' },
|
|
})
|
|
expect((await parseJsonResponse(await PUT(req, numberParams))).status).toBe(200)
|
|
|
|
const updateArg = calls.find((c) => c.method === 'update')?.args[0] as Record<string, unknown>
|
|
expect(Object.keys(updateArg)).toEqual(['account_name'])
|
|
})
|
|
})
|
|
|
|
describe('POST /api/bookkeeping/accounts/activate', () => {
|
|
it('returns 400 (not a crash) on invalid JSON', async () => {
|
|
const { supabase } = createCapturingSupabase([])
|
|
auth(supabase)
|
|
const req = new Request('http://localhost/api/bookkeeping/accounts/activate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: '{not json',
|
|
})
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(
|
|
await activatePOST(req, routeParams)
|
|
)
|
|
expect(status).toBe(400)
|
|
expect(body.error).toBe('account_numbers array required')
|
|
})
|
|
|
|
it('returns 400 when account_numbers is missing or empty', async () => {
|
|
const { supabase } = createCapturingSupabase([])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/activate', {
|
|
method: 'POST',
|
|
body: { account_numbers: [] },
|
|
})
|
|
const { status } = await parseJsonResponse(await activatePOST(req, routeParams))
|
|
expect(status).toBe(400)
|
|
})
|
|
|
|
it('activates a known BAS account and buckets unknown numbers', async () => {
|
|
const { supabase } = createCapturingSupabase([
|
|
{ data: [] }, // existing lookup — none in chart
|
|
{ data: [{ account_number: '1930' }] }, // insert result
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/activate', {
|
|
method: 'POST',
|
|
body: { account_numbers: ['1930', '0000'] },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{
|
|
activated: number
|
|
unknown: string[]
|
|
}>(await activatePOST(req, routeParams))
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.activated).toBe(1)
|
|
expect(body.unknown).toEqual(['0000'])
|
|
})
|
|
|
|
// The only route back for a deactivated account: it is in the chart, so the
|
|
// insert path would hit the unique constraint. It must be flipped back on
|
|
// instead, including for custom numbers the BAS reference has never heard of.
|
|
it('reactivates an existing inactive account instead of inserting it', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ data: [{ account_number: '3910', is_active: false }] }, // existing lookup
|
|
{ data: [{ account_number: '3910' }] }, // update result
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/activate', {
|
|
method: 'POST',
|
|
body: { account_numbers: ['3910'] },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{
|
|
activated: number
|
|
reactivated: number
|
|
skipped: number
|
|
unknown: string[]
|
|
}>(await activatePOST(req, routeParams))
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.reactivated).toBe(1)
|
|
expect(body.activated).toBe(0)
|
|
expect(body.unknown).toEqual([])
|
|
expect(calls.find((c) => c.method === 'update')?.args[0]).toEqual({ is_active: true })
|
|
expect(calls.some((c) => c.method === 'insert')).toBe(false)
|
|
})
|
|
|
|
it('skips an account that is already active', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ data: [{ account_number: '1930', is_active: true }] },
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/activate', {
|
|
method: 'POST',
|
|
body: { account_numbers: ['1930'] },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{
|
|
activated: number
|
|
reactivated: number
|
|
skipped: number
|
|
}>(await activatePOST(req, routeParams))
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.skipped).toBe(1)
|
|
expect(body.reactivated).toBe(0)
|
|
expect(calls.some((c) => c.method === 'update')).toBe(false)
|
|
expect(calls.some((c) => c.method === 'insert')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('POST /api/bookkeeping/accounts/deactivate', () => {
|
|
it('returns 401 when not authenticated', async () => {
|
|
requireAuthMock.mockResolvedValue({
|
|
user: null,
|
|
supabase: {},
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
const req = createMockRequest('/api/bookkeeping/accounts/deactivate', {
|
|
method: 'POST',
|
|
body: { account_numbers: ['4010'] },
|
|
})
|
|
const res = await deactivatePOST(req, routeParams)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('returns 400 when account_numbers is missing or empty', async () => {
|
|
const { supabase } = createCapturingSupabase([])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/deactivate', {
|
|
method: 'POST',
|
|
body: { account_numbers: [] },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(
|
|
await deactivatePOST(req, routeParams)
|
|
)
|
|
expect(status).toBe(400)
|
|
expect(body.error).toBe('account_numbers array required')
|
|
})
|
|
|
|
it('returns 403 for a viewer', async () => {
|
|
const { supabase } = createCapturingSupabase([])
|
|
auth(supabase)
|
|
requireWriteMock.mockResolvedValue({
|
|
ok: false,
|
|
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
|
})
|
|
const req = createMockRequest('/api/bookkeeping/accounts/deactivate', {
|
|
method: 'POST',
|
|
body: { account_numbers: ['4010'] },
|
|
})
|
|
const res = await deactivatePOST(req, routeParams)
|
|
expect(res.status).toBe(403)
|
|
})
|
|
|
|
// The post-migration sweep: only never-used, non-system, active accounts
|
|
// flip; everything else is reported back so the UI can say what it skipped.
|
|
it('deactivates the never-used accounts and reports what it skipped', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{
|
|
data: [
|
|
{ account_number: '4010', is_active: true, is_system_account: false }, // unused
|
|
{ account_number: '4020', is_active: true, is_system_account: false }, // unused
|
|
{ account_number: '5010', is_active: true, is_system_account: false }, // used
|
|
{ account_number: '1930', is_active: true, is_system_account: true }, // system
|
|
{ account_number: '6110', is_active: false, is_system_account: false }, // already off
|
|
],
|
|
},
|
|
{ data: [{ account_number: '5010', usage_count: 12 }] }, // get_account_usage_counts
|
|
{ data: [{ account_number: '4010' }, { account_number: '4020' }] }, // update result
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/deactivate', {
|
|
method: 'POST',
|
|
body: { account_numbers: ['4010', '4020', '5010', '1930', '6110', '9999'] },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{
|
|
deactivated: number
|
|
skipped_system: string[]
|
|
skipped_used: string[]
|
|
skipped_inactive: number
|
|
unknown: string[]
|
|
}>(await deactivatePOST(req, routeParams))
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.deactivated).toBe(2)
|
|
expect(body.skipped_used).toEqual(['5010'])
|
|
expect(body.skipped_system).toEqual(['1930'])
|
|
expect(body.skipped_inactive).toBe(1)
|
|
expect(body.unknown).toEqual(['9999'])
|
|
|
|
// Usage is read through the company-scoped RPC, never an embed.
|
|
expect(calls.find((c) => c.method === 'rpc')?.args).toEqual([
|
|
'get_account_usage_counts',
|
|
{ p_company_id: 'company-1' },
|
|
])
|
|
// The update is scoped to the company and to exactly the unused set.
|
|
const updateCall = calls.find((c) => c.method === 'update')
|
|
expect(updateCall?.args).toEqual([{ is_active: false }])
|
|
const inAfterUpdate = calls.slice(calls.indexOf(updateCall!)).find((c) => c.method === 'in')
|
|
expect(inAfterUpdate?.args).toEqual(['account_number', ['4010', '4020']])
|
|
})
|
|
|
|
it('includes used accounts only when include_used is set', async () => {
|
|
const { supabase } = createCapturingSupabase([
|
|
{ data: [{ account_number: '5010', is_active: true, is_system_account: false }] },
|
|
{ data: [{ account_number: '5010', usage_count: 3 }] },
|
|
{ data: [{ account_number: '5010' }] },
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/deactivate', {
|
|
method: 'POST',
|
|
body: { account_numbers: ['5010'], include_used: true },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{ deactivated: number; skipped_used: string[] }>(
|
|
await deactivatePOST(req, routeParams)
|
|
)
|
|
expect(status).toBe(200)
|
|
expect(body.deactivated).toBe(1)
|
|
expect(body.skipped_used).toEqual([])
|
|
})
|
|
|
|
it('skips the update entirely when nothing qualifies', async () => {
|
|
const { supabase, calls } = createCapturingSupabase([
|
|
{ data: [{ account_number: '5010', is_active: true, is_system_account: false }] },
|
|
{ data: [{ account_number: '5010', usage_count: 3 }] },
|
|
])
|
|
auth(supabase)
|
|
const req = createMockRequest('/api/bookkeeping/accounts/deactivate', {
|
|
method: 'POST',
|
|
body: { account_numbers: ['5010'] },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{ deactivated: number; skipped_used: string[] }>(
|
|
await deactivatePOST(req, routeParams)
|
|
)
|
|
expect(status).toBe(200)
|
|
expect(body.deactivated).toBe(0)
|
|
expect(body.skipped_used).toEqual(['5010'])
|
|
expect(calls.find((c) => c.method === 'update')).toBeUndefined()
|
|
})
|
|
})
|