feat: add safe owner-only migration reset (#1682)

* feat: add safe company migration reset

* fix: harden company reset eligibility

* fix: close company reset compliance gaps

* test: fix migration reset pg-real probes

* fix: preserve migration archive access

* docs: explain migration numbering continuity

* fix: block reset with VAT workflow state

* fix: block externally staged reset data

* fix: address migration reset review findings

* fix: clear stale migration archive estimate

* fix: retry migration archive estimates
This commit is contained in:
Mattsson
2026-08-19 12:04:24 +02:00
committed by GitHub
parent b07a4a4bca
commit 3a1b842e4a
24 changed files with 5017 additions and 57 deletions
@@ -0,0 +1,242 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(supabase),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { GET, POST } from '../route'
const params = { params: Promise.resolve({ id: 'company-1' }) }
const validBody = {
confirm_name: 'Testbolaget AB',
reason: 'Den första migreringen fick fel periodindelning.',
confirm_no_filed_declarations: true,
confirm_retained_archive: true,
}
describe('/api/company/[id]/migration-reset', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1', email: 'owner@example.com' } },
error: null,
})
})
it('returns 401 when unauthenticated', async () => {
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
const response = await POST(
createMockRequest('/api/company/company-1/migration-reset', {
method: 'POST',
body: validBody,
}),
params,
)
expect(response.status).toBe(401)
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('returns 400 when a strong confirmation is missing', async () => {
const response = await POST(
createMockRequest('/api/company/company-1/migration-reset', {
method: 'POST',
body: { ...validBody, confirm_retained_archive: false },
}),
params,
)
expect(response.status).toBe(400)
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('returns 400 when the audit reason is too short', async () => {
const response = await POST(
createMockRequest('/api/company/company-1/migration-reset', {
method: 'POST',
body: { ...validBody, reason: 'För kort' },
}),
params,
)
expect(response.status).toBe(400)
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('returns 404 when the URL is not the active company', async () => {
const response = await POST(
createMockRequest('/api/company/company-2/migration-reset', {
method: 'POST',
body: validBody,
}),
{ params: Promise.resolve({ id: 'company-2' }) },
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('COMPANY_RESET_NOT_FOUND')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('returns 403 when the eligibility RPC rejects a non-owner', async () => {
enqueue({ data: { ok: false, code: 'COMPANY_RESET_FORBIDDEN' }, error: null })
const response = await GET(
createMockRequest('/api/company/company-1/migration-reset'),
params,
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(403)
expect(body.error.code).toBe('COMPANY_RESET_FORBIDDEN')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
})
it('returns the owner eligibility preview', async () => {
enqueue({
data: {
ok: true,
eligibility: {
eligible: true,
display_name: 'Testbolaget AB',
counts: { journal_entries: 0, documents: 2, voucher_sequences: 0 },
blockers: [],
},
},
error: null,
})
const response = await GET(
createMockRequest('/api/company/company-1/migration-reset'),
params,
)
const { status, body } = await parseJsonResponse<{
data: { eligible: boolean; counts: { documents: number } }
}>(response)
expect(status).toBe(200)
expect(body.data.eligible).toBe(true)
expect(body.data.counts.documents).toBe(2)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(supabase.rpc).toHaveBeenCalledWith(
'get_company_migration_reset_eligibility',
{ p_company_id: 'company-1' },
)
})
it('returns 409 with current blockers when execution is ineligible', async () => {
enqueue({
data: {
ok: false,
code: 'COMPANY_RESET_INELIGIBLE',
details: {
eligible: false,
blockers: [{ code: 'authority_submission_detected', count: 1 }],
},
},
error: null,
})
const response = await POST(
createMockRequest('/api/company/company-1/migration-reset', {
method: 'POST',
body: validBody,
}),
params,
)
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { blockers: Array<{ code: string }> } }
}>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('COMPANY_RESET_INELIGIBLE')
expect(body.error.details.blockers[0].code).toBe('authority_submission_detected')
})
it('returns the replacement and switches the active-company cookie', async () => {
enqueue({
data: {
ok: true,
reset_id: 'reset-1',
source_company_id: 'company-1',
replacement_company_id: 'company-new',
archived_at: '2026-08-18T09:00:00.000Z',
counts: { journal_entries: 0, documents: 2 },
},
error: null,
})
const response = await POST(
createMockRequest('/api/company/company-1/migration-reset', {
method: 'POST',
body: validBody,
}),
params,
)
const { status, body } = await parseJsonResponse<{
data: { resetId: string; replacementCompanyId: string; retainedCounts: unknown }
}>(response)
expect(status).toBe(200)
expect(body.data).toMatchObject({
resetId: 'reset-1',
replacementCompanyId: 'company-new',
retainedCounts: { journal_entries: 0, documents: 2 },
})
expect(response.headers.get('set-cookie')).toContain('gnubok-company-id=company-new')
expect(supabase.rpc).toHaveBeenCalledWith('reset_company_for_migration', {
p_company_id: 'company-1',
p_confirmed_name: validBody.confirm_name,
p_reason: validBody.reason,
p_confirm_no_filed_declarations: true,
p_confirm_retained_archive: true,
})
})
it('returns 500 when the atomic RPC fails', async () => {
enqueue({ data: null, error: { code: 'XX000', message: 'transaction failed' } })
const response = await POST(
createMockRequest('/api/company/company-1/migration-reset', {
method: 'POST',
body: validBody,
}),
params,
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(500)
expect(body.error.code).toBe('COMPANY_RESET_FAILED')
})
it('falls back to COMPANY_RESET_FAILED for an unexpected RPC code', async () => {
enqueue({ data: { ok: false, code: 'SOME_INTERNAL_CODE' }, error: null })
const response = await POST(
createMockRequest('/api/company/company-1/migration-reset', {
method: 'POST',
body: validBody,
}),
params,
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(500)
expect(body.error.code).toBe('COMPANY_RESET_FAILED')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
})
})
@@ -0,0 +1,278 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const {
supabase: archiveSupabase,
enqueue: enqueueArchive,
reset: resetArchive,
calls: archiveCalls,
} = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(supabase),
createServiceClient: () => archiveSupabase,
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/reports/full-archive-export', () => ({
estimateArchiveSize: vi.fn(),
generateBaseDataArchive: vi.fn(),
}))
import {
estimateArchiveSize,
generateBaseDataArchive,
} from '@/lib/reports/full-archive-export'
import { GET } from '../route'
const mockEstimate = vi.mocked(estimateArchiveSize)
const mockGenerate = vi.mocked(generateBaseDataArchive)
const params = { params: Promise.resolve({ id: 'company-1' }) }
function enqueueAuthorizedArchive() {
enqueue({ data: { role: 'owner' }, error: null })
enqueue({
data: { source_company_id: 'source-1', created_at: '2026-08-18T14:00:00.000Z' },
error: null,
})
enqueueArchive({
data: { source_company_id: 'source-1', created_at: '2026-08-18T14:00:00.000Z' },
error: null,
})
enqueueArchive({ data: { role: 'owner' }, error: null })
enqueueArchive({ data: { archived_at: '2026-08-18T14:00:00.000Z' }, error: null })
}
describe('GET /api/company/[id]/migration-reset/archive', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
resetArchive()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1', email: 'owner@example.com' } },
error: null,
})
mockEstimate.mockResolvedValue({
total_bytes: 10_000_000,
document_bytes: 1_000_000,
document_count: 2,
})
})
it('returns 401 when unauthenticated', async () => {
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
const response = await GET(
createMockRequest('/api/company/company-1/migration-reset/archive'),
params,
)
expect(response.status).toBe(401)
expect(mockEstimate).not.toHaveBeenCalled()
})
it('returns 404 when the URL is not the active replacement company', async () => {
const response = await GET(
createMockRequest('/api/company/company-2/migration-reset/archive'),
{ params: Promise.resolve({ id: 'company-2' }) },
)
expect(response.status).toBe(404)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(mockEstimate).not.toHaveBeenCalled()
})
it('returns 403 to a non-owner replacement member', async () => {
enqueue({ data: { role: 'admin' }, error: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await GET(createMockRequest('/api/company/company-1/migration-reset/archive'), params),
)
expect(status).toBe(403)
expect(body.error.code).toBe('COMPANY_RESET_FORBIDDEN')
expect(mockEstimate).not.toHaveBeenCalled()
})
it('returns 404 when the active company has no retained reset source', async () => {
enqueue({ data: { role: 'owner' }, error: null })
enqueue({ data: null, error: null })
const response = await GET(
createMockRequest('/api/company/company-1/migration-reset/archive'),
params,
)
expect(response.status).toBe(404)
expect(mockEstimate).not.toHaveBeenCalled()
})
it('returns retained-source metadata and size for an owner', async () => {
enqueueAuthorizedArchive()
const response = await GET(
createMockRequest('/api/company/company-1/migration-reset/archive', {
searchParams: { estimate: '1' },
}),
params,
)
const { status, body } = await parseJsonResponse<{
data: { archived_at: string; document_count: number; within_limit: boolean }
}>(response)
expect(status).toBe(200)
expect(body.data).toMatchObject({
archived_at: '2026-08-18T14:00:00.000Z',
document_count: 2,
within_limit: true,
})
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(mockEstimate).toHaveBeenCalledWith(archiveSupabase, 'source-1', 'all')
expect(mockGenerate).not.toHaveBeenCalled()
})
it('downloads the retained source without changing the active company', async () => {
enqueueAuthorizedArchive()
mockGenerate.mockResolvedValue(new ArrayBuffer(1024))
const response = await GET(
createMockRequest('/api/company/company-1/migration-reset/archive'),
params,
)
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('application/zip')
expect(response.headers.get('Content-Disposition')).toMatch(
/^attachment; filename="migration_reset_archive_\d{8}\.zip"$/,
)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(mockGenerate).toHaveBeenCalledWith(archiveSupabase, 'source-1', {
include_documents: true,
})
})
it('requires an explicit document-free download when the ZIP is over limit', async () => {
enqueueAuthorizedArchive()
mockEstimate.mockResolvedValue({
total_bytes: 100 * 1024 * 1024,
document_bytes: 92 * 1024 * 1024,
document_count: 10,
})
const response = await GET(
createMockRequest('/api/company/company-1/migration-reset/archive'),
params,
)
expect(response.status).toBe(413)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(mockGenerate).not.toHaveBeenCalled()
})
it('downloads without documents when the planned payload is within the limit', async () => {
enqueueAuthorizedArchive()
mockEstimate.mockResolvedValue({
total_bytes: 100 * 1024 * 1024,
document_bytes: 92 * 1024 * 1024,
document_count: 10,
})
mockGenerate.mockResolvedValue(new ArrayBuffer(1024))
const response = await GET(
createMockRequest('/api/company/company-1/migration-reset/archive', {
searchParams: { include_documents: 'false' },
}),
params,
)
expect(response.status).toBe(200)
expect(mockGenerate).toHaveBeenCalledWith(archiveSupabase, 'source-1', {
include_documents: false,
})
})
it('blocks a document-free download when its planned payload is still over the limit', async () => {
enqueueAuthorizedArchive()
mockEstimate.mockResolvedValue({
total_bytes: 100 * 1024 * 1024,
document_bytes: 10 * 1024 * 1024,
document_count: 10,
})
const response = await GET(
createMockRequest('/api/company/company-1/migration-reset/archive', {
searchParams: { include_documents: 'false' },
}),
params,
)
const { status, body } = await parseJsonResponse<{
size_bytes: number
size_limit_bytes: number
}>(response)
expect(status).toBe(413)
expect(body.size_bytes).toBe(90 * 1024 * 1024)
expect(body.size_limit_bytes).toBe(80 * 1024 * 1024)
expect(mockGenerate).not.toHaveBeenCalled()
})
it('keeps the archive reachable when retained-source membership changes', async () => {
enqueueAuthorizedArchive()
const response = await GET(
createMockRequest('/api/company/company-1/migration-reset/archive', {
searchParams: { estimate: '1' },
}),
params,
)
expect(response.status).toBe(200)
expect(mockEstimate).toHaveBeenCalledWith(archiveSupabase, 'source-1', 'all')
expect(archiveCalls).not.toContainEqual({
table: 'company_members',
method: 'eq',
args: ['company_id', 'source-1'],
})
})
it('fails closed when the retained source is not archived', async () => {
enqueue({ data: { role: 'owner' }, error: null })
enqueue({
data: { source_company_id: 'source-1', created_at: '2026-08-18T14:00:00.000Z' },
error: null,
})
enqueueArchive({
data: { source_company_id: 'source-1', created_at: '2026-08-18T14:00:00.000Z' },
error: null,
})
enqueueArchive({ data: { role: 'owner' }, error: null })
enqueueArchive({ data: { archived_at: null }, error: null })
const response = await GET(
createMockRequest('/api/company/company-1/migration-reset/archive'),
params,
)
expect(response.status).toBe(403)
expect(mockEstimate).not.toHaveBeenCalled()
})
it('returns 500 when archive generation fails', async () => {
enqueueAuthorizedArchive()
mockGenerate.mockRejectedValue(new Error('storage unavailable'))
const response = await GET(
createMockRequest('/api/company/company-1/migration-reset/archive'),
params,
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(500)
expect(body.error.code).toBe('COMPANY_RESET_FAILED')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
})
})
@@ -0,0 +1,199 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import {
estimateArchiveSize,
generateBaseDataArchive,
} from '@/lib/reports/full-archive-export'
import { createServiceClient } from '@/lib/supabase/server'
export const runtime = 'nodejs'
export const maxDuration = 300
const SIZE_LIMIT_BYTES = 80 * 1024 * 1024
type Params = { params: Promise<{ id: string }> }
interface ResetArchiveRow {
source_company_id: string
created_at: string
}
function privateNoStore(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'private, no-store')
return response
}
/**
* GET /api/company/[id]/migration-reset/archive
*
* Gives the replacement-company owner a read-only ZIP of the retained source.
* The archived source never becomes active and no source row is modified.
*/
export const GET = withRouteContext<Params>(
'company.migration-reset.archive',
async (request, { supabase, companyId, user, log, requestId }, { params }) => {
const { id } = await params
if (id !== companyId) {
return privateNoStore(errorResponseFromCode('COMPANY_RESET_NOT_FOUND', log, { requestId }))
}
const { data: membership, error: membershipError } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (membershipError) {
log.error('failed to authorize migration reset archive', membershipError)
return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId }))
}
if (membership?.role !== 'owner') {
return privateNoStore(errorResponseFromCode('COMPANY_RESET_FORBIDDEN', log, { requestId }))
}
const { data: visibleReset, error: visibleResetError } = await supabase
.from('company_migration_resets')
.select('source_company_id, created_at')
.eq('replacement_company_id', companyId)
.maybeSingle()
if (visibleResetError) {
log.error('failed to find migration reset archive', visibleResetError)
return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId }))
}
if (!visibleReset) {
return privateNoStore(errorResponseFromCode('COMPANY_RESET_NOT_FOUND', log, { requestId }))
}
// Service credentials are required for a complete statutory export, but
// authorization is repeated before they are used. The immutable reset row
// must still link this active replacement to an archived source. Access is
// based on current ownership of the replacement, not mutable membership of
// the retained source, so normal team removal or account anonymization
// cannot accidentally strand the statutory archive.
const archiveClient = createServiceClient()
const { data: verifiedReset, error: verifiedResetError } = await archiveClient
.from('company_migration_resets')
.select('source_company_id, created_at')
.eq('replacement_company_id', companyId)
.maybeSingle()
if (verifiedResetError) {
log.error('failed to verify migration reset archive link', verifiedResetError)
return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId }))
}
const reset = verifiedReset as ResetArchiveRow | null
if (!reset || reset.source_company_id !== visibleReset.source_company_id) {
log.warn('migration reset archive link verification denied', {
userId: user.id,
companyId,
})
return privateNoStore(errorResponseFromCode('COMPANY_RESET_FORBIDDEN', log, { requestId }))
}
const [{ data: replacementMembership, error: replacementMembershipError }, {
data: sourceCompany,
error: sourceCompanyError,
}] = await Promise.all([
archiveClient
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle(),
archiveClient
.from('companies')
.select('archived_at')
.eq('id', reset.source_company_id)
.maybeSingle(),
])
if (replacementMembershipError || sourceCompanyError) {
log.error(
'failed to verify retained migration source',
replacementMembershipError ?? sourceCompanyError,
)
return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId }))
}
if (replacementMembership?.role !== 'owner' || !sourceCompany?.archived_at) {
log.warn('retained migration source access denied', {
userId: user.id,
companyId,
sourceCompanyId: reset.source_company_id,
})
return privateNoStore(errorResponseFromCode('COMPANY_RESET_FORBIDDEN', log, { requestId }))
}
const { searchParams } = new URL(request.url)
const estimateOnly = searchParams.get('estimate') === '1'
const includeDocuments = searchParams.get('include_documents') !== 'false'
try {
const estimate = await estimateArchiveSize(archiveClient, reset.source_company_id, 'all')
const plannedSizeBytes = includeDocuments
? estimate.total_bytes
: Math.max(0, estimate.total_bytes - estimate.document_bytes)
if (estimateOnly) {
return privateNoStore(NextResponse.json(
{
data: {
...estimate,
archived_at: reset.created_at,
size_limit_bytes: SIZE_LIMIT_BYTES,
within_limit: plannedSizeBytes <= SIZE_LIMIT_BYTES,
},
},
))
}
if (plannedSizeBytes > SIZE_LIMIT_BYTES) {
return privateNoStore(NextResponse.json(
{
error: 'archive_too_large',
size_bytes: plannedSizeBytes,
size_limit_bytes: SIZE_LIMIT_BYTES,
},
{ status: 413 },
))
}
const zipBuffer = await generateBaseDataArchive(archiveClient, reset.source_company_id, {
include_documents: includeDocuments,
})
const filename = `migration_reset_archive_${formatDateStamp(new Date())}.zip`
log.info('migration reset source archive generated', {
userId: user.id,
companyId,
sourceCompanyId: reset.source_company_id,
includeDocuments,
filename,
sizeBytes: zipBuffer.byteLength,
})
return new NextResponse(zipBuffer, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'private, no-store',
},
})
} catch (error) {
log.error('migration reset source archive generation failed', error as Error, {
userId: user.id,
companyId,
sourceCompanyId: reset.source_company_id,
})
return privateNoStore(
errorResponseFromCode('COMPANY_RESET_FAILED', log, { requestId }),
)
}
},
)
function formatDateStamp(date: Date): string {
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const day = String(date.getUTCDate()).padStart(2, '0')
return `${year}${month}${day}`
}
@@ -0,0 +1,133 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CompanyMigrationResetSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { CompanyMigrationResetRpcResult } from '@/types'
type Params = { params: Promise<{ id: string }> }
const EXPECTED_CODES = new Set([
'COMPANY_RESET_NOT_FOUND',
'COMPANY_RESET_FORBIDDEN',
'COMPANY_RESET_INELIGIBLE',
'COMPANY_RESET_CONFIRMATION_MISMATCH',
'COMPANY_RESET_REASON_INVALID',
'COMPANY_RESET_CONFIRMATION_REQUIRED',
])
function rpcFailure(
result: CompanyMigrationResetRpcResult,
log: Parameters<typeof errorResponseFromCode>[1],
requestId: string,
) {
const code = result.code && EXPECTED_CODES.has(result.code)
? result.code
: 'COMPANY_RESET_FAILED'
return errorResponseFromCode(code, log, {
requestId,
details: result.details,
})
}
function privateNoStore(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'private, no-store')
return response
}
/**
* GET /api/company/[id]/migration-reset
*
* Returns the owner-only, fail-closed eligibility preview. The execution RPC
* rechecks every condition, so this response is informational only.
*/
export const GET = withRouteContext<Params>(
'company.migration-reset.preview',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
if (id !== companyId) {
return privateNoStore(errorResponseFromCode('COMPANY_RESET_NOT_FOUND', log, { requestId }))
}
const { data, error } = await supabase.rpc(
'get_company_migration_reset_eligibility',
{ p_company_id: companyId },
)
if (error) {
log.error('migration reset eligibility RPC failed', error)
return privateNoStore(errorResponseFromCode('COMPANY_RESET_FAILED', log, { requestId }))
}
const result = data as CompanyMigrationResetRpcResult | null
if (!result?.ok) {
return privateNoStore(rpcFailure(result ?? { ok: false }, log, requestId))
}
return privateNoStore(NextResponse.json({ data: result.eligibility }))
},
)
/**
* POST /api/company/[id]/migration-reset
*
* Atomically archives the source company and creates a clean active company.
* No source accounting record is deleted, detached, renumbered, or copied.
*/
export const POST = withRouteContext<Params>(
'company.migration-reset.execute',
async (request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
if (id !== companyId) {
return privateNoStore(errorResponseFromCode('COMPANY_RESET_NOT_FOUND', log, { requestId }))
}
const validation = await validateBody(request, CompanyMigrationResetSchema, {
log,
operation: 'company.migration-reset.execute',
})
if (!validation.success) return privateNoStore(validation.response)
const body = validation.data
const { data, error } = await supabase.rpc('reset_company_for_migration', {
p_company_id: companyId,
p_confirmed_name: body.confirm_name,
p_reason: body.reason,
p_confirm_no_filed_declarations: body.confirm_no_filed_declarations,
p_confirm_retained_archive: body.confirm_retained_archive,
})
if (error) {
log.error('migration reset RPC failed', error)
return privateNoStore(errorResponseFromCode('COMPANY_RESET_FAILED', log, { requestId }))
}
const result = data as CompanyMigrationResetRpcResult | null
if (!result?.ok) {
return privateNoStore(rpcFailure(result ?? { ok: false }, log, requestId))
}
if (!result.replacement_company_id) {
log.error('migration reset RPC returned no replacement company id')
return privateNoStore(errorResponseFromCode('COMPANY_RESET_FAILED', log, { requestId }))
}
const response = NextResponse.json({
data: {
resetId: result.reset_id,
sourceCompanyId: result.source_company_id,
replacementCompanyId: result.replacement_company_id,
archivedAt: result.archived_at,
retainedCounts: result.counts,
},
})
response.cookies.set('gnubok-company-id', result.replacement_company_id, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365,
})
return privateNoStore(response)
},
{ requireWrite: true },
)