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:
@@ -1586,5 +1586,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-04] company_settings.pays_salaries (and a never-attested employer_registered) now follow the ledger: trigger salary_runs_booked_marks_employer (20260904191000) sets them when a salary run is booked, with a backfill for the 12 companies already booking payroll with the flag at its default. Support case 2026-09-04: the assistant told a payroll-running aktiebolag in every answer that it "betalar inte löner" because lib/agent/ask/snapshot.ts asserted the column default (NOT NULL DEFAULT false, only written by the Skatt settings form) as a fact. The snapshot and the composer's KÄNDA FAKTA now apply the employee-facts doctrine (positive evidence or an attested negative, never the default) and the snapshot names Inställningar > Skatt / > Bokföring so the model can point at the page. Chose a trigger over calling a helper from the booking code because there are three booking writers (lib/salary/book-run.ts, the v1 route's own copy, seeders) and 20260717151000 already treated in-app payroll as employer evidence once as a one-off backfill; the trigger makes that rule continuous. An explicit employer_registered = false stays the user's answer.
|
||||
[2026-09-04] Supplier credit notes rest at 'credited' from creation (one row builder in lib/supplier-invoices/credit-note.ts for the dashboard route, the MCP executor and the v1 API) and a CHECK (supplier_invoices_credit_note_not_payable, 20260904190000) keeps every writer out of the payable states; the worklist attest count also excludes is_credit_note. Support case 2026-09-04: a credit note was inserted at 'registered' (the attest entry state) while the detail page rightly had no attest button for it, so Att göra showed "1 leverantörsfaktura att attestera" that nobody could clear; 14 such rows on prod plus one MCP-approved credit note, all backfilled by the migration (one immutable reset-source row skipped, hence NOT VALID). Chose the DB CHECK over fixing only the three insert literals because the invariant is "a credit note is never a payable", not "this literal says registered"; chose 'credited' over 'approved'+approved_at (would surface Markera betald and the Att betala tab) and over 'paid' (the customer side forbids paid credit notes by CHECK); the provider importers already used 'credited'. Also: the GET route now hydrates credited_original with a second scoped query because PostgREST cannot pick a direction for a self-referencing embed hint and returned the one-to-many side (an empty array), rendered as "Krediterar: Ankomst #" with no number.
|
||||
[2026-09-04] Underlag attach on a folder-picked Fortnox export (Loftux, 50 of 50 files refused with UNDERLAG_REF_MISMATCH): the multipart filename is reduced to its basename at the route boundary (lib/documents/upload-file-name.ts), rather than teaching the voucher-ref parser to strip directories or adding a client-supplied file_name field. Chrome writes webkitRelativePath as the multipart filename for folder selections, so the attach check saw "2026/06/Leverantörsfakturor/A166_x.pdf" while the preview had resolved File.name "A166_x.pdf"; the two endpoints received the same file under two names and the guard compared them. Stripping inside the parser would turn a typed manual ref "2024/01/31" into voucher 31 (the manual box shares the parser), and a second client-supplied name is no more trustworthy than the first, so the boundary is the only level that fixes the class.
|
||||
[2026-09-04] Sandbox seed recovery (#2292) uses a per-anonymous-user database claim and a completion marker written last. A failed or expired attempt archives its exclusively owned demo company and starts a fresh one, preserving partial posted history for normal sandbox expiry instead of reseeding into it. Legacy demos are adopted only when the final payroll links prove completion. All demo vouchers now use the bookkeeping engine; the posting-integrity guards stay unchanged.
|
||||
[2026-09-04] Migrated sales invoices without rows (Profilio 384/384, Loftux 311/672, Damac 182/542, Clearstoq 1 125/1 125): completed by an hourly re-runnable pass (extensions/general/arcim-migration/lib/complete-invoice-lines.ts, cron /api/extensions/arcim-migration/complete-invoice-lines/cron) that starts from OUR row-less invoices, joins them to the provider register on number + date, hydrates only that subset and writes rows once the detail total matches the stored total to the öre; the header VAT split is rewritten only when the stored one holds no evidence (null rate, or 0 kr VAT beside subtotal = total). Why not a bigger in-run budget: the largest register (1 911 invoices at Fortnox's platform-wide 4 req/s) does not fit one 300 s function whatever the split, and a budget-bounded one-shot pass leaves whatever it misses missing forever, silently (the wizard never showed the hydration report; it does now). Why not re-running fetchSalesInvoicesHydrated: it sorts the whole register open-first every time, so a second run re-spends its budget on the same invoices and never reaches the rest. Why not reset + re-import or an arithmetic backfill: reset deletes rows that payments and vouchers already point at, and total/1,25 asserts a rate the source never stated (DECISIONS 2026-08-22). The pass reuses mapSalesInvoice, so a row it writes is indistinguishable from a fully hydrated import; it never touches totals, status, payments or any journal entry (momsdeklaration and every report read the ledger).
|
||||
[2026-09-04] Connector-hop failures (timeout, error envelope, wire-contract mismatch) are transient in every sync path: the row keeps its status and the user message says no renewal is needed, same as AspspUnavailableError (#2202), and the cron now treats AspspUnavailableError the same way instead of parking it in 'error'. Why: on 2026-09-04 the Connect service answered a shape the client rejects and the cron flipped four canary companies to 'error' with SYNC_FAILED_MESSAGE, so users re-authorized consents that were fine. The Zod issues are logged (field paths) because a bare 'unexpected shape' left the failure undiagnosable. Rejected: a new 'degraded' connection status (one more state every filter and the probe would have to learn; the health probe already catches a dead session on the same run) and removing the canary companies from the env (hides the contract bug instead of exposing its field paths).
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -1219,6 +1219,7 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
|
||||
event_log: '30-day TTL event bus log',
|
||||
extension_data: 'extension runtime state (includes this backup\'s own state)',
|
||||
idempotency_keys: 'infrastructure',
|
||||
sandbox_seed_attempts: 'infrastructure: disposable demo seed coordination, no accounting records',
|
||||
inbox_rate_counters: 'infrastructure',
|
||||
mail_connections:
|
||||
'mailbox OAuth grants (live refresh tokens), not portable. The receipts they find are archived as documents.',
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
-- A settings row is created before demo data and is not a completion marker.
|
||||
-- User-scoped coordination also covers the first request, before a company exists.
|
||||
CREATE TABLE public.sandbox_seed_attempts (
|
||||
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
company_id uuid REFERENCES public.companies(id) ON DELETE SET NULL,
|
||||
attempt_id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
status text NOT NULL CHECK (status IN ('running', 'failed', 'complete')),
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
completed_at timestamptz,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX sandbox_seed_attempts_company_id ON public.sandbox_seed_attempts(company_id);
|
||||
ALTER TABLE public.sandbox_seed_attempts ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.sandbox_seed_attempts FROM anon, authenticated;
|
||||
GRANT SELECT ON public.sandbox_seed_attempts TO authenticated;
|
||||
CREATE POLICY sandbox_seed_attempts_read_own ON public.sandbox_seed_attempts
|
||||
FOR SELECT TO authenticated USING (user_id = (SELECT auth.uid()));
|
||||
CREATE TRIGGER sandbox_seed_attempts_updated_at BEFORE UPDATE ON public.sandbox_seed_attempts
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
CREATE FUNCTION public.claim_sandbox_seed()
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user uuid := auth.uid();
|
||||
v_attempt public.sandbox_seed_attempts%ROWTYPE;
|
||||
v_company uuid;
|
||||
v_complete boolean := false;
|
||||
BEGIN
|
||||
-- Read the server-owned auth record, not editable user_metadata.
|
||||
IF v_user IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM auth.users WHERE id = v_user AND is_anonymous IS TRUE
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Sandbox requires an anonymous user' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended('sandbox-seed:' || v_user::text, 0));
|
||||
SELECT * INTO v_attempt FROM public.sandbox_seed_attempts WHERE user_id = v_user FOR UPDATE;
|
||||
IF FOUND THEN
|
||||
IF v_attempt.status = 'running' AND v_attempt.started_at > now() - interval '15 minutes' THEN
|
||||
RETURN jsonb_build_object('status', 'busy');
|
||||
END IF;
|
||||
v_company := v_attempt.company_id;
|
||||
v_complete := v_attempt.status = 'complete';
|
||||
ELSE
|
||||
SELECT active_company_id INTO v_company FROM public.user_preferences WHERE user_id = v_user;
|
||||
-- Legacy successful seeds ended by linking all three payroll vouchers.
|
||||
-- Older or partial demos without that evidence are conservatively replaced.
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.salary_runs sr
|
||||
JOIN public.journal_entries s ON s.id = sr.salary_entry_id AND s.company_id = sr.company_id AND s.status = 'posted'
|
||||
JOIN public.journal_entries a ON a.id = sr.avgifter_entry_id AND a.company_id = sr.company_id AND a.status = 'posted'
|
||||
JOIN public.journal_entries v ON v.id = sr.vacation_entry_id AND v.company_id = sr.company_id AND v.status = 'posted'
|
||||
WHERE sr.company_id = v_company AND sr.status = 'booked'
|
||||
) INTO v_complete;
|
||||
END IF;
|
||||
|
||||
IF v_company IS NOT NULL THEN
|
||||
-- Never archive, adopt or seed a real/shared company, even for an anonymous caller.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.companies c JOIN public.company_members cm ON cm.company_id = c.id
|
||||
WHERE c.id = v_company AND c.created_by = v_user
|
||||
AND cm.user_id = v_user AND cm.role = 'owner'
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM public.company_members WHERE company_id = v_company AND user_id <> v_user
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM public.company_settings WHERE company_id = v_company AND is_sandbox IS NOT TRUE
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Not an exclusively owned sandbox company' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF v_complete AND EXISTS (
|
||||
SELECT 1 FROM public.companies c JOIN public.company_settings cs ON cs.company_id = c.id
|
||||
WHERE c.id = v_company AND c.archived_at IS NULL AND cs.is_sandbox
|
||||
) THEN
|
||||
INSERT INTO public.sandbox_seed_attempts(user_id, company_id, status, completed_at)
|
||||
VALUES (v_user, v_company, 'complete', now())
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
INSERT INTO public.user_preferences(user_id, active_company_id) VALUES (v_user, v_company)
|
||||
ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id;
|
||||
RETURN jsonb_build_object('status', 'complete', 'company_id', v_company);
|
||||
END IF;
|
||||
-- Preserve partial entries and documents; ordinary sandbox expiry owns deletion.
|
||||
UPDATE public.companies SET archived_at = now(), archived_by = v_user
|
||||
WHERE id = v_company AND archived_at IS NULL;
|
||||
END IF;
|
||||
|
||||
v_company := public.create_company_with_owner('Sandlådan Konsult', 'enskild_firma', true);
|
||||
-- Make even a crash before the first seed write discoverable by sandbox cleanup.
|
||||
INSERT INTO public.company_settings(user_id, company_id, is_sandbox)
|
||||
VALUES (v_user, v_company, true);
|
||||
INSERT INTO public.sandbox_seed_attempts(user_id, company_id, status)
|
||||
VALUES (v_user, v_company, 'running')
|
||||
ON CONFLICT (user_id) DO UPDATE SET company_id = EXCLUDED.company_id,
|
||||
attempt_id = gen_random_uuid(), status = 'running', started_at = now(), completed_at = NULL
|
||||
RETURNING * INTO v_attempt;
|
||||
RETURN jsonb_build_object('status', 'running', 'company_id', v_company, 'attempt_id', v_attempt.attempt_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.finish_sandbox_seed(p_attempt_id uuid, p_success boolean)
|
||||
RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM auth.users WHERE id = auth.uid() AND is_anonymous IS TRUE
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Sandbox requires an anonymous user' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
UPDATE public.sandbox_seed_attempts
|
||||
SET status = CASE WHEN p_success THEN 'complete' ELSE 'failed' END,
|
||||
completed_at = CASE WHEN p_success THEN now() ELSE NULL END
|
||||
WHERE user_id = auth.uid() AND attempt_id = p_attempt_id AND status = 'running';
|
||||
RETURN FOUND;
|
||||
END;
|
||||
$$;
|
||||
REVOKE ALL ON FUNCTION public.claim_sandbox_seed() FROM PUBLIC, anon;
|
||||
REVOKE ALL ON FUNCTION public.finish_sandbox_seed(uuid, boolean) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.claim_sandbox_seed() TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.finish_sandbox_seed(uuid, boolean) TO authenticated;
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -47,6 +47,12 @@ AS $$
|
||||
SELECT string_to_array(name, '/');
|
||||
$$;
|
||||
|
||||
-- auth.identities is created by GoTrue at startup, not by the Postgres
|
||||
-- image. Likewise, GoTrue's 20240214120130 migration adds is_anonymous;
|
||||
-- sandbox authorization reads this server-owned flag, not user metadata.
|
||||
ALTER TABLE auth.users
|
||||
ADD COLUMN IF NOT EXISTS is_anonymous boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- auth.identities is created by GoTrue at startup, not by the Postgres
|
||||
-- image, and GoTrue does not run in CI. Triggers on auth.users that touch
|
||||
-- identities (20260903110000 unlink_old_address_identities) need the table
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, it } from 'vitest'
|
||||
import { getPool } from './setup'
|
||||
|
||||
describe('sandbox seed attempt lifecycle', () => {
|
||||
it('serializes claims, isolates users, preserves partial data and fences stale completions', async () => {
|
||||
const client = await getPool().connect()
|
||||
try {
|
||||
await client.query(readFileSync(resolve('tests/pg/sandbox-seed-attempts.sql'), 'utf8'))
|
||||
} finally {
|
||||
await client.query('ROLLBACK')
|
||||
client.release()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Shared by pg-real and the staging MCP verification. All fixtures roll back.
|
||||
BEGIN;
|
||||
DO $$
|
||||
DECLARE
|
||||
u uuid := gen_random_uuid();
|
||||
other_user uuid := gen_random_uuid();
|
||||
a jsonb;
|
||||
b jsonb;
|
||||
old_company uuid;
|
||||
attempt uuid;
|
||||
BEGIN
|
||||
INSERT INTO auth.users(id, email, instance_id, is_anonymous)
|
||||
VALUES (u, u::text || '@test.invalid', '00000000-0000-0000-0000-000000000000', true),
|
||||
(other_user, other_user::text || '@test.invalid', '00000000-0000-0000-0000-000000000000', false);
|
||||
PERFORM set_config('request.jwt.claims', jsonb_build_object('sub', u, 'role', 'authenticated', 'is_anonymous', true)::text, true);
|
||||
PERFORM set_config('request.jwt.claim.sub', u::text, true);
|
||||
SET LOCAL ROLE authenticated;
|
||||
a := public.claim_sandbox_seed();
|
||||
ASSERT a->>'status' = 'running', 'first request must claim';
|
||||
old_company := (a->>'company_id')::uuid;
|
||||
attempt := (a->>'attempt_id')::uuid;
|
||||
ASSERT (SELECT active_company_id = old_company FROM public.user_preferences WHERE user_id = u);
|
||||
ASSERT (SELECT is_sandbox FROM public.company_settings WHERE company_id = old_company);
|
||||
ASSERT (public.claim_sandbox_seed()->>'status') = 'busy', 'second request must not seed';
|
||||
ASSERT NOT public.finish_sandbox_seed(gen_random_uuid(), true), 'wrong token must not finish';
|
||||
ASSERT public.finish_sandbox_seed(attempt, false), 'failed attempt must release';
|
||||
b := public.claim_sandbox_seed();
|
||||
ASSERT b->>'status' = 'running';
|
||||
ASSERT b->>'company_id' <> a->>'company_id', 'retry must use a fresh company';
|
||||
-- Active-company RLS intentionally hides the previous archived company.
|
||||
RESET ROLE;
|
||||
ASSERT EXISTS (SELECT 1 FROM public.companies WHERE id = old_company AND archived_at IS NOT NULL);
|
||||
ASSERT EXISTS (SELECT 1 FROM public.company_settings WHERE company_id = old_company), 'preserve partial data';
|
||||
SET LOCAL ROLE authenticated;
|
||||
ASSERT NOT public.finish_sandbox_seed(attempt, true), 'old worker cannot finish new attempt';
|
||||
ASSERT public.finish_sandbox_seed((b->>'attempt_id')::uuid, true);
|
||||
ASSERT NOT public.finish_sandbox_seed((b->>'attempt_id')::uuid, false), 'late error cannot downgrade success';
|
||||
ASSERT public.claim_sandbox_seed()->>'status' = 'complete';
|
||||
ASSERT public.claim_sandbox_seed()->>'company_id' = b->>'company_id';
|
||||
BEGIN
|
||||
UPDATE public.sandbox_seed_attempts SET status = 'complete' WHERE user_id = u;
|
||||
RAISE EXCEPTION 'direct state writes must be denied';
|
||||
EXCEPTION WHEN insufficient_privilege THEN NULL;
|
||||
END;
|
||||
RESET ROLE;
|
||||
PERFORM set_config('request.jwt.claim.sub', other_user::text, true);
|
||||
PERFORM set_config('request.jwt.claims', jsonb_build_object('sub', other_user, 'role', 'authenticated')::text, true);
|
||||
SET LOCAL ROLE authenticated;
|
||||
ASSERT NOT EXISTS (SELECT 1 FROM public.sandbox_seed_attempts WHERE user_id = u), 'state must be isolated';
|
||||
BEGIN
|
||||
PERFORM public.claim_sandbox_seed();
|
||||
RAISE EXCEPTION 'registered user must be denied';
|
||||
EXCEPTION WHEN insufficient_privilege THEN NULL;
|
||||
END;
|
||||
BEGIN
|
||||
PERFORM public.finish_sandbox_seed((b->>'attempt_id')::uuid, true);
|
||||
RAISE EXCEPTION 'registered user must not finish';
|
||||
EXCEPTION WHEN insufficient_privilege THEN NULL;
|
||||
END;
|
||||
RESET ROLE;
|
||||
-- Expired workers are replaced, retaining their partial data.
|
||||
UPDATE public.sandbox_seed_attempts SET status = 'running', started_at = now() - interval '16 minutes' WHERE user_id = u;
|
||||
PERFORM set_config('request.jwt.claim.sub', u::text, true);
|
||||
PERFORM set_config('request.jwt.claims', jsonb_build_object('sub', u, 'role', 'authenticated', 'is_anonymous', true)::text, true);
|
||||
SET LOCAL ROLE authenticated;
|
||||
a := public.claim_sandbox_seed();
|
||||
ASSERT a->>'status' = 'running';
|
||||
ASSERT a->>'company_id' <> b->>'company_id';
|
||||
RESET ROLE;
|
||||
END $$;
|
||||
ROLLBACK;
|
||||
Reference in New Issue
Block a user