Bug/accounting ps eu (#474)
* feat(api): implement commit functionality for journal entries * fix(extensions): make ExtensionSettings.clear() a real delete so disconnect flows work The 2026-03-30 multi-tenant refactor dropped all RLS policies on extension_data and recreated only SELECT/INSERT/UPDATE. Combined with `value jsonb NOT NULL`, every extension that called `settings.set(key, null)` to clear stored state (cloud-backup disconnect, skatteverket OAuth/AGI cleanup, arcim-migration consent reset) silently failed — the upsert hit the NOT NULL constraint and the error was swallowed, leaving users stuck with stale connection rows. Adds an `extension_data_delete` RLS policy, a `clear(key)` method backed by a real DELETE, switches the four affected handlers, and makes `set()` throw on Supabase error so this class of silent failure can't recur. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(journal-entries): add draft saving functionality to journal entry form * feat: add periodisk sammanställning report generation and CSV export - Implemented period date helpers in `period-dates.ts` for calculating start and end dates based on period type (monthly, quarterly, yearly). - Created `periodisk-sammanstallning.ts` to generate the periodisk sammanställning report, including data fetching, validation, and warning handling. - Developed CSV serializer in `periodisk-sammanstallning-csv.ts` for exporting the report in SKV574008 format. - Added new columns to `company_settings` for storing periodisk sammanställning settings and tax contact information via migration. - Introduced a new migration to add a `paid_with_private_funds` flag to `supplier_invoices` for tracking out-of-pocket expenses. - Updated journal entries to include the new source type for privately paid supplier invoices. * feat(migrations): add paid_with_private_funds flag to supplier_invoices and expand journal_entries.source_type CHECK * fix(ai_requests): drop existing policies and trigger before creating new ones * fix(migrations): ensure extension_data has a proper DELETE policy for ExtensionSettings.clear() * fix(supplier-invoices): update error handling for invalid input in POST request * fix: correct capitalization in project title * fix(migrations): resolve duplicate version 20260513120000 Two migrations shared the same timestamp prefix, causing schema_migrations_pkey collision on Supabase preview branches. Bump extension_data_delete_policy to 20260513120001. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ba45b8f661
commit
c8461397c8
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
makeJournalEntry,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const mockCreateClient = vi.fn()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => mockCreateClient(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockCommitEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
commitEntry: (...args: unknown[]) => mockCommitEntry(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/bookkeeping/journal-entries/[id]/commit', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/commit', {
|
||||
method: 'POST',
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns posted entry on success', async () => {
|
||||
const postedEntry = makeJournalEntry({
|
||||
id: 'entry-1',
|
||||
status: 'posted',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 42,
|
||||
})
|
||||
mockCommitEntry.mockResolvedValue(postedEntry)
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/commit', {
|
||||
method: 'POST',
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(postedEntry)
|
||||
expect(mockCommitEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
'entry-1',
|
||||
'manual'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 400 when engine throws', async () => {
|
||||
mockCommitEntry.mockRejectedValue(new Error('Entry not balanced'))
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/commit', {
|
||||
method: 'POST',
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Entry not balanced')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { commitEntry } from '@/lib/bookkeeping/engine'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
try {
|
||||
const posted = await commitEntry(supabase, companyId, user.id, id, 'manual')
|
||||
return NextResponse.json({ data: posted })
|
||||
} catch (err) {
|
||||
const typed = bookkeepingErrorResponse(err)
|
||||
if (typed) return typed
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to commit entry' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { createDraftEntry, createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
@@ -141,8 +141,13 @@ export async function POST(request: Request) {
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const asDraft = searchParams.get('as_draft') === 'true'
|
||||
|
||||
try {
|
||||
const entry = await createJournalEntry(supabase, companyId, user.id, body)
|
||||
const entry = asDraft
|
||||
? await createDraftEntry(supabase, companyId, user.id, body)
|
||||
: await createJournalEntry(supabase, companyId, user.id, body)
|
||||
return NextResponse.json({ data: entry })
|
||||
} catch (err) {
|
||||
const typed = bookkeepingErrorResponse(err)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
generatePeriodiskSammanstallning,
|
||||
type PsPeriodType,
|
||||
} from '@/lib/reports/periodisk-sammanstallning'
|
||||
import {
|
||||
buildPeriodiskSammanstallningCsv,
|
||||
PsCsvBuildError,
|
||||
} from '@/lib/reports/periodisk-sammanstallning-csv'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
/**
|
||||
* GET /api/reports/periodisk-sammanstallning/csv
|
||||
*
|
||||
* Returns the SKV574008-formatted CSV file for upload to Skatteverket.
|
||||
* Refuses (400) if the report has any blocking warnings.
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'report.periodisk_sammanstallning.csv',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodType = searchParams.get('periodType') as PsPeriodType | null
|
||||
const yearStr = searchParams.get('year')
|
||||
const periodStr = searchParams.get('period')
|
||||
|
||||
if (!periodType || !yearStr || !periodStr) {
|
||||
return errorResponseFromCode('PS_REPORT_MISSING_PARAMS', log, { requestId })
|
||||
}
|
||||
if (periodType !== 'monthly' && periodType !== 'quarterly') {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD_TYPE', log, {
|
||||
requestId, details: { received: periodType },
|
||||
})
|
||||
}
|
||||
const year = parseInt(yearStr, 10)
|
||||
const period = parseInt(periodStr, 10)
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_YEAR', log, { requestId })
|
||||
}
|
||||
if (isNaN(period)) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, { requestId })
|
||||
}
|
||||
if (periodType === 'monthly' && (period < 1 || period > 12)) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, { requestId })
|
||||
}
|
||||
if (periodType === 'quarterly' && (period < 1 || period > 4)) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, { requestId })
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('org_number, tax_contact_name, tax_contact_phone, tax_contact_email')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!settings?.org_number
|
||||
|| !settings?.tax_contact_name
|
||||
|| !settings?.tax_contact_phone
|
||||
|| !settings?.tax_contact_email) {
|
||||
return errorResponseFromCode('PS_REPORT_MISSING_FILER_INFO', log, { requestId })
|
||||
}
|
||||
|
||||
try {
|
||||
const report = await generatePeriodiskSammanstallning(
|
||||
supabase, companyId, periodType, year, period,
|
||||
)
|
||||
|
||||
const csv = buildPeriodiskSammanstallningCsv(report, {
|
||||
organizationNumber: settings.org_number,
|
||||
contactName: settings.tax_contact_name,
|
||||
contactPhone: settings.tax_contact_phone,
|
||||
contactEmail: settings.tax_contact_email,
|
||||
})
|
||||
|
||||
return new Response(csv.content as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': csv.mimeType,
|
||||
'Content-Disposition': `attachment; filename="${csv.filename}"`,
|
||||
'X-Request-Id': requestId,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof PsCsvBuildError) {
|
||||
if (err.reason === 'BLOCKING_WARNINGS') {
|
||||
return errorResponseFromCode('PS_REPORT_CSV_BLOCKED_BY_ERRORS', log, {
|
||||
requestId, details: { message: err.message },
|
||||
})
|
||||
}
|
||||
if (err.reason === 'MISSING_FILER_INFO') {
|
||||
return errorResponseFromCode('PS_REPORT_MISSING_FILER_INFO', log, {
|
||||
requestId, details: { message: err.message },
|
||||
})
|
||||
}
|
||||
}
|
||||
log.error('periodisk sammanställning CSV failed', err as Error, {
|
||||
periodType, year, period,
|
||||
})
|
||||
return errorResponseFromCode('PS_REPORT_GENERATION_FAILED', log, {
|
||||
requestId,
|
||||
details: { reason: err instanceof Error ? err.message : 'unknown' },
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
generatePeriodiskSammanstallning,
|
||||
reconcilePsAgainstVatDeclaration,
|
||||
type PsPeriodType,
|
||||
} from '@/lib/reports/periodisk-sammanstallning'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { MomsPeriod } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/reports/periodisk-sammanstallning
|
||||
*
|
||||
* Query parameters:
|
||||
* periodType: 'monthly' | 'quarterly'
|
||||
* year: number (e.g., 2025)
|
||||
* period: number (1-12 for monthly, 1-4 for quarterly)
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'report.periodisk_sammanstallning',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodType = searchParams.get('periodType') as PsPeriodType | null
|
||||
const yearStr = searchParams.get('year')
|
||||
const periodStr = searchParams.get('period')
|
||||
|
||||
if (!periodType || !yearStr || !periodStr) {
|
||||
return errorResponseFromCode('PS_REPORT_MISSING_PARAMS', log, { requestId })
|
||||
}
|
||||
|
||||
if (periodType !== 'monthly' && periodType !== 'quarterly') {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD_TYPE', log, {
|
||||
requestId,
|
||||
details: { received: periodType },
|
||||
})
|
||||
}
|
||||
|
||||
const year = parseInt(yearStr, 10)
|
||||
const period = parseInt(periodStr, 10)
|
||||
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_YEAR', log, {
|
||||
requestId,
|
||||
details: { received: yearStr },
|
||||
})
|
||||
}
|
||||
if (isNaN(period)) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, {
|
||||
requestId,
|
||||
details: { received: periodStr },
|
||||
})
|
||||
}
|
||||
if (periodType === 'monthly' && (period < 1 || period > 12)) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, {
|
||||
requestId,
|
||||
details: { periodType, received: period, allowed: '1-12' },
|
||||
})
|
||||
}
|
||||
if (periodType === 'quarterly' && (period < 1 || period > 4)) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, {
|
||||
requestId,
|
||||
details: { periodType, received: period, allowed: '1-4' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const report = await generatePeriodiskSammanstallning(
|
||||
supabase, companyId, periodType, year, period,
|
||||
)
|
||||
|
||||
// Best-effort reconciliation against momsdeklaration when periods coincide.
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('moms_period')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
const momsPeriod = (settings?.moms_period ?? null) as MomsPeriod | null
|
||||
const reconciled = await reconcilePsAgainstVatDeclaration(
|
||||
supabase, companyId, report, momsPeriod,
|
||||
)
|
||||
|
||||
return NextResponse.json({ data: reconciled })
|
||||
} catch (err) {
|
||||
log.error('periodisk sammanställning calculation failed', err as Error, {
|
||||
periodType, year, period,
|
||||
})
|
||||
return errorResponseFromCode('PS_REPORT_GENERATION_FAILED', log, {
|
||||
requestId,
|
||||
details: { reason: err instanceof Error ? err.message : 'unknown' },
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -31,9 +31,12 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
}))
|
||||
|
||||
const mockCreateSupplierInvoiceRegistrationEntry = vi.fn()
|
||||
const mockCreateSupplierInvoicePrivatelyPaidEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierInvoiceRegistrationEntry: (...args: unknown[]) =>
|
||||
mockCreateSupplierInvoiceRegistrationEntry(...args),
|
||||
createSupplierInvoicePrivatelyPaidEntry: (...args: unknown[]) =>
|
||||
mockCreateSupplierInvoicePrivatelyPaidEntry(...args),
|
||||
}))
|
||||
|
||||
import { eventBus } from '@/lib/events'
|
||||
@@ -457,4 +460,126 @@ describe('POST /api/supplier-invoices', () => {
|
||||
expect(status).toBe(500)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('SI_CREATE_FAILED')
|
||||
})
|
||||
|
||||
it('books privately-paid invoice via 2893 path for aktiebolag', async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-1', status: 'paid' })
|
||||
|
||||
// Fetch supplier
|
||||
enqueue({ data: supplier, error: null })
|
||||
// Fetch company.entity_type (paidPrivately branch)
|
||||
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
|
||||
// RPC get_next_arrival_number
|
||||
enqueue({ data: 12 })
|
||||
// Insert invoice
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Insert items
|
||||
enqueue({ data: null, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
|
||||
mockCreateSupplierInvoicePrivatelyPaidEntry.mockResolvedValue({ id: 'je-priv-1' })
|
||||
// Update invoice with payment_journal_entry_id
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert supplier_invoice_payments row
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: VALID_UUID,
|
||||
supplier_invoice_number: 'KVITTO-001',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-06-01',
|
||||
paid_with_private_funds: true,
|
||||
items: [
|
||||
{
|
||||
description: 'Kontorsmaterial',
|
||||
quantity: 1,
|
||||
unit_price: 400,
|
||||
account_number: '6110',
|
||||
vat_rate: 0.25,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { payment_journal_entry_id: string; registration_journal_entry_id: null }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.payment_journal_entry_id).toBe('je-priv-1')
|
||||
expect(body.data.registration_journal_entry_id).toBeNull()
|
||||
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).toHaveBeenCalled()
|
||||
// The classic registration path must NOT be touched.
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
const call = mockCreateSupplierInvoicePrivatelyPaidEntry.mock.calls[0]
|
||||
expect(call[5]).toBe('aktiebolag')
|
||||
})
|
||||
|
||||
it('passes entity_type=enskild_firma so engine credits 2018', async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-2', status: 'paid' })
|
||||
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: 13 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { accounting_method: 'cash' }, error: null })
|
||||
|
||||
mockCreateSupplierInvoicePrivatelyPaidEntry.mockResolvedValue({ id: 'je-priv-2' })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: VALID_UUID,
|
||||
supplier_invoice_number: 'KVITTO-002',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-06-01',
|
||||
paid_with_private_funds: true,
|
||||
items: [
|
||||
{
|
||||
description: 'Lunch klient',
|
||||
quantity: 1,
|
||||
unit_price: 200,
|
||||
account_number: '5810',
|
||||
vat_rate: 0.12,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
const call = mockCreateSupplierInvoicePrivatelyPaidEntry.mock.calls[0]
|
||||
expect(call[5]).toBe('enskild_firma')
|
||||
})
|
||||
|
||||
it('rejects paid_with_private_funds combined with reverse_charge', async () => {
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: VALID_UUID,
|
||||
supplier_invoice_number: 'LF-RC',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
paid_with_private_funds: true,
|
||||
reverse_charge: true,
|
||||
items: [{ description: 'Service', quantity: 1, unit_price: 5000, account_number: '6540', vat_rate: 0.25 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
|
||||
// Make sure we never touched the engine paths.
|
||||
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import {
|
||||
createSupplierInvoiceRegistrationEntry,
|
||||
createSupplierInvoicePrivatelyPaidEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
@@ -54,6 +57,17 @@ export const POST = withRouteContext(
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
const paidPrivately = body.paid_with_private_funds === true
|
||||
|
||||
if (paidPrivately && body.reverse_charge) {
|
||||
// RC invoices come from registered businesses with formal invoices and
|
||||
// go through normal AP. "Privately paid" only makes sense for
|
||||
// out-of-pocket kvitton — combining the two is a UI bug. 400, not 500.
|
||||
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
|
||||
requestId,
|
||||
details: { reason: 'paid_with_private_funds is not supported with reverse_charge' },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: supplier, error: supplierError } = await supabase
|
||||
.from('suppliers')
|
||||
@@ -66,6 +80,25 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('SUPPLIER_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
// Entity type drives the credit account for privately-paid invoices:
|
||||
// AB → 2893 (skuld till aktieägare), EF → 2018 (egen insättning). Loaded
|
||||
// up front so we can fail early if the company row is missing.
|
||||
let entityType: 'aktiebolag' | 'enskild_firma' | null = null
|
||||
if (paidPrivately) {
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('entity_type')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
if (!company?.entity_type) {
|
||||
return errorResponseFromCode('SI_CREATE_FAILED', log, {
|
||||
requestId,
|
||||
details: { reason: 'company entity_type missing — cannot pick owner account' },
|
||||
})
|
||||
}
|
||||
entityType = company.entity_type as 'aktiebolag' | 'enskild_firma'
|
||||
}
|
||||
|
||||
const { data: arrivalNum, error: arrivalError } = await supabase
|
||||
.rpc('get_next_arrival_number', { p_company_id: companyId })
|
||||
|
||||
@@ -101,11 +134,33 @@ export const POST = withRouteContext(
|
||||
const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0)
|
||||
const total = Math.round((subtotal + vatAmount) * 100) / 100
|
||||
|
||||
// Representation (BAS 6070–6079): ingående moms is only deductible up to
|
||||
// 300 SEK base/person per ML 8 kap. 1 §, and the income-tax deduction was
|
||||
// abolished in 2017 (IL 16 kap. 2 §). The engine debits 2641 for the full
|
||||
// VAT; we surface a non-blocking warning so the user can adjust manually.
|
||||
// Only emit on the new private-funds path for now — other AP paths share
|
||||
// the flaw and are tracked separately.
|
||||
const warnings: Array<{ code: string; message: string }> = []
|
||||
if (paidPrivately) {
|
||||
const repItems = items.filter(i => /^607\d$/.test(i.account_number))
|
||||
if (repItems.length > 0) {
|
||||
warnings.push({
|
||||
code: 'REPRESENTATION_VAT_CAP',
|
||||
message:
|
||||
'Representation (konto 6070–6079): ingående moms är endast avdragsgill ' +
|
||||
'upp till 300 kr/person (ML 8 kap. 1 §) och kostnaden är inte ' +
|
||||
'inkomstskattemässigt avdragsgill (IL 16 kap. 2 §). Justera bokföringen ' +
|
||||
'manuellt om beloppet överstiger gränsen.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const exchangeRate = body.exchange_rate || null
|
||||
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
|
||||
const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null
|
||||
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
|
||||
|
||||
const totalRounded = Math.round(total * 100) / 100
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.insert({
|
||||
@@ -117,19 +172,22 @@ export const POST = withRouteContext(
|
||||
invoice_date: body.invoice_date,
|
||||
due_date: body.due_date,
|
||||
delivery_date: body.delivery_date || null,
|
||||
status: 'registered',
|
||||
status: paidPrivately ? 'paid' : 'registered',
|
||||
currency: body.currency || 'SEK',
|
||||
exchange_rate: exchangeRate,
|
||||
vat_treatment: body.vat_treatment || 'standard_25',
|
||||
reverse_charge: body.reverse_charge || false,
|
||||
payment_reference: body.payment_reference || null,
|
||||
paid_with_private_funds: paidPrivately,
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
subtotal_sek: subtotalSek,
|
||||
vat_amount: Math.round(vatAmount * 100) / 100,
|
||||
vat_amount_sek: vatAmountSek,
|
||||
total: Math.round(total * 100) / 100,
|
||||
total: totalRounded,
|
||||
total_sek: totalSek,
|
||||
remaining_amount: Math.round(total * 100) / 100,
|
||||
paid_amount: paidPrivately ? totalRounded : 0,
|
||||
remaining_amount: paidPrivately ? 0 : totalRounded,
|
||||
paid_at: paidPrivately ? new Date().toISOString() : null,
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select()
|
||||
@@ -214,6 +272,11 @@ export const POST = withRouteContext(
|
||||
// is fatal — an orphan supplier_invoices row without a registration JE
|
||||
// silently understates leverantörsskuld (2440) and ingående moms (2641)
|
||||
// for the momsdeklaration. Roll back instead.
|
||||
//
|
||||
// Privately-paid path bypasses both accrual and cash flows: a single
|
||||
// verifikat books the expense + VAT against 2893 (AB) or 2018 (EF) at
|
||||
// registration time, regardless of accounting_method. mark-paid is never
|
||||
// invoked for these (status='paid' from the start).
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
@@ -222,8 +285,59 @@ export const POST = withRouteContext(
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
let registrationJournalEntryId: string | null = null
|
||||
let paymentJournalEntryId: string | null = null
|
||||
|
||||
if (accountingMethod === 'accrual') {
|
||||
if (paidPrivately && entityType) {
|
||||
try {
|
||||
const journalEntry = await createSupplierInvoicePrivatelyPaidEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
invoice as SupplierInvoice,
|
||||
items as SupplierInvoiceItem[],
|
||||
entityType,
|
||||
supplier.name,
|
||||
)
|
||||
if (journalEntry) {
|
||||
paymentJournalEntryId = journalEntry.id
|
||||
await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ payment_journal_entry_id: journalEntry.id })
|
||||
.eq('id', invoice.id)
|
||||
// Mirror the payment in supplier_invoice_payments so AR/AP and
|
||||
// payment-history queries stay consistent with the mark-paid path.
|
||||
await supabase.from('supplier_invoice_payments').insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
supplier_invoice_id: invoice.id,
|
||||
// For an eget utlägg the actual out-of-pocket date may differ from
|
||||
// the invoice/receipt date — accept an explicit payment_date and
|
||||
// fall back to invoice_date for the common kvitto case.
|
||||
payment_date: body.payment_date ?? invoice.invoice_date,
|
||||
amount: totalRounded,
|
||||
currency: invoice.currency,
|
||||
exchange_rate_difference: 0,
|
||||
journal_entry_id: journalEntry.id,
|
||||
notes: 'Eget utlägg — betalat privat',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
|
||||
if (isBookkeepingError(err)) {
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
log.error('failed to create privately-paid journal entry', err as Error, {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
return errorResponseFromCode('SI_CREATE_FAILED', log, {
|
||||
requestId,
|
||||
details: {
|
||||
reason: err instanceof Error ? err.message : 'unknown',
|
||||
step: 'privately_paid_journal_entry',
|
||||
},
|
||||
})
|
||||
}
|
||||
} else if (accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createSupplierInvoiceRegistrationEntry(
|
||||
supabase,
|
||||
@@ -264,6 +378,17 @@ export const POST = withRouteContext(
|
||||
type: 'supplier_invoice.registered',
|
||||
payload: { supplierInvoice: invoice as SupplierInvoice, companyId: companyId!, userId: user.id },
|
||||
})
|
||||
if (paidPrivately) {
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.paid',
|
||||
payload: {
|
||||
supplierInvoice: invoice as SupplierInvoice,
|
||||
paymentAmount: totalRounded,
|
||||
companyId: companyId!,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('supplier_invoice.registered event emission failed', err as Error)
|
||||
}
|
||||
@@ -273,7 +398,9 @@ export const POST = withRouteContext(
|
||||
...invoice,
|
||||
items: itemInserts,
|
||||
registration_journal_entry_id: registrationJournalEntryId,
|
||||
payment_journal_entry_id: paymentJournalEntryId,
|
||||
},
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
Reference in New Issue
Block a user