fix(sandbox): use posting engine and recover failed seeds (#2297)
* fix(sandbox): seed through posting engine and recover failed attempts * test(sandbox): align CI auth schema for anonymous users
This commit is contained in:
@@ -195,7 +195,7 @@ describe('sandbox ledger history', () => {
|
||||
expect(otherTagged).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps every entry inside the fiscal year and on the posted, allowed shape', () => {
|
||||
it('keeps dates inside the fiscal year and leaves posting metadata to the engine', () => {
|
||||
const history = buildSandboxLedgerHistory(input)
|
||||
|
||||
for (const entry of history.entries) {
|
||||
@@ -203,8 +203,9 @@ describe('sandbox ledger history', () => {
|
||||
expect(entry.entry_date <= '2026-12-31').toBe(true)
|
||||
expect(entry.entry_date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
expect(ALLOWED_SOURCE_TYPES).toContain(entry.source_type)
|
||||
expect(entry.status).toBe('posted')
|
||||
expect(entry.committed_at).toBe(entry.entry_date)
|
||||
expect(entry).not.toHaveProperty('status')
|
||||
expect(entry).not.toHaveProperty('committed_at')
|
||||
expect(entry).not.toHaveProperty('commit_method')
|
||||
expect(entry.voucher_series).toBe('A')
|
||||
expect(entry.fiscal_period_id).toBe('fp-1')
|
||||
expect(entry.user_id).toBe('user-1')
|
||||
|
||||
@@ -3,14 +3,15 @@ import { NextResponse } from 'next/server'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireAuth: vi.fn(),
|
||||
getActiveCompanyId: vi.fn(),
|
||||
createJournalEntry: vi.fn(),
|
||||
checkRateLimit: vi.fn(),
|
||||
ensureSandboxAgentProfile: vi.fn(),
|
||||
markEntriesNoDocRequired: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: mocks.requireAuth }))
|
||||
vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: mocks.getActiveCompanyId }))
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({ createJournalEntry: mocks.createJournalEntry }))
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/auth/rate-limit-http', () => ({ checkRateLimit: mocks.checkRateLimit }))
|
||||
vi.mock('@/lib/sandbox/ensure-agent', () => ({
|
||||
ensureSandboxAgentProfile: mocks.ensureSandboxAgentProfile,
|
||||
@@ -44,61 +45,21 @@ vi.mock('../customers', () => ({
|
||||
}))
|
||||
vi.mock('../pending-operations', () => ({ buildSandboxPendingOperations: () => [] }))
|
||||
vi.mock('../articles', () => ({ buildSandboxArticles: () => [] }))
|
||||
vi.mock('../ledger-history', () => ({
|
||||
SANDBOX_LEDGER_ACCOUNT_NUMBERS: [],
|
||||
buildSandboxLedgerHistory: () => ({ entries: [], linesByEntryIndex: [] }),
|
||||
}))
|
||||
vi.mock('../salary-vouchers', () => ({
|
||||
SANDBOX_SALARY_ACCOUNT_NUMBERS: [],
|
||||
buildSandboxSalaryVouchers: () => [],
|
||||
}))
|
||||
vi.mock('../salary', () => ({
|
||||
SANDBOX_RUN_TOTALS: {
|
||||
total_gross: 0,
|
||||
total_tax: 0,
|
||||
total_net: 0,
|
||||
total_avgifter: 0,
|
||||
total_vacation_accrual: 0,
|
||||
},
|
||||
SANDBOX_TOTAL_VACATION_ACCRUAL_AVGIFTER: 0,
|
||||
buildSandboxEmployees: () => [
|
||||
{ last_name: 'Andersson' },
|
||||
{ last_name: 'Berg' },
|
||||
],
|
||||
mapSandboxEmployeeIds: (rows: Array<{ id: string }>) => ({
|
||||
annaEmployeeId: rows[0].id,
|
||||
erikEmployeeId: rows[1].id,
|
||||
}),
|
||||
buildSandboxSalaryRuns: () => [
|
||||
{ status: 'booked' },
|
||||
{ status: 'draft' },
|
||||
],
|
||||
buildSandboxSalaryRunEmployees: ({ annaEmployeeId, erikEmployeeId }: {
|
||||
annaEmployeeId: string
|
||||
erikEmployeeId: string
|
||||
}) => [
|
||||
{ employee_id: annaEmployeeId },
|
||||
{ employee_id: erikEmployeeId },
|
||||
],
|
||||
buildSandboxSalaryLineItems: () => [],
|
||||
resolveSandboxSalaryPeriods: () => ({
|
||||
booked: { paymentDate: '2026-04-25', year: 2026, month: 4 },
|
||||
}),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
interface MockSupabaseResult {
|
||||
supabase: Record<string, unknown>
|
||||
supabase: ReturnType<typeof makeClient>
|
||||
deadlineInserts: unknown[][]
|
||||
}
|
||||
|
||||
function createMockSupabase(): MockSupabaseResult {
|
||||
function makeClient() {
|
||||
let rowId = 0
|
||||
let voucherNumber = 0
|
||||
let attempt = 0
|
||||
let state = 'new'
|
||||
const deadlineInserts: unknown[][] = []
|
||||
|
||||
const from = vi.fn((table: string) => {
|
||||
if (table === 'journal_entries' || table === 'journal_entry_lines') throw new Error('Seed must use the engine')
|
||||
let insertPayload: unknown = undefined
|
||||
const chain: Record<string, unknown> = {}
|
||||
|
||||
@@ -133,13 +94,32 @@ function createMockSupabase(): MockSupabaseResult {
|
||||
|
||||
const supabase = {
|
||||
from,
|
||||
rpc: vi.fn(async (fn: string) => ({
|
||||
data: fn === 'next_voucher_number' ? ++voucherNumber : null,
|
||||
error: null,
|
||||
})),
|
||||
rpc: vi.fn(async (fn: string, args?: { p_success?: boolean }) => {
|
||||
if (fn === 'claim_sandbox_seed') {
|
||||
if (state === 'running') return { data: { status: 'busy' }, error: null }
|
||||
if (state !== 'complete') { attempt++; state = 'running' }
|
||||
return { data: { status: state, company_id: `company-${attempt}`, attempt_id: `attempt-${attempt}` }, error: null }
|
||||
}
|
||||
if (fn === 'finish_sandbox_seed') {
|
||||
state = args?.p_success ? 'complete' : 'failed'
|
||||
return { data: true, error: null }
|
||||
}
|
||||
return { data: null, error: null }
|
||||
}),
|
||||
}
|
||||
|
||||
return { supabase, deadlineInserts }
|
||||
return Object.assign(supabase, { deadlineInserts })
|
||||
}
|
||||
|
||||
function createMockSupabase(): MockSupabaseResult {
|
||||
const supabase = makeClient()
|
||||
return { supabase, deadlineInserts: supabase.deadlineInserts }
|
||||
}
|
||||
|
||||
function authenticate(supabase: ReturnType<typeof makeClient>) {
|
||||
mocks.requireAuth.mockResolvedValue({
|
||||
error: null, user: { id: 'user-1', is_anonymous: true }, supabase,
|
||||
})
|
||||
}
|
||||
|
||||
function request(): Request {
|
||||
@@ -155,7 +135,9 @@ describe('POST /api/sandbox/seed', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 15, 12, 0, 0))
|
||||
mocks.checkRateLimit.mockResolvedValue({ ok: true })
|
||||
mocks.getActiveCompanyId.mockResolvedValue('company-1')
|
||||
mocks.createJournalEntry.mockImplementation(async (_db, companyId, _user, input) => ({
|
||||
id: `entry-${mocks.createJournalEntry.mock.calls.length}`, company_id: companyId, ...input,
|
||||
}))
|
||||
mocks.ensureSandboxAgentProfile.mockResolvedValue(undefined)
|
||||
mocks.markEntriesNoDocRequired.mockResolvedValue(undefined)
|
||||
})
|
||||
@@ -194,4 +176,91 @@ describe('POST /api/sandbox/seed', () => {
|
||||
tax_period: '2026-Q2',
|
||||
})
|
||||
})
|
||||
it('rejects registered users without claiming or writing', async () => {
|
||||
const db = makeClient()
|
||||
mocks.requireAuth.mockResolvedValue({ error: null, user: { id: 'real', is_anonymous: false }, supabase: db })
|
||||
expect((await POST(request())).status).toBe(403)
|
||||
expect(db.rpc).not.toHaveBeenCalled()
|
||||
expect(db.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('posts history, invoice and payroll through the engine without preset audit fields', async () => {
|
||||
const db = makeClient()
|
||||
authenticate(db)
|
||||
expect((await POST(request())).status).toBe(200)
|
||||
const inputs = mocks.createJournalEntry.mock.calls.map(call => call[3])
|
||||
expect(new Set(inputs.map(input => input.source_type))).toEqual(
|
||||
new Set(['manual', 'invoice_created', 'invoice_paid', 'salary_payment']),
|
||||
)
|
||||
for (const input of inputs) {
|
||||
expect(input).not.toHaveProperty('committed_at')
|
||||
expect(input).not.toHaveProperty('commit_method')
|
||||
expect(input).not.toHaveProperty('voucher_number')
|
||||
expect(input).not.toHaveProperty('status')
|
||||
expect(input.lines.length).toBeGreaterThan(1)
|
||||
}
|
||||
expect(db.rpc).toHaveBeenLastCalledWith('finish_sandbox_seed', {
|
||||
p_attempt_id: 'attempt-1', p_success: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not seed again after completion', async () => {
|
||||
const db = makeClient()
|
||||
authenticate(db)
|
||||
await POST(request())
|
||||
const count = mocks.createJournalEntry.mock.calls.length
|
||||
expect(await (await POST(request())).json()).toEqual({ seeded: false, topped_up: true })
|
||||
expect(mocks.createJournalEntry).toHaveBeenCalledTimes(count)
|
||||
})
|
||||
|
||||
it('retries a failed seed in a fresh company instead of topping up partial settings', async () => {
|
||||
const db = makeClient()
|
||||
authenticate(db)
|
||||
mocks.createJournalEntry.mockRejectedValueOnce(new Error('posting failed'))
|
||||
expect((await POST(request())).status).toBe(500)
|
||||
expect(db.rpc).toHaveBeenLastCalledWith('finish_sandbox_seed', { p_attempt_id: 'attempt-1', p_success: false })
|
||||
expect((await POST(request())).status).toBe(200)
|
||||
expect(mocks.createJournalEntry.mock.calls[0][1]).toBe('company-1')
|
||||
expect(mocks.createJournalEntry.mock.calls.at(-1)?.[1]).toBe('company-2')
|
||||
})
|
||||
|
||||
it('returns 409 to a concurrent request without releasing the first request claim', async () => {
|
||||
const db = makeClient()
|
||||
authenticate(db)
|
||||
let release!: () => void
|
||||
let entered!: () => void
|
||||
const posting = new Promise<void>(resolve => { entered = resolve })
|
||||
mocks.createJournalEntry.mockImplementationOnce(async () => {
|
||||
entered()
|
||||
await new Promise<void>(resolve => { release = resolve })
|
||||
return { id: 'first' }
|
||||
})
|
||||
const first = POST(request())
|
||||
await posting
|
||||
const second = await POST(request())
|
||||
expect(second.status).toBe(409)
|
||||
expect(second.headers.get('Retry-After')).toBe('5')
|
||||
expect(db.rpc.mock.calls.filter(call => call[0] === 'finish_sandbox_seed')).toHaveLength(0)
|
||||
release()
|
||||
expect((await first).status).toBe(200)
|
||||
})
|
||||
|
||||
it('fails closed if claim storage is unavailable', async () => {
|
||||
const db = makeClient()
|
||||
authenticate(db)
|
||||
db.rpc.mockResolvedValueOnce({ data: null, error: null })
|
||||
expect((await POST(request())).status).toBe(500)
|
||||
expect(db.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps every engine input inside the seeded period in January', async () => {
|
||||
vi.setSystemTime(new Date(2026, 0, 3, 12))
|
||||
authenticate(makeClient())
|
||||
expect((await POST(request())).status).toBe(200)
|
||||
for (const call of mocks.createJournalEntry.mock.calls) {
|
||||
expect(call[3].entry_date >= '2026-01-01').toBe(true)
|
||||
expect(call[3].entry_date <= '2026-12-31').toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -82,13 +82,14 @@ describe('buildSandboxSalaryVouchers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('stamps every entry as a posted salary_payment on the run', () => {
|
||||
it('links salary_payment to the run and leaves posting metadata to the engine', () => {
|
||||
for (const { entry } of buildSandboxSalaryVouchers({ ...BASE, ...TOTALS })) {
|
||||
expect(entry.source_type).toBe('salary_payment')
|
||||
expect(entry.source_id).toBe('run-1')
|
||||
expect(entry.status).toBe('posted')
|
||||
expect(entry).not.toHaveProperty('status')
|
||||
expect(entry.entry_date).toBe('2026-07-25')
|
||||
expect(entry.committed_at).toBe('2026-07-25')
|
||||
expect(entry).not.toHaveProperty('committed_at')
|
||||
expect(entry).not.toHaveProperty('commit_method')
|
||||
expect(entry.voucher_series).toMatch(/^[A-Z]$/)
|
||||
expect(entry.description).toContain('2026-07')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Explicit opt-in smoke test against the hosted staging branch only.
|
||||
* SANDBOX_STAGING_ENV_FILE points to its credentials file. Never reads .env.local.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'dotenv'
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const context = vi.hoisted(() => ({ auth: vi.fn() }))
|
||||
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: context.auth }))
|
||||
vi.mock('@/lib/auth/rate-limit-http', () => ({ checkRateLimit: async () => ({ ok: true }) }))
|
||||
|
||||
describe.skipIf(!process.env.SANDBOX_STAGING_ENV_FILE)('hosted staging sandbox', () => {
|
||||
let client: SupabaseClient
|
||||
let service: SupabaseClient
|
||||
let POST: typeof import('../route').POST
|
||||
beforeAll(async () => {
|
||||
const env = parse(readFileSync(process.env.SANDBOX_STAGING_ENV_FILE!, 'utf8'))
|
||||
if (new URL(env.NEXT_PUBLIC_SUPABASE_URL).hostname !== 'metjnjrhvujscngnpzdv.supabase.co') {
|
||||
throw new Error('Sandbox smoke test only permits the staging branch')
|
||||
}
|
||||
if (process.env.SANDBOX_STAGING_SERVICE_ROLE_KEY) {
|
||||
env.SUPABASE_SERVICE_ROLE_KEY = process.env.SANDBOX_STAGING_SERVICE_ROLE_KEY
|
||||
}
|
||||
// Load only after checking the target. Includes the real event handlers.
|
||||
Object.assign(process.env, env)
|
||||
POST = (await import('../route')).POST
|
||||
client = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
})
|
||||
service = createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
})
|
||||
const { error: credentialError } = await service.from('companies').select('id').limit(0)
|
||||
if (credentialError) throw new Error('A working staging service key is required for event handlers and retention verification')
|
||||
const { data, error } = await client.auth.signInAnonymously()
|
||||
if (error || !data.user) throw error ?? new Error('No anonymous staging user')
|
||||
context.auth.mockResolvedValue({ user: data.user, supabase: client, error: null })
|
||||
}, 90_000)
|
||||
|
||||
it('seeds through real posting guards, links vouchers and repeats without duplicates', async () => {
|
||||
const start = Date.now()
|
||||
// These are separate concurrent PostgREST transactions, not an in-memory lock.
|
||||
const claims = await Promise.all([client.rpc('claim_sandbox_seed'), client.rpc('claim_sandbox_seed')])
|
||||
expect(claims.every(claim => !claim.error)).toBe(true)
|
||||
expect(claims.map(claim => claim.data.status).sort()).toEqual(['busy', 'running'])
|
||||
const owner = claims.find(claim => claim.data.status === 'running')!.data
|
||||
await client.rpc('finish_sandbox_seed', { p_attempt_id: owner.attempt_id, p_success: false })
|
||||
|
||||
// Simulate an interruption AFTER real vouchers committed, before payroll.
|
||||
const { data: { user: signedIn } } = await client.auth.getUser()
|
||||
const failingClient = new Proxy(client, {
|
||||
get(target, property) {
|
||||
if (property === 'from') return (table: string) => {
|
||||
if (table === 'salary_runs') return {
|
||||
insert: () => ({ select: async () => ({ data: null, error: { message: 'Injected payroll interruption' } }) }),
|
||||
}
|
||||
return target.from(table)
|
||||
}
|
||||
return Reflect.get(target, property)
|
||||
},
|
||||
})
|
||||
context.auth.mockResolvedValueOnce({ user: signedIn, supabase: failingClient, error: null })
|
||||
expect((await POST(new Request('http://localhost/api/sandbox/seed', { method: 'POST' }))).status).toBe(500)
|
||||
const { data: failed } = await client.from('sandbox_seed_attempts').select('*').single()
|
||||
expect(failed.status).toBe('failed')
|
||||
const { count: preservedCount } = await client.from('journal_entries').select('id', { head: true, count: 'exact' })
|
||||
.eq('company_id', failed.company_id).eq('status', 'posted')
|
||||
expect(preservedCount).toBeGreaterThan(0)
|
||||
|
||||
const first = await POST(new Request('http://localhost/api/sandbox/seed', { method: 'POST' }))
|
||||
expect(await first.json()).toEqual({ seeded: true })
|
||||
expect(first.status).toBe(200)
|
||||
const { data: attempt, error } = await client.from('sandbox_seed_attempts').select('*').single()
|
||||
expect(error).toBeNull()
|
||||
expect(attempt.status).toBe('complete')
|
||||
expect(attempt.company_id).not.toBe(failed.company_id)
|
||||
const { data: archived } = await service.from('companies').select('archived_at').eq('id', failed.company_id).single()
|
||||
expect(archived!.archived_at).toBeTruthy()
|
||||
const { count: retainedCount } = await service.from('journal_entries').select('id', { head: true, count: 'exact' })
|
||||
.eq('company_id', failed.company_id).eq('status', 'posted')
|
||||
expect(retainedCount).toBe(preservedCount)
|
||||
const { data: entries, error: entriesError } = await client.from('journal_entries')
|
||||
.select('id, fiscal_period_id, status, committed_at, voucher_number, voucher_series, lines:journal_entry_lines(debit_amount,credit_amount)')
|
||||
.eq('company_id', attempt.company_id)
|
||||
expect(entriesError).toBeNull()
|
||||
expect(entries!.length).toBeGreaterThanOrEqual(5)
|
||||
const { data: { user } } = await client.auth.getUser()
|
||||
const { error: invalidDraft } = await client.from('journal_entries').insert({
|
||||
user_id: user!.id, company_id: attempt.company_id,
|
||||
fiscal_period_id: entries![0].fiscal_period_id, voucher_number: 0,
|
||||
entry_date: new Date().toISOString().slice(0, 10), description: 'Rejected test draft',
|
||||
source_type: 'manual', status: 'draft', committed_at: new Date().toISOString(),
|
||||
})
|
||||
expect(invalidDraft?.code).toBe('42501')
|
||||
for (const entry of entries!) {
|
||||
expect(entry.status).toBe('posted')
|
||||
expect(entry.committed_at).toBeTruthy()
|
||||
expect(entry.voucher_number).toBeGreaterThan(0)
|
||||
expect(Math.round(entry.lines.reduce((sum, line) => sum + line.debit_amount - line.credit_amount, 0) * 100)).toBe(0)
|
||||
}
|
||||
const { data: runs } = await client.from('salary_runs').select('salary_entry_id,avgifter_entry_id,vacation_entry_id')
|
||||
.eq('company_id', attempt.company_id).eq('status', 'booked')
|
||||
expect(runs).toHaveLength(1)
|
||||
expect(Object.values(runs![0]).every(id => entries!.some(entry => entry.id === id))).toBe(true)
|
||||
const { data: invoice } = await client.from('invoices').select('journal_entry_id')
|
||||
.eq('company_id', attempt.company_id).eq('invoice_number', 'F-2026001').single()
|
||||
expect(entries!.some(entry => entry.id === invoice!.journal_entry_id)).toBe(true)
|
||||
const second = await POST(new Request('http://localhost/api/sandbox/seed', { method: 'POST' }))
|
||||
expect(second.status).toBe(200)
|
||||
expect(await second.json()).toEqual({ seeded: false, topped_up: true })
|
||||
const { count } = await client.from('journal_entries').select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', attempt.company_id)
|
||||
expect(count).toBe(entries!.length)
|
||||
// A complete pre-migration demo has no attempt row. Adopt it without reseeding.
|
||||
const { error: removeMarker } = await service.from('sandbox_seed_attempts').delete().eq('user_id', user!.id)
|
||||
expect(removeMarker).toBeNull()
|
||||
const legacy = await POST(new Request('http://localhost/api/sandbox/seed', { method: 'POST' }))
|
||||
expect(await legacy.json()).toEqual({ seeded: false, topped_up: true })
|
||||
const { data: adopted } = await client.from('sandbox_seed_attempts').select('company_id,status').single()
|
||||
expect(adopted).toMatchObject({ company_id: attempt.company_id, status: 'complete' })
|
||||
expect(Date.now() - start).toBeLessThan(300_000)
|
||||
}, 300_000)
|
||||
})
|
||||
@@ -33,10 +33,9 @@
|
||||
* enskild_firma, so every row in the reports carries its BAS name instead of the
|
||||
* "Konto 6212" fallback.
|
||||
*
|
||||
* The entries deliberately carry NO voucher_number: the caller assigns those by
|
||||
* calling the next_voucher_number RPC once per entry, in array order, so the
|
||||
* sequence stays unbroken (BFNAR 2013:2). Lines carry no journal_entry_id for
|
||||
* the same reason.
|
||||
* The entries carry no posting metadata: the caller posts through the engine
|
||||
* in array order. It assigns voucher numbers atomically when committing and
|
||||
* links the lines to their journal entry.
|
||||
*/
|
||||
|
||||
import { roundOre } from '@/lib/money'
|
||||
@@ -86,10 +85,8 @@ export interface SandboxLedgerEntryRow {
|
||||
voucher_series: string
|
||||
entry_date: string
|
||||
description: string
|
||||
source_type: string
|
||||
source_type: 'manual'
|
||||
source_id: null
|
||||
status: string
|
||||
committed_at: string
|
||||
}
|
||||
|
||||
/** A journal_entry_lines row WITHOUT journal_entry_id: the caller fills it in. */
|
||||
@@ -252,8 +249,6 @@ export function buildSandboxLedgerHistory({
|
||||
description,
|
||||
source_type: SOURCE_TYPE,
|
||||
source_id: null,
|
||||
status: 'posted',
|
||||
committed_at: entryDate,
|
||||
})
|
||||
linesByEntryIndex.push(
|
||||
lines.map((line, index) => ({
|
||||
|
||||
+99
-284
@@ -1,8 +1,9 @@
|
||||
import crypto from 'crypto'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
|
||||
import { truncateIp } from '@/lib/api/v1/with-api-v1'
|
||||
@@ -37,6 +38,10 @@ import {
|
||||
// product; a per-/24 cap on the seed endpoint keeps a single network from
|
||||
// spinning up arbitrary sandbox companies. Idempotent for legit users, so 5/h
|
||||
// covers retries; an attacker has to rotate /24s to scale abuse.
|
||||
// The database lease outlives this function's maximum runtime.
|
||||
export const maxDuration = 300
|
||||
ensureInitialized()
|
||||
|
||||
const RATE_LIMIT = { maxRequests: 5, windowMs: 60 * 60 * 1000 }
|
||||
|
||||
/**
|
||||
@@ -90,65 +95,44 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// Anonymous users start with no company. Create one before seeding.
|
||||
// If a previous seed attempt already created a company for this user, reuse it
|
||||
// (idempotency).
|
||||
let companyId = await getActiveCompanyId(supabase, user.id)
|
||||
|
||||
if (!companyId) {
|
||||
const { data: newCompanyId, error: companyError } = await supabase.rpc(
|
||||
'create_company_with_owner',
|
||||
{
|
||||
p_name: 'Sandlådan Konsult',
|
||||
p_entity_type: 'enskild_firma',
|
||||
}
|
||||
)
|
||||
|
||||
if (companyError || !newCompanyId) {
|
||||
log.error('failed to create sandbox company', { error: companyError, userId: user.id })
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create sandbox company', requestId },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
companyId = newCompanyId as string
|
||||
const startedAt = Date.now()
|
||||
const { data: attempt, error: claimError } = await supabase.rpc('claim_sandbox_seed')
|
||||
if (claimError || !attempt) {
|
||||
log.error('failed to claim sandbox seed', { error: claimError, userId: user.id })
|
||||
return NextResponse.json({ error: 'Failed to prepare sandbox', requestId }, { status: 500 })
|
||||
}
|
||||
|
||||
// Idempotency: if the core seed already ran (company_settings exists), skip
|
||||
// the bulk insert path. We still TOP UP the newer surfaces (agent_profile,
|
||||
// suppliers, asset, pending operations) afterwards so an old sandbox session
|
||||
// (created before those were added to the seed) picks them up on the next
|
||||
// call instead of being stuck without a verified assistant.
|
||||
const { data: existing } = await supabase
|
||||
.from('company_settings')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (existing) {
|
||||
if (attempt.status === 'busy') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Sandbox creation is already in progress', requestId },
|
||||
{ status: 409, headers: { 'Retry-After': '5' } },
|
||||
)
|
||||
}
|
||||
const companyId = attempt.company_id as string
|
||||
if (attempt.status === 'complete') {
|
||||
try {
|
||||
await topUpSandboxAdditions(supabase, companyId)
|
||||
return NextResponse.json({ seeded: false, topped_up: true })
|
||||
} catch (err) {
|
||||
log.error('failed to top up sandbox additions', { error: err, userId: user.id, companyId })
|
||||
return NextResponse.json({ seeded: false, topped_up: false })
|
||||
return NextResponse.json({ error: 'Failed to prepare sandbox', requestId }, { status: 500 })
|
||||
}
|
||||
}
|
||||
const attemptId = attempt.attempt_id as string
|
||||
|
||||
try {
|
||||
const userId = user.id
|
||||
|
||||
// 1. Update profile (auto-created by auth trigger)
|
||||
await supabase
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.update({ full_name: 'Demo Användare' })
|
||||
.eq('id', userId)
|
||||
if (profileError) throw profileError
|
||||
|
||||
// 2. Create company settings
|
||||
// 2. Fill the sandbox settings reserved by claim_sandbox_seed.
|
||||
const { error: settingsError } = await supabase
|
||||
.from('company_settings')
|
||||
.insert({
|
||||
.update({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
entity_type: 'enskild_firma',
|
||||
@@ -192,6 +176,9 @@ export async function POST(request: Request) {
|
||||
pays_salaries: true,
|
||||
employer_registered: true,
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (settingsError) throw settingsError
|
||||
|
||||
@@ -270,6 +257,11 @@ export async function POST(request: Request) {
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30)
|
||||
const fifteenDaysAgo = new Date(today)
|
||||
fifteenDaysAgo.setDate(today.getDate() - 15)
|
||||
const overdueInvoiceDate = toDateStr(thirtyDaysAgo)
|
||||
// The demo has one fiscal period. Keep invoice vouchers in it in January.
|
||||
const yearStart = new Date(currentYear, 0, 1)
|
||||
if (thirtyDaysAgo < yearStart) thirtyDaysAgo.setTime(yearStart.getTime())
|
||||
if (fifteenDaysAgo < yearStart) fifteenDaysAgo.setTime(yearStart.getTime())
|
||||
const thirtyDaysFromNow = new Date(today)
|
||||
thirtyDaysFromNow.setDate(today.getDate() + 30)
|
||||
const fiveDaysAgo = new Date(today)
|
||||
@@ -320,7 +312,7 @@ export async function POST(request: Request) {
|
||||
company_id: companyId,
|
||||
customer_id: customerMap['Anna Lindström'],
|
||||
invoice_number: 'F-2026003',
|
||||
invoice_date: toDateStr(thirtyDaysAgo),
|
||||
invoice_date: overdueInvoiceDate,
|
||||
due_date: toDateStr(fiveDaysAgo),
|
||||
status: 'overdue',
|
||||
subtotal: 5000,
|
||||
@@ -494,7 +486,7 @@ export async function POST(request: Request) {
|
||||
// as broken rather than empty.
|
||||
//
|
||||
// Seeded BEFORE the invoice and payroll vouchers below on purpose.
|
||||
// next_voucher_number hands out numbers in call order, so seeding January
|
||||
// The engine assigns voucher numbers at commit, so seeding January
|
||||
// last would have produced A-1 dated in July followed by A-3 dated in
|
||||
// January: a gap-free sequence that runs backwards through the year, which
|
||||
// is not what BFNAR 2013:2 means by a chronological verifikationsserie.
|
||||
@@ -506,79 +498,19 @@ export async function POST(request: Request) {
|
||||
accountMap,
|
||||
})
|
||||
|
||||
// One RPC per voucher: next_voucher_number is a counter table with a row
|
||||
// lock (not MAX+1), so sequential calls are safe and gap-free. The two
|
||||
// writes are batched rather than run per entry, which is what turns ~130
|
||||
// round trips into ~45 for a seed that runs on every sandbox visit.
|
||||
const historyVoucherNumbers: number[] = []
|
||||
for (const historyEntry of ledgerHistory.entries) {
|
||||
const { data: historyVoucherNumber, error: historyVoucherError } = await supabase.rpc(
|
||||
'next_voucher_number',
|
||||
{
|
||||
p_company_id: companyId,
|
||||
p_fiscal_period_id: fiscalPeriod.id,
|
||||
p_series: historyEntry.voucher_series,
|
||||
},
|
||||
)
|
||||
if (historyVoucherError) throw historyVoucherError
|
||||
historyVoucherNumbers.push(historyVoucherNumber as number)
|
||||
const historyEntryIds: string[] = []
|
||||
for (const [index, entry] of ledgerHistory.entries.entries()) {
|
||||
const posted = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: entry.fiscal_period_id,
|
||||
voucher_series: entry.voucher_series,
|
||||
entry_date: entry.entry_date,
|
||||
description: entry.description,
|
||||
source_type: entry.source_type,
|
||||
lines: ledgerHistory.linesByEntryIndex[index],
|
||||
})
|
||||
historyEntryIds.push(posted.id)
|
||||
}
|
||||
|
||||
// Inserted as draft and posted after the lines land: PostgREST autocommits
|
||||
// each request, and check_balance_on_posted_insert (migration
|
||||
// 20260806130000) rejects a posted header whose transaction carries no
|
||||
// lines. The draft-to-posted UPDATE below fires check_balance_on_post
|
||||
// against the finished verifikat instead.
|
||||
//
|
||||
// committed_at note: this route runs under the requester's authenticated
|
||||
// client, and set_committed_at() (migration 20260806160000) preserves a
|
||||
// preset committed_at only for trusted roles, so any backdated
|
||||
// committed_at supplied here is overwritten with now() at posting. That
|
||||
// is deliberate: an end-user role must never control the audit timestamp,
|
||||
// and sandbox companies are disposable.
|
||||
const { data: insertedHistoryEntries, error: historyEntryError } = await supabase
|
||||
.from('journal_entries')
|
||||
.insert(
|
||||
ledgerHistory.entries.map((historyEntry, index) => ({
|
||||
...historyEntry,
|
||||
voucher_number: historyVoucherNumbers[index],
|
||||
status: 'draft',
|
||||
})),
|
||||
)
|
||||
.select('id, voucher_number')
|
||||
if (historyEntryError) throw historyEntryError
|
||||
|
||||
// Match on voucher_number, not on array position: PostgREST does not
|
||||
// promise the returned rows come back in insertion order, and
|
||||
// (company_id, fiscal_period_id, voucher_series, voucher_number) is unique.
|
||||
const historyIdByVoucher = new Map(
|
||||
(insertedHistoryEntries ?? []).map(row => [row.voucher_number as number, row.id as string]),
|
||||
)
|
||||
|
||||
const historyEntryIds = historyVoucherNumbers.map(voucherNumber => {
|
||||
const entryId = historyIdByVoucher.get(voucherNumber)
|
||||
if (!entryId) {
|
||||
throw new Error(`Sandbox seed: ledger history voucher ${voucherNumber} was not inserted`)
|
||||
}
|
||||
return entryId
|
||||
})
|
||||
|
||||
const { error: historyLinesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.insert(
|
||||
ledgerHistory.linesByEntryIndex.flatMap((lines, index) =>
|
||||
lines.map(line => ({ ...line, journal_entry_id: historyEntryIds[index] })),
|
||||
),
|
||||
)
|
||||
if (historyLinesError) throw historyLinesError
|
||||
|
||||
const { error: historyPostError } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'posted' })
|
||||
.in('id', historyEntryIds)
|
||||
.eq('company_id', companyId)
|
||||
if (historyPostError) throw historyPostError
|
||||
|
||||
// The history is the company's books from before it arrived in Accounted:
|
||||
// its kvitton live in the previous system's binder, not here. Left
|
||||
// unflagged, every one of these vouchers would land on Hem as "Verifikat
|
||||
@@ -595,131 +527,36 @@ export async function POST(request: Request) {
|
||||
'Historisk bokföring: underlag arkiverade i det tidigare systemet.',
|
||||
)
|
||||
|
||||
// 10. Invoice vouchers (inserted directly, not via engine, to avoid event emission)
|
||||
const { data: voucherNum1 } = await supabase.rpc('next_voucher_number', {
|
||||
p_company_id: companyId,
|
||||
p_fiscal_period_id: fiscalPeriod.id,
|
||||
p_series: 'A',
|
||||
// 10. Invoice vouchers use the engine's draft + atomic commit path.
|
||||
const invoiceEntry = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: fiscalPeriod.id,
|
||||
voucher_series: 'A',
|
||||
entry_date: toDateStr(thirtyDaysAgo),
|
||||
description: 'Faktura F-2026001, Björk & Partner AB',
|
||||
source_type: 'invoice_created',
|
||||
source_id: invoiceMap['F-2026001'],
|
||||
lines: [
|
||||
{ account_number: '1510', debit_amount: 18750, credit_amount: 0, dimensions: {} },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 15000, dimensions: { '1': 'BUTIK', '6': 'P001' } },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 3750, dimensions: {} },
|
||||
],
|
||||
})
|
||||
|
||||
const { data: je1, error: je1Error } = await supabase
|
||||
.from('journal_entries')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
fiscal_period_id: fiscalPeriod.id,
|
||||
voucher_number: voucherNum1 ?? 1,
|
||||
voucher_series: 'A',
|
||||
entry_date: toDateStr(thirtyDaysAgo),
|
||||
description: 'Faktura F-2026001, Björk & Partner AB',
|
||||
source_type: 'invoice_created',
|
||||
source_id: invoiceMap['F-2026001'],
|
||||
// Draft until the lines exist; see the ledger-history comment above.
|
||||
status: 'draft',
|
||||
committed_at: toDateStr(thirtyDaysAgo),
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (je1Error) throw je1Error
|
||||
|
||||
const { data: voucherNum2 } = await supabase.rpc('next_voucher_number', {
|
||||
p_company_id: companyId,
|
||||
p_fiscal_period_id: fiscalPeriod.id,
|
||||
p_series: 'A',
|
||||
const je2 = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: fiscalPeriod.id,
|
||||
voucher_series: 'A',
|
||||
entry_date: toDateStr(fifteenDaysAgo),
|
||||
description: 'Betalning faktura F-2026001, Björk & Partner AB',
|
||||
source_type: 'invoice_paid',
|
||||
source_id: invoiceMap['F-2026001'],
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 18750, credit_amount: 0, dimensions: {} },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 18750, dimensions: {} },
|
||||
],
|
||||
})
|
||||
|
||||
const { data: je2, error: je2Error } = await supabase
|
||||
.from('journal_entries')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
fiscal_period_id: fiscalPeriod.id,
|
||||
voucher_number: voucherNum2 ?? 2,
|
||||
voucher_series: 'A',
|
||||
entry_date: toDateStr(fifteenDaysAgo),
|
||||
description: 'Betalning faktura F-2026001, Björk & Partner AB',
|
||||
source_type: 'invoice_paid',
|
||||
source_id: invoiceMap['F-2026001'],
|
||||
// Draft until the lines exist; see the ledger-history comment above.
|
||||
status: 'draft',
|
||||
committed_at: toDateStr(fifteenDaysAgo),
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (je2Error) throw je2Error
|
||||
|
||||
// 10. Create journal entry lines. The P&L line carries demo dimensions
|
||||
// ({"1":"BUTIK","6":"P001"}) so the register's "antal taggade rader",
|
||||
// voucher-detail badges, and the dimension P&L report light up in the
|
||||
// sandbox. cost_center/project are GENERATED from the bag since the PR9
|
||||
// cutover: writing them explicitly would error.
|
||||
const revenueDims = { '1': 'BUTIK', '6': 'P001' }
|
||||
const { error: jelError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.insert([
|
||||
// JE1: Invoice creation, Debit AR, Credit Revenue + VAT
|
||||
// NB: `dimensions` must be set explicitly on EVERY row: same PostgREST
|
||||
// bulk-insert normalization as paid_amount below: omitting it on some
|
||||
// rows while one row sets it sends null (violating NOT NULL) instead
|
||||
// of falling through to the schema default '{}'.
|
||||
{
|
||||
journal_entry_id: je1.id,
|
||||
account_number: '1510',
|
||||
account_id: accountMap['1510'] ?? null,
|
||||
debit_amount: 18750,
|
||||
credit_amount: 0,
|
||||
sort_order: 0,
|
||||
dimensions: {},
|
||||
},
|
||||
{
|
||||
journal_entry_id: je1.id,
|
||||
account_number: '3001',
|
||||
account_id: accountMap['3001'] ?? null,
|
||||
debit_amount: 0,
|
||||
credit_amount: 15000,
|
||||
sort_order: 1,
|
||||
dimensions: revenueDims,
|
||||
},
|
||||
{
|
||||
journal_entry_id: je1.id,
|
||||
account_number: '2611',
|
||||
account_id: accountMap['2611'] ?? null,
|
||||
debit_amount: 0,
|
||||
credit_amount: 3750,
|
||||
sort_order: 2,
|
||||
dimensions: {},
|
||||
},
|
||||
// JE2: Invoice payment, Debit Bank, Credit AR
|
||||
{
|
||||
journal_entry_id: je2.id,
|
||||
account_number: '1930',
|
||||
account_id: accountMap['1930'] ?? null,
|
||||
debit_amount: 18750,
|
||||
credit_amount: 0,
|
||||
sort_order: 0,
|
||||
dimensions: {},
|
||||
},
|
||||
{
|
||||
journal_entry_id: je2.id,
|
||||
account_number: '1510',
|
||||
account_id: accountMap['1510'] ?? null,
|
||||
debit_amount: 0,
|
||||
credit_amount: 18750,
|
||||
sort_order: 1,
|
||||
dimensions: {},
|
||||
},
|
||||
])
|
||||
|
||||
if (jelError) throw jelError
|
||||
|
||||
const { error: invoicePostError } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'posted' })
|
||||
.in('id', [je1.id, je2.id])
|
||||
.eq('company_id', companyId)
|
||||
if (invoicePostError) throw invoicePostError
|
||||
const { error: invoiceLinkError } = await supabase.from('invoices')
|
||||
.update({ journal_entry_id: invoiceEntry.id })
|
||||
.eq('id', invoiceMap['F-2026001']).eq('company_id', companyId)
|
||||
if (invoiceLinkError) throw invoiceLinkError
|
||||
|
||||
// 11. Create transactions
|
||||
const { data: txRows, error: txError } = await supabase
|
||||
@@ -1179,9 +1016,8 @@ export async function POST(request: Request) {
|
||||
|
||||
// 21. Verifikat for the BOOKED run. A run in status 'booked' that posted
|
||||
// nothing would be a lie: the real path (bookPaidSalaryRun) always writes
|
||||
// these through the engine before advancing the status. The seed inserts
|
||||
// journal rows directly to avoid event emission, so ./salary-vouchers
|
||||
// mirrors the engine's account structure instead.
|
||||
// these through the engine before advancing the status. The pure builder
|
||||
// supplies the same account structure and the engine performs the writes.
|
||||
const bookedPeriod = resolveSandboxSalaryPeriods(today).booked
|
||||
const salaryVouchers = buildSandboxSalaryVouchers({
|
||||
userId,
|
||||
@@ -1204,51 +1040,18 @@ export async function POST(request: Request) {
|
||||
|
||||
const runEntryLinks: Record<string, string> = {}
|
||||
for (const voucher of salaryVouchers) {
|
||||
const { data: salaryVoucherNumber, error: salaryVoucherError } = await supabase.rpc(
|
||||
'next_voucher_number',
|
||||
{
|
||||
p_company_id: companyId,
|
||||
p_fiscal_period_id: fiscalPeriod.id,
|
||||
p_series: voucher.entry.voucher_series,
|
||||
},
|
||||
)
|
||||
// A posted verifikat with no voucher number is a hole in the
|
||||
// verifikationsserie (BFNAR 2013:2), so a failed counter read has to stop
|
||||
// the seed rather than insert one.
|
||||
if (salaryVoucherError) throw salaryVoucherError
|
||||
if (salaryVoucherNumber == null) {
|
||||
throw new Error('Sandbox seed: next_voucher_number returned no number for a salary voucher')
|
||||
}
|
||||
|
||||
const { data: insertedSalaryEntry, error: salaryEntryError } = await supabase
|
||||
.from('journal_entries')
|
||||
// Draft until the lines exist; see the ledger-history comment above.
|
||||
.insert({ ...voucher.entry, voucher_number: salaryVoucherNumber, status: 'draft' })
|
||||
.select('id')
|
||||
.single()
|
||||
if (salaryEntryError) throw salaryEntryError
|
||||
|
||||
const { error: salaryEntryLinesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.insert(
|
||||
voucher.lines.map(line => ({
|
||||
...line,
|
||||
account_id: accountMap[line.account_number] ?? null,
|
||||
journal_entry_id: insertedSalaryEntry.id,
|
||||
})),
|
||||
)
|
||||
if (salaryEntryLinesError) throw salaryEntryLinesError
|
||||
|
||||
runEntryLinks[voucher.runColumn] = insertedSalaryEntry.id
|
||||
const posted = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: voucher.entry.fiscal_period_id,
|
||||
voucher_series: voucher.entry.voucher_series,
|
||||
entry_date: voucher.entry.entry_date,
|
||||
description: voucher.entry.description,
|
||||
source_type: voucher.entry.source_type,
|
||||
source_id: voucher.entry.source_id,
|
||||
lines: voucher.lines,
|
||||
})
|
||||
runEntryLinks[voucher.runColumn] = posted.id
|
||||
}
|
||||
|
||||
const { error: salaryPostError } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'posted' })
|
||||
.in('id', Object.values(runEntryLinks))
|
||||
.eq('company_id', companyId)
|
||||
if (salaryPostError) throw salaryPostError
|
||||
|
||||
const { error: linkRunError } = await supabase
|
||||
.from('salary_runs')
|
||||
.update(runEntryLinks)
|
||||
@@ -1257,8 +1060,20 @@ export async function POST(request: Request) {
|
||||
|
||||
if (linkRunError) throw linkRunError
|
||||
|
||||
const { data: completed, error: completionError } = await supabase.rpc('finish_sandbox_seed', {
|
||||
p_attempt_id: attemptId,
|
||||
p_success: true,
|
||||
})
|
||||
if (completionError || !completed) throw completionError ?? new Error('Sandbox seed claim was lost')
|
||||
log.info('sandbox seed completed', { companyId, attemptId, durationMs: Date.now() - startedAt })
|
||||
return NextResponse.json({ seeded: true })
|
||||
} catch (err) {
|
||||
// A late failure cannot downgrade a completed attempt after a lost response.
|
||||
const { error: failureError } = await supabase.rpc('finish_sandbox_seed', {
|
||||
p_attempt_id: attemptId,
|
||||
p_success: false,
|
||||
})
|
||||
if (failureError) log.error('failed to release sandbox seed', { error: failureError, companyId, attemptId })
|
||||
log.error('failed to seed sandbox data', { error: err, userId: user.id, companyId })
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to seed sandbox data', requestId },
|
||||
@@ -1270,7 +1085,7 @@ export async function POST(request: Request) {
|
||||
/**
|
||||
* Idempotent top-up for sandboxes that pre-date the agent_profile addition
|
||||
* to the seed. Re-running the seed on those older sandboxes short-circuits
|
||||
* at the company_settings idempotency check above, so they never get the
|
||||
* after the verified completion check above, so they never get the
|
||||
* agent_profile without this hook. Delegates to ensureSandboxAgentProfile
|
||||
* so the profile data stays in exactly one place.
|
||||
*
|
||||
|
||||
@@ -3,16 +3,14 @@
|
||||
*
|
||||
* A run in status 'booked' that produced no verifikat would be a lie: in the
|
||||
* real product `bookPaidSalaryRun` posts 2-4 entries through the bookkeeping
|
||||
* engine before it advances the status. The sandbox seed cannot call that path
|
||||
* (it goes through the engine, which emits events, and the seed deliberately
|
||||
* inserts journal rows directly), so this module mirrors the account structure
|
||||
* engine before it advances the status. This module mirrors the account structure
|
||||
* of `createSalaryRunEntries` in lib/salary/salary-entries.ts instead.
|
||||
*
|
||||
* Accounts come from SALARY_ACCOUNTS (lib/salary/account-mapping.ts), not from
|
||||
* literals here, so a future BAS remap moves the seed with the engine.
|
||||
*
|
||||
* Pure builders, in the same style as ./customers.ts and ./pending-operations.ts:
|
||||
* the caller assigns voucher numbers and journal_entry_id foreign keys.
|
||||
* the caller posts through the engine and links the returned journal entry IDs.
|
||||
*/
|
||||
|
||||
import { SALARY_ACCOUNTS } from '@/lib/salary/account-mapping'
|
||||
@@ -79,8 +77,6 @@ export interface SeedJournalEntry {
|
||||
description: string
|
||||
source_type: 'salary_payment'
|
||||
source_id: string
|
||||
status: 'posted'
|
||||
committed_at: string
|
||||
voucher_series: string
|
||||
}
|
||||
|
||||
@@ -143,8 +139,6 @@ export function buildSandboxSalaryVouchers(input: SalaryVoucherInput): SalaryVou
|
||||
entry_date: paymentDate,
|
||||
source_type: 'salary_payment' as const,
|
||||
source_id: salaryRunId,
|
||||
status: 'posted' as const,
|
||||
committed_at: paymentDate,
|
||||
voucher_series: 'A',
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user