feat(invoices,year-end): four byrå-feedback fixes (validation feedback, moms gate, klarmarkera, article search) (#1641)
* fix(invoices): surface validation errors instead of a silent dead submit button A missing unit (or any other Zod failure) blocked both Granska & skapa and Spara som utkast with zero feedback: handleSubmit had no onInvalid callback, the buttons stayed enabled, and the unit field rendered no inline error. Reported by a byra user whose client could not save any invoice. - onInvalid handler on all three submit paths: destructive toast plus scroll to the first inline error - inline error text under the unit select and quantity input (the only line fields that had none) - same treatment in NewRecurringScheduleDialog, including inline errors on its item rows Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(supplier-invoices): stop defaulting 25 % moms for icke momsregistrerade companies The registration form hard-coded vat_rate 0.25 on the initial line, added rows, AI prefill fallback and konto defaults, regardless of company_settings.vat_registered. A non-VAT-registered business that missed the prefilled rate booked ingaende moms (2641) it has no right to deduct (ML 8 kap. 3 \u00a7). The customer-invoice side already gates on the same flag; the supplier side ignored it. - form: read vat_registered from /api/settings; when false, all moms controls (rate cells, per-line moms, totals rows) are hidden and every line is forced to 0 %, including late AI prefills - reverse charge keeps its rate controls: self-assessment is a separate obligation from deduction - route: 400 SI_CREATE_INVALID_INPUT when a non-registered company posts a line with vat_rate/vat_amount > 0 (API/MCP defense in depth), and an omitted vat_rate now defaults to 0 instead of 25 % for those companies - tests: guard rejection, reverse-charge pass-through, 0-default; existing POST tests updated for the new settings lookup Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(year-end): klarmarkera imported years already closed in a previous system SIE-imported historical fiscal years land with is_closed = false and no closing entry, so the year-end page lists every migrated year as pending bokslut even though the bokslut was done in the old software. There was no sanctioned way to mark them done: closePeriod hard-requires locked_at and closing_entry_id. - migration: fiscal_periods.closed_externally boolean (audit clarity: distinguishes a year-end run here from a close done elsewhere) - markPeriodClosedExternally(): closes + locks without a closing entry; refuses already-closed periods, periods with their own closing entry, periods that have not ended, and periods with unbooked bank transactions (same stranding guard as lockPeriod); writes the immutable audit_log entry - POST /api/bookkeeping/fiscal-periods/[id]/close-external (requireWrite) - year-end page: one attn line on the preflight step with a confirm dialog describing the outcome; the marked year drops out of the eligible list Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): searchable article picker on invoice lines The article field was a plain Radix Select whose only matching is label-prefix typeahead: for numbered articles that means number-only lookup, and typing "skruv" found nothing. Byra feedback: name search would help a lot for users with real article catalogs. New ArticleCombobox (input-trigger dropdown, same pattern as AccountCombobox): free-text search over name + article number, diacritics-folded via foldText, keyboard navigation, pinned "Egen rad" free-text option, browse-all on focus like the Select it replaces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: log klarmarkera pg-test decision Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address skeptic and compliance-review findings on PR #1641 - ArticleCombobox: keyboard focus no longer auto-opens the list, opening highlights the committed selection, typing highlights the first match, and re-selecting the current value is a no-op. Previously Tab+Enter silently detached the article and wiped its revenue-account override. - Supplier invoice prefill for icke momsregistrerade: the zeroing effect now grosses the net amount up by the extracted rate before forcing 0 %, so the booked cost and 2440 keep the full att-betala amount instead of understating both by the moms. - markPeriodClosedExternally: only migrated periods qualify (must contain SIE-imported verifikat or no verifikat at all); the update carries an is_closed=false predicate so a concurrent normal close cannot be overwritten; confirm dialog now names the reporting consequences. - Route comment: honest scope (this route only; v1/inbox/MCP sweep is a follow-up) and current-law citation (13 kap. ML 2023:200). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: use roundOre for the icke-momsregistrerad gross-up (ratchet guard) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
caa0c3b41d
commit
dfb34a01d9
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Tests for POST /api/bookkeeping/fiscal-periods/[id]/close-external
|
||||
* ("klarmarkera": period closed in a previous bookkeeping system).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/bookkeeping/period-service', () => ({
|
||||
markPeriodClosedExternally: vi.fn(),
|
||||
}))
|
||||
|
||||
import { markPeriodClosedExternally } from '@/lib/core/bookkeeping/period-service'
|
||||
import { POST } from '../route'
|
||||
|
||||
const mockMark = vi.mocked(markPeriodClosedExternally)
|
||||
const idParams = { params: Promise.resolve({ id: 'period-1' }) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/fiscal-periods/[id]/close-external', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 when the caller lacks write permission', async () => {
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
|
||||
})
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
|
||||
expect(res.status).toBe(403)
|
||||
expect(mockMark).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps a service refusal to 400 with a safe message', async () => {
|
||||
mockMark.mockRejectedValue(new Error('Period is already closed'))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(
|
||||
await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
expect(typeof body.error).toBe('string')
|
||||
expect(body.error.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('marks the period on the happy path', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockMark.mockResolvedValue({ id: 'period-1', is_closed: true, closed_externally: true } as any)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { is_closed: boolean; closed_externally: boolean }
|
||||
}>(await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams))
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.is_closed).toBe(true)
|
||||
expect(body.data.closed_externally).toBe(true)
|
||||
expect(mockMark).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'period-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { markPeriodClosedExternally } from '@/lib/core/bookkeeping/period-service'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
// "Klarmarkera": mark an imported historical year as closed in a previous
|
||||
// bookkeeping system. Same legacy `{ error: string }` failure shape as the
|
||||
// sibling close route: the year-end UI reads it directly.
|
||||
export const POST = withRouteContext(
|
||||
'period.close_external',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { user, supabase, companyId } = ctx
|
||||
|
||||
try {
|
||||
const period = await markPeriodClosedExternally(supabase, companyId, user.id, id)
|
||||
return NextResponse.json({ data: period })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? getUserErrorMessage(err) : 'Failed to mark period as closed' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -221,6 +221,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
|
||||
|
||||
// Fetch supplier
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
// RPC get_next_arrival_number
|
||||
enqueue({ data: 5 })
|
||||
@@ -269,6 +270,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-deferred' })
|
||||
|
||||
// Fetch supplier
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
// RPC get_next_arrival_number
|
||||
enqueue({ data: 5 })
|
||||
@@ -315,6 +317,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
|
||||
enqueue({ data: { id: DOCUMENT_UUID, journal_entry_id: null }, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: 6 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
@@ -384,6 +387,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
|
||||
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: 5 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
@@ -423,6 +427,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
|
||||
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: 6 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
@@ -453,6 +458,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
|
||||
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: 7 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
@@ -483,6 +489,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1', invoice_date: '2099-06-01' })
|
||||
|
||||
// Fetch supplier
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
// RPC get_next_arrival_number
|
||||
enqueue({ data: 9 })
|
||||
@@ -521,6 +528,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
|
||||
// Fetch supplier
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
// RPC get_next_arrival_number
|
||||
enqueue({ data: 8 })
|
||||
@@ -573,6 +581,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
it('returns 409 without credit_note_id when existing invoice is not credited', async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: 9 })
|
||||
enqueue({
|
||||
@@ -616,6 +625,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
it('returns generic 409 when existing row lookup races to nothing', async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: 10 })
|
||||
enqueue({
|
||||
@@ -652,6 +662,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
it('falls through to 500 for non-23505 insert errors', async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: 11 })
|
||||
enqueue({ data: null, error: { code: '23502', message: 'NOT NULL violation' } })
|
||||
@@ -678,6 +689,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-1', status: 'paid' })
|
||||
|
||||
// Fetch supplier
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
// Fetch company.entity_type (paidPrivately branch)
|
||||
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
|
||||
@@ -734,6 +746,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-2', status: 'paid' })
|
||||
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: 13 })
|
||||
@@ -779,6 +792,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
|
||||
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: 7 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
@@ -825,6 +839,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
|
||||
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: 8 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
@@ -954,6 +969,7 @@ describe('POST /api/supplier-invoices: exchange rate + SEK amounts', () => {
|
||||
captured.find((c) => c.table === 'supplier_invoices')?.payload
|
||||
|
||||
function enqueueHappyPath() {
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null }) // supplier lookup
|
||||
enqueue({ data: 7 }) // get_next_arrival_number
|
||||
enqueue({ data: makeSupplierInvoice({ id: 'si-fx' }), error: null }) // insert invoice
|
||||
@@ -1064,6 +1080,7 @@ describe('POST /api/supplier-invoices: exchange rate + SEK amounts', () => {
|
||||
})
|
||||
|
||||
it('refuses the create with SI_FX_RATE_MISSING when no rate can be resolved', async () => {
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null })
|
||||
mockFetchExchangeRate.mockResolvedValue(null)
|
||||
|
||||
@@ -1206,6 +1223,7 @@ describe('POST /api/supplier-invoices: särskild löneskatt (apply_slp)', () =>
|
||||
})
|
||||
|
||||
it('happy path: apply_slp on a 7412 line is stored on the item and reaches the generator', async () => {
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null }) // supplier lookup
|
||||
enqueue({ data: 9 }) // get_next_arrival_number
|
||||
enqueue({ data: makeSupplierInvoice({ id: 'si-slp' }), error: null }) // insert invoice
|
||||
@@ -1240,6 +1258,7 @@ describe('POST /api/supplier-invoices: särskild löneskatt (apply_slp)', () =>
|
||||
})
|
||||
|
||||
it('defaults apply_slp to false when omitted', async () => {
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null })
|
||||
enqueue({ data: 10 })
|
||||
enqueue({ data: makeSupplierInvoice({ id: 'si-noslp' }), error: null })
|
||||
@@ -1262,3 +1281,85 @@ describe('POST /api/supplier-invoices: särskild löneskatt (apply_slp)', () =>
|
||||
expect(rows[0].apply_slp).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/supplier-invoices: icke momsregistrerad (vat_registered=false)', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
function vrBody(items: Record<string, unknown>[], overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
supplier_id: VALID_UUID,
|
||||
supplier_invoice_number: 'LF-VR',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
items,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('rejects a line carrying moms with SI_CREATE_INVALID_INPUT', async () => {
|
||||
enqueue({ data: { vat_registered: false }, error: null }) // vat_registered guard
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: vrBody([
|
||||
{ description: 'Material', amount: 1000, account_number: '4010', 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')
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets reverse charge pass the guard (self-assessment is separate from deduction)', async () => {
|
||||
enqueue({ data: { vat_registered: false }, error: null }) // vat_registered guard
|
||||
enqueue({ data: null, error: { message: 'Not found' } }) // supplier lookup fails
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: vrBody(
|
||||
[{ description: 'EU-tjänst', amount: 1000, account_number: '4531', vat_rate: 0 }],
|
||||
{ reverse_charge: true },
|
||||
),
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
// Reaching SUPPLIER_NOT_FOUND proves the moms guard did not fire.
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('SUPPLIER_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('defaults an omitted vat_rate to 0 instead of 25 %', async () => {
|
||||
enqueue({ data: { vat_registered: false }, error: null }) // vat_registered guard
|
||||
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null }) // supplier lookup
|
||||
enqueue({ data: 5 }) // get_next_arrival_number
|
||||
enqueue({ data: makeSupplierInvoice({ id: 'si-vr' }), error: null }) // insert invoice
|
||||
enqueue({ data: [], error: null }) // insert items
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null }) // company settings
|
||||
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-vr' })
|
||||
enqueue({ data: null, error: null }) // update registration_journal_entry_id
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: vrBody([{ description: 'Material', amount: 1000, account_number: '4010' }]),
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
const itemsInsert = findCall('supplier_invoice_items', 'insert')
|
||||
const rows = itemsInsert![0] as Array<Record<string, unknown>>
|
||||
expect(rows[0].vat_rate).toBe(0)
|
||||
expect(rows[0].vat_amount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -174,6 +174,30 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
// Icke momsregistrerad verksamhet has no deduction right for input VAT
|
||||
// (avdragsrätt, 13 kap. ML 2023:200): a line carrying moms would book
|
||||
// 2641 the company can never reclaim. The form hides the moms controls;
|
||||
// this guard covers THIS route only. The v1 REST route, the inbox convert
|
||||
// route and the MCP staged executor still default 25 % and need the same
|
||||
// treatment in a follow-up sweep. Reverse charge stays allowed:
|
||||
// self-assessment is a separate obligation from deduction.
|
||||
const { data: vatSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('vat_registered')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
const vatRegistered = vatSettings?.vat_registered !== false
|
||||
if (
|
||||
!vatRegistered &&
|
||||
!body.reverse_charge &&
|
||||
body.items.some((item) => (item.vat_rate ?? 0) > 0 || (item.vat_amount ?? 0) > 0)
|
||||
) {
|
||||
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
|
||||
requestId,
|
||||
details: { reason: 'company is not VAT-registered; supplier invoice lines cannot carry moms' },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: supplier, error: supplierError } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
@@ -236,7 +260,9 @@ export const POST = withRouteContext(
|
||||
}
|
||||
|
||||
const items = body.items.map((item, index) => {
|
||||
const vatRate = item.vat_rate ?? 0.25
|
||||
// An omitted rate defaults to 25 % only for VAT-registered companies;
|
||||
// icke momsregistrerade book the gross amount with no moms line.
|
||||
const vatRate = item.vat_rate ?? (vatRegistered ? 0.25 : 0)
|
||||
const lineTotal = item.amount != null
|
||||
? Math.round(item.amount * 100) / 100
|
||||
: Math.round((item.quantity ?? 1) * (item.unit_price ?? 0) * 100) / 100
|
||||
|
||||
Reference in New Issue
Block a user