Bug/document linking (#688)
* feat: enhance supplier invoice payment process and settings handling - Implemented linking of invoice documents to journal entries for cash payments in the supplier invoice payment process. - Refactored settings fetching logic to improve loading states and error handling across various settings components. - Introduced a new SettingsLoadError component to handle cases where settings fetch fails or returns no data. - Updated useSettings hook to manage loading and error states more effectively, allowing for retries on failure. - Enhanced tests for supplier invoice creation to ensure document IDs are persisted correctly for cash method payments. * feat(salary): enable monthly salary edits in draft runs and handle zero-total declarations
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────
|
||||
// The route is wrapped in withRouteContext (auth via requireAuth, company via
|
||||
// getActiveCompanyId, write-gate via requireWritePermission). createSalaryRunEntries
|
||||
// is mocked so we can assert it is NOT called for a nollkörning (zero-amount run),
|
||||
// where the bookkeeping engine would otherwise reject a zero voucher.
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
vi.mock('@/lib/events', () => ({
|
||||
eventBus: { emit: vi.fn().mockResolvedValue(undefined) },
|
||||
}))
|
||||
vi.mock('@/lib/salary/salary-entries', () => ({ createSalaryRunEntries: vi.fn() }))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
const makePaidRun = (overrides = {}) => ({
|
||||
id: 'run-1',
|
||||
company_id: 'company-1',
|
||||
status: 'paid',
|
||||
period_year: 2026,
|
||||
period_month: 4,
|
||||
payment_date: '2026-04-25',
|
||||
voucher_series: 'A',
|
||||
total_gross: 0,
|
||||
total_tax: 0,
|
||||
total_net: 0,
|
||||
total_avgifter: 0,
|
||||
total_vacation_accrual: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('POST /api/salary/runs/[id]/book — nollkörning', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('books an empty-roster zero-total run without creating journal entries', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: mockUser as never,
|
||||
supabase: supabase as never,
|
||||
error: null,
|
||||
})
|
||||
|
||||
enqueueMany([
|
||||
{ data: makePaidRun() }, // salary_runs (paid) lookup
|
||||
{ data: [] }, // salary_run_employees roster (empty)
|
||||
{ data: { id: 'run-1', status: 'booked' } }, // salary_runs update → booked
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/book', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string; status: string } }>(
|
||||
response,
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.status).toBe('booked')
|
||||
expect(createSalaryRunEntries).not.toHaveBeenCalled()
|
||||
expect(eventBus.emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'salary_run.booked' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('books a run whose employees were all set to 0 kr as a nollkörning (no vouchers)', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: mockUser as never,
|
||||
supabase: supabase as never,
|
||||
error: null,
|
||||
})
|
||||
|
||||
enqueueMany([
|
||||
{ data: makePaidRun() }, // salary_runs (paid) lookup
|
||||
{ data: [{ employee_id: 'e1', gross_salary: 0, line_items: [] }] }, // roster present but zero
|
||||
{ data: { id: 'run-1', status: 'booked' } }, // salary_runs update → booked
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/book', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(createSalaryRunEntries).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -36,8 +36,47 @@ export const POST = withRouteContext(
|
||||
.select('*, employee:employees(employment_type), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
|
||||
if (empError || !employees || employees.length === 0) {
|
||||
return errorResponseFromCode('SALARY_RUN_NO_EMPLOYEES', opLog, { requestId })
|
||||
if (empError) {
|
||||
return errorResponse(empError, opLog, { requestId })
|
||||
}
|
||||
const roster = employees ?? []
|
||||
|
||||
// Nollkörning: a run with no monetary effect (employees set to 0 kr, or no
|
||||
// roster at all) has nothing to post. The bookkeeping engine forbids
|
||||
// zero-amount vouchers (every entry must balance with debit & credit > 0),
|
||||
// so we skip journal-entry creation entirely and just advance to 'booked'.
|
||||
// The AGI nolldeklaration is then the only artefact for the period.
|
||||
const nothingToBook =
|
||||
Math.round((run.total_gross ?? 0) * 100) === 0 &&
|
||||
Math.round((run.total_tax ?? 0) * 100) === 0 &&
|
||||
Math.round((run.total_avgifter ?? 0) * 100) === 0 &&
|
||||
Math.round((run.total_vacation_accrual ?? 0) * 100) === 0
|
||||
|
||||
if (nothingToBook) {
|
||||
const { data: bookedRun, error: updateError } = await supabase
|
||||
.from('salary_runs')
|
||||
.update({
|
||||
status: 'booked',
|
||||
booked_at: new Date().toISOString(),
|
||||
booked_by: user.id,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
return errorResponse(updateError, opLog, { requestId })
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'salary_run.booked',
|
||||
payload: { salaryRunId: id, entryIds: [], userId: user.id, companyId: companyId! },
|
||||
})
|
||||
|
||||
opLog.info('salary run booked as nollkörning (no journal entries)', { salaryRunId: id })
|
||||
|
||||
return NextResponse.json({ data: bookedRun })
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -56,7 +95,7 @@ export const POST = withRouteContext(
|
||||
total_net: run.total_net,
|
||||
total_avgifter: run.total_avgifter,
|
||||
total_vacation_accrual: run.total_vacation_accrual,
|
||||
employees: employees.map((sre) => ({
|
||||
employees: roster.map((sre) => ({
|
||||
employee_id: sre.employee_id,
|
||||
employment_type: sre.employee?.employment_type || 'employee',
|
||||
gross_salary: sre.gross_salary,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────
|
||||
// This route hand-rolls auth (createClient + getUser) rather than
|
||||
// withRouteContext, so we mock createClient and the write/company helpers.
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
const mockCreateClient = vi.fn()
|
||||
vi.mock('@/lib/supabase/server', () => ({ createClient: () => mockCreateClient() }))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/salary/personnummer', () => ({
|
||||
decryptPersonnummer: (x: string) => x,
|
||||
maskPersonnummer: (x: string) => x,
|
||||
}))
|
||||
|
||||
import { PATCH } from '../route'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
function authedSupabase() {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
|
||||
mockCreateClient.mockResolvedValue(supabase)
|
||||
return { supabase, enqueueMany }
|
||||
}
|
||||
|
||||
describe('PATCH /api/salary/runs/[id]/employees/[employeeId] — monthly salary edit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('updates the per-run monthly salary while the run is a draft', async () => {
|
||||
const { enqueueMany } = authedSupabase()
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup
|
||||
{
|
||||
data: { id: 'sre-1', employment_degree: 100, salary_type: 'monthly', monthly_salary: 30000 },
|
||||
}, // salary_run_employees update
|
||||
{ data: null }, // salary_line_items Grundlön refresh
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', {
|
||||
method: 'PATCH',
|
||||
body: { monthly_salary: 30000 },
|
||||
})
|
||||
const response = await PATCH(
|
||||
request,
|
||||
createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { monthly_salary: number } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.monthly_salary).toBe(30000)
|
||||
})
|
||||
|
||||
it('allows a zero monthly salary (nollkörning) on a draft', async () => {
|
||||
const { enqueueMany } = authedSupabase()
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', status: 'draft' } },
|
||||
{ data: { id: 'sre-1', employment_degree: 100, salary_type: 'monthly', monthly_salary: 0 } },
|
||||
{ data: null },
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', {
|
||||
method: 'PATCH',
|
||||
body: { monthly_salary: 0 },
|
||||
})
|
||||
const response = await PATCH(
|
||||
request,
|
||||
createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }),
|
||||
)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
|
||||
it('rejects a monthly salary edit when the run is no longer a draft', async () => {
|
||||
const { enqueueMany } = authedSupabase()
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', status: 'review' } }, // not a draft
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', {
|
||||
method: 'PATCH',
|
||||
body: { monthly_salary: 30000 },
|
||||
})
|
||||
const response = await PATCH(
|
||||
request,
|
||||
createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toContain('utkast')
|
||||
})
|
||||
|
||||
it('rejects mixing a salary edit with a tax override in one request', async () => {
|
||||
authedSupabase()
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', {
|
||||
method: 'PATCH',
|
||||
body: { monthly_salary: 30000, tax_withheld_override: 5000, reason: 'test' },
|
||||
})
|
||||
const response = await PATCH(
|
||||
request,
|
||||
createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }),
|
||||
)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -53,13 +53,19 @@ export async function GET(
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply per-employee override on tax/avgifter (advanced mode).
|
||||
* Per-employee edits within a salary run. Two operations, gated to different
|
||||
* statuses (never combined in one request):
|
||||
*
|
||||
* Only allowed in `review` status — the calculation engine has run, but the
|
||||
* run hasn't been approved or booked yet. After approval, vouchers and AGI
|
||||
* lock in the effective values; further changes require correction flows.
|
||||
* • monthly_salary — set this month's base salary for the employee. Allowed
|
||||
* only in `draft`. 0 is valid (an intentional nollkörning). The engine reads
|
||||
* this per-run value (not the employee master) when the run is calculated, so
|
||||
* each month's gross can differ without touching the employee's standard pay.
|
||||
*
|
||||
* Pass `null` for any field to clear a previously-set override.
|
||||
* • tax_withheld_override / avgifter_*_override — manual tax/avgifter
|
||||
* adjustment (advanced mode). Allowed only in `review`: the engine has run
|
||||
* but the run isn't approved/booked. After approval, vouchers and AGI lock in
|
||||
* the effective values; further changes require correction flows. Pass `null`
|
||||
* for any override field to clear it.
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
@@ -78,7 +84,24 @@ export async function PATCH(
|
||||
const parsed = await validateBody(request, SalaryEmployeeOverrideSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
// Gate on run status. Override is only valid mid-review.
|
||||
// Two distinct operations share this endpoint, gated to different statuses:
|
||||
// • monthly_salary → edit this month's base salary (draft only)
|
||||
// • *_override → manual tax/avgifter adjustment (review only)
|
||||
// They must not be mixed in one request.
|
||||
const wantsSalaryEdit = parsed.data.monthly_salary !== undefined
|
||||
const wantsOverride =
|
||||
parsed.data.tax_withheld_override !== undefined ||
|
||||
parsed.data.avgifter_amount_override !== undefined ||
|
||||
parsed.data.avgifter_basis_override !== undefined ||
|
||||
parsed.data.reason !== undefined
|
||||
|
||||
if (wantsSalaryEdit && wantsOverride) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kan inte ändra månadslön och skatte-/avgiftsjustering i samma anrop.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
@@ -87,6 +110,48 @@ export async function PATCH(
|
||||
.single()
|
||||
|
||||
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
|
||||
// ── Draft-stage edit of this month's base salary ──
|
||||
if (wantsSalaryEdit) {
|
||||
if (run.status !== 'draft') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Månadslönen kan bara redigeras medan lönekörningen är ett utkast.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const monthly = Math.round((parsed.data.monthly_salary as number) * 100) / 100
|
||||
|
||||
const { data: sre, error: sreErr } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.update({ monthly_salary: monthly })
|
||||
.eq('salary_run_id', id)
|
||||
.eq('employee_id', employeeId)
|
||||
.eq('company_id', companyId)
|
||||
.select('id, employment_degree, salary_type, monthly_salary')
|
||||
.maybeSingle()
|
||||
|
||||
if (sreErr) return NextResponse.json({ error: sreErr.message }, { status: 400 })
|
||||
if (!sre) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Keep the displayed 'Grundlön' line consistent with the new salary. This is
|
||||
// display-only — the engine recomputes baseSalary from monthly_salary at
|
||||
// calc time — but it avoids a stale row before the user clicks Beräkna.
|
||||
if (sre.salary_type === 'monthly') {
|
||||
const baseAmount = Math.round(monthly * (sre.employment_degree / 100) * 100) / 100
|
||||
await supabase
|
||||
.from('salary_line_items')
|
||||
.update({ amount: baseAmount })
|
||||
.eq('salary_run_employee_id', sre.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('item_type', 'monthly_salary')
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: sre })
|
||||
}
|
||||
|
||||
// ── Review-stage override of tax/avgifter ──
|
||||
if (run.status !== 'review') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Justering av skatt/avgifter är bara tillåten i granskningsläge (review).' },
|
||||
|
||||
@@ -35,7 +35,18 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
mockCreateSupplierInvoiceCashEntry(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/documents/document-service', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/core/documents/document-service')>(
|
||||
'@/lib/core/documents/document-service'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
linkToJournalEntry: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
@@ -241,6 +252,88 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cash method: links the inbox document to the cash payment verifikat (BFL 5 kap 6 §)', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
total: 10000,
|
||||
remaining_amount: 10000,
|
||||
paid_amount: 0,
|
||||
document_id: 'doc-1',
|
||||
supplier,
|
||||
items: [],
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: no candidate transactions
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: { accounting_method: 'cash' }, error: null })
|
||||
|
||||
mockCreateSupplierInvoiceCashEntry.mockResolvedValue({ id: 'je-cash' })
|
||||
|
||||
// Update invoice (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'si-1' }], error: null })
|
||||
// Record payment
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ journal_entry_id: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.journal_entry_id).toBe('je-cash')
|
||||
// The cash entry is the ONLY booking, so its underlag must hang on it.
|
||||
expect(linkToJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'doc-1',
|
||||
'je-cash',
|
||||
)
|
||||
})
|
||||
|
||||
it('accrual method: does NOT re-link the document at payment (stays on the registration verifikat)', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
total: 10000,
|
||||
remaining_amount: 10000,
|
||||
paid_amount: 0,
|
||||
document_id: 'doc-1',
|
||||
registration_journal_entry_id: 'je-reg',
|
||||
supplier,
|
||||
items: [],
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: no candidate transactions
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
|
||||
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-pay' })
|
||||
|
||||
// Update invoice (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'si-1' }], error: null })
|
||||
// Record payment
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
// The document already lives on the registration verifikat — re-linking
|
||||
// here would move it off the primary booking.
|
||||
expect(linkToJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 500 when journal entry creation fails (blocking — GL must succeed for payment)', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
@@ -279,6 +280,27 @@ export const POST = withRouteContext(
|
||||
opLog.warn('failed to record supplier_invoice_payments row', paymentError)
|
||||
}
|
||||
|
||||
// Under kontantmetoden the cash payment entry is the ONLY booking of the
|
||||
// affärshändelse, so its underlag (the document from the inbox) must hang on
|
||||
// THIS verifikat per BFL 5 kap 6 §. Under faktureringsmetoden the document
|
||||
// is already linked to the registration verifikat at receipt — re-linking
|
||||
// here would move it off that primary booking, so we attach only for the
|
||||
// cash entry. Non-fatal: the payment is already committed and immutable, so
|
||||
// a link failure is logged and the invoice stays usable (mirrors the
|
||||
// registration-time linking in commitCreateSupplierInvoiceFromInbox).
|
||||
const invoiceDocumentId = (invoice as { document_id?: string | null }).document_id
|
||||
if (useCashEntry && invoiceDocumentId && journalEntryId) {
|
||||
try {
|
||||
await linkToJournalEntry(supabase, companyId!, invoiceDocumentId, journalEntryId)
|
||||
} catch (linkErr) {
|
||||
opLog.warn('failed to link supplier invoice document to cash payment JE', {
|
||||
documentId: invoiceDocumentId,
|
||||
journalEntryId,
|
||||
error: linkErr instanceof Error ? linkErr.message : String(linkErr),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.paid',
|
||||
|
||||
Reference in New Issue
Block a user