Add/pdf and templates (#705)
* fix(invoices): apply configured voucher series to payments + preview next voucher The booking engine resolves the series from default_voucher_series_per_source_type, but the global "Standardserie" dropdown wrote a separate field the engine ignored, and cash-method invoice payments (invoice_cash_payment) weren't exposed in settings — so configured series were silently dropped to "A". - Expose cash/private payment source types in the per-source-type form - Write the global default through to the map on save, keeping overrides - Resolve voucher-sequences/next by source_type (+date) to match the engine - Show the upcoming voucher (V2) in the payment dialog title - Share resolveInvoicePaymentSourceType so preview and booking can't drift Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(salary): keep AGI panel in sync with Skatteverket signing state The AGI panel mixed run-scoped generation state (agi_generated_at, agi_declarations) with period-scoped submission state (extension_data agi_submission_{period}), so the two could drift and present contradictory UI. Reconcile them: - Auto-detect a Mina Sidor BankID signature: while awaiting_signing, poll /agi/kvittenser on mount and on tab refocus so the panel flips to "signed" (hiding the signing actions) without a manual "Hamta kvittens" click. - Warn instead of offering to sign when the locked granskningsunderlag predates the run's latest AGI generation (draftIsStale) — avoids filing superseded figures. - Self-heal a stale "AGI-XML saknas" error once the run's AGI is (re)generated out-of-band (MCP/API/other tab). - Refetch the salary run on tab focus so agi_generated_at reflects out-of-band generation without a hard reload. - /agi/lasUpp now clears the cached agi_submission_{period} record, so unlocking drops the panel back to the pre-submission state instead of stranding it on a released "redo att signeras" draft. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: Implement VAT registration handling and invoice item line types - Added VAT registration check in commitCreateInvoice to set VAT rate to 0% for non-VAT registered companies. - Updated invoice creation logic to reflect 'exempt' VAT treatment and adjusted related fields accordingly. - Introduced support for free-text and blank spacer rows in invoice items by adding a new line_type field. - Enhanced invoice and credit note handling to accommodate new line types. - Added new localized messages for text rows in English and Swedish. - Created tests for salary run approval logic, ensuring bank details are validated correctly. - Implemented effective net payout calculation for salary runs, considering tax overrides. - Added SQL migrations to support new invoice item line types and accounting method awareness for linking invoices to vouchers. * feat(articles): artikelregister with revenue account + VAT rate per article Article register (non-inventory) with per-article VAT rate and optional BAS class-3 revenue-account override. Includes API routes, UI pages, MCP tools, pending-operation staging, and the activate-or-create account flow (ACCOUNTS_NOT_IN_CHART -> ActivateAccountsDialog, unknown numbers -> AddAccountDialog) reusing the journal entry UX. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): no-doc-required batch + bulk-missing endpoints Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(payments): supplier payment lines + cash-method invoice matching Shared payment-line proposal for supplier invoices, improved match-invoice/match-supplier-invoice flows (kontantmetoden-aware), and voucher-link support without requiring a 151x clearing entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): new journal entry dialog, SIE import tweaks, misc New journal entry dialog component, journal list/page updates, invoice editor updates, SIE import adjustments, transaction ingest and api-key tweaks, pr-agent workflow update. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): implement tax reduction features and localization updates * feat(tests): add VAT registration gate to pending operations commit tests --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
21cfcbe180
commit
f9ea9c0082
@@ -4,7 +4,8 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateArticleSchema } from '@/lib/api/schemas'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
|
||||
import { checkRevenueAccount } from '@/lib/articles/validate-revenue-account'
|
||||
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { Article } from '@/types'
|
||||
|
||||
@@ -53,9 +54,14 @@ export const PATCH = withRouteContext(
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
// Same activate-and-retry contract as POST /api/articles: a class-3 account
|
||||
// that just isn't activated yet returns ACCOUNTS_NOT_IN_CHART.
|
||||
if (body.revenue_account) {
|
||||
const ok = await isValidRevenueAccount(supabase, companyId!, body.revenue_account)
|
||||
if (!ok) {
|
||||
const status = await checkRevenueAccount(supabase, companyId!, body.revenue_account)
|
||||
if (status === 'activatable') {
|
||||
return accountsNotInChartResponse(new AccountsNotInChartError([body.revenue_account]))
|
||||
}
|
||||
if (status === 'invalid') {
|
||||
return errorResponseFromCode('ARTICLE_REVENUE_ACCOUNT_INVALID', opLog, { requestId })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,42 @@ describe('GET/PATCH/DELETE /api/articles/[id]', () => {
|
||||
expect(body.data.price_excl_vat).toBe(1500)
|
||||
})
|
||||
|
||||
it('PATCH answers ACCOUNTS_NOT_IN_CHART for a BAS class-3 account missing from the chart', async () => {
|
||||
// chart_of_accounts lookup: no row, but 3999 is a known BAS class-3
|
||||
// account → activatable via the activate-and-retry dialog flow.
|
||||
enqueue({ data: null })
|
||||
|
||||
const request = createMockRequest('/api/articles/a1', {
|
||||
method: 'PATCH',
|
||||
body: { revenue_account: '3999' },
|
||||
})
|
||||
|
||||
const response = await PATCH(request, createMockRouteParams({ id: 'a1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; account_numbers: string[] }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
|
||||
expect(body.error.account_numbers).toEqual(['3999'])
|
||||
})
|
||||
|
||||
it('PATCH rejects a 3xxx revenue_account unknown to both chart and BAS catalogue', async () => {
|
||||
// No chart row and 3041 is not in the BAS reference → invalid, no dialog.
|
||||
enqueue({ data: null })
|
||||
|
||||
const request = createMockRequest('/api/articles/a1', {
|
||||
method: 'PATCH',
|
||||
body: { revenue_account: '3041' },
|
||||
})
|
||||
|
||||
const response = await PATCH(request, createMockRouteParams({ id: 'a1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ARTICLE_REVENUE_ACCOUNT_INVALID')
|
||||
})
|
||||
|
||||
it('DELETE soft-deactivates and returns success', async () => {
|
||||
enqueue({ data: { id: 'a1', active: false } })
|
||||
|
||||
|
||||
@@ -60,8 +60,27 @@ describe('GET/POST /api/articles', () => {
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('POST rejects a revenue_account that is not an active class-3 account', async () => {
|
||||
// chart_of_accounts lookup returns no row → override is invalid.
|
||||
it('POST rejects a 3xxx revenue_account unknown to both chart and BAS catalogue', async () => {
|
||||
// Non-3xxx numbers are already stopped by the Zod schema; the route-level
|
||||
// 'invalid' branch covers 3xxx numbers that exist nowhere — no chart row
|
||||
// and not in the BAS reference (3041 is not a BAS 2026 account).
|
||||
enqueue({ data: null })
|
||||
|
||||
const request = createMockRequest('/api/articles', {
|
||||
method: 'POST',
|
||||
body: { name: 'Frakt', price_excl_vat: 100, revenue_account: '3041' },
|
||||
})
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ARTICLE_REVENUE_ACCOUNT_INVALID')
|
||||
})
|
||||
|
||||
it('POST answers ACCOUNTS_NOT_IN_CHART for a BAS class-3 account missing from the chart', async () => {
|
||||
// No chart row, but 3999 is a known BAS class-3 account → activatable, so
|
||||
// the client can run the activate-and-retry dialog flow.
|
||||
enqueue({ data: null })
|
||||
|
||||
const request = createMockRequest('/api/articles', {
|
||||
@@ -69,11 +88,47 @@ describe('GET/POST /api/articles', () => {
|
||||
body: { name: 'Frakt', price_excl_vat: 100, revenue_account: '3999' },
|
||||
})
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; account_numbers: string[] }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
|
||||
expect(body.error.account_numbers).toEqual(['3999'])
|
||||
})
|
||||
|
||||
it('POST answers ACCOUNTS_NOT_IN_CHART for an inactive class-3 chart account', async () => {
|
||||
enqueue({ data: { account_class: 3, is_active: false } })
|
||||
|
||||
const request = createMockRequest('/api/articles', {
|
||||
method: 'POST',
|
||||
body: { name: 'Frakt', price_excl_vat: 100, revenue_account: '3001' },
|
||||
})
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ARTICLE_REVENUE_ACCOUNT_INVALID')
|
||||
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
|
||||
})
|
||||
|
||||
it('POST accepts a revenue_account that is active class 3 in the chart', async () => {
|
||||
// 1st DB hit: chart_of_accounts lookup → active class-3 row.
|
||||
enqueue({ data: { account_class: 3, is_active: true } })
|
||||
// 2nd DB hit: insert ... returning the row.
|
||||
enqueue({ data: { id: 'a1', name: 'Frakt', article_number: '3', revenue_account: '3001' } })
|
||||
|
||||
const request = createMockRequest('/api/articles', {
|
||||
method: 'POST',
|
||||
body: { name: 'Frakt', price_excl_vat: 100, revenue_account: '3001' },
|
||||
})
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ data: { revenue_account: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.revenue_account).toBe('3001')
|
||||
})
|
||||
|
||||
it('POST creates an article and auto-assigns a number', async () => {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateArticleSchema } from '@/lib/api/schemas'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
|
||||
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
|
||||
import { checkRevenueAccount } from '@/lib/articles/validate-revenue-account'
|
||||
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { Article } from '@/types'
|
||||
|
||||
@@ -50,9 +51,14 @@ export const POST = withRouteContext(
|
||||
const body = result.data
|
||||
|
||||
// Guard the optional revenue-account override against the chart of accounts.
|
||||
// A class-3 account that merely isn't activated yet gets the standard
|
||||
// ACCOUNTS_NOT_IN_CHART envelope so the client can offer activate-and-retry.
|
||||
if (body.revenue_account) {
|
||||
const ok = await isValidRevenueAccount(supabase, companyId!, body.revenue_account)
|
||||
if (!ok) {
|
||||
const status = await checkRevenueAccount(supabase, companyId!, body.revenue_account)
|
||||
if (status === 'activatable') {
|
||||
return accountsNotInChartResponse(new AccountsNotInChartError([body.revenue_account]))
|
||||
}
|
||||
if (status === 'invalid') {
|
||||
return errorResponseFromCode('ARTICLE_REVENUE_ACCOUNT_INVALID', log, { requestId })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ function mockChain(result: ChainResult) {
|
||||
return chain
|
||||
}
|
||||
|
||||
function mkReq() {
|
||||
return new Request('http://localhost/api/bookkeeping/voucher-sequences/next')
|
||||
function mkReq(query = '') {
|
||||
return new Request(`http://localhost/api/bookkeeping/voucher-sequences/next${query}`)
|
||||
}
|
||||
|
||||
function mkParams() {
|
||||
@@ -142,4 +142,92 @@ describe('GET /api/bookkeeping/voucher-sequences/next', () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toEqual({ next: 13, series: 'V', fiscal_period_id: 'period-2' })
|
||||
})
|
||||
|
||||
it('resolves the series per source_type, matching the booking engine', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
mockFrom.mockImplementation((table: string) => {
|
||||
if (table === 'fiscal_periods') {
|
||||
return mockChain({ data: { id: 'period-1' }, error: null })
|
||||
}
|
||||
if (table === 'company_settings') {
|
||||
return mockChain({
|
||||
data: {
|
||||
default_voucher_series: 'A',
|
||||
default_voucher_series_per_source_type: { invoice_cash_payment: 'V' },
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
if (table === 'voucher_sequences') {
|
||||
return mockChain({ data: { last_number: 4 }, error: null })
|
||||
}
|
||||
throw new Error(`Unexpected table: ${table}`)
|
||||
})
|
||||
|
||||
const response = await GET(mkReq('?source_type=invoice_cash_payment'), mkParams())
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
// Uses the per-source-type map (V), NOT the global default (A).
|
||||
expect(body.data).toEqual({ next: 5, series: 'V', fiscal_period_id: 'period-1' })
|
||||
})
|
||||
|
||||
it('falls back to A when the source_type has no per-source-type mapping', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
mockFrom.mockImplementation((table: string) => {
|
||||
if (table === 'fiscal_periods') {
|
||||
return mockChain({ data: { id: 'period-1' }, error: null })
|
||||
}
|
||||
if (table === 'company_settings') {
|
||||
return mockChain({
|
||||
data: {
|
||||
default_voucher_series: 'V',
|
||||
default_voucher_series_per_source_type: { invoice_paid: 'B' },
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
if (table === 'voucher_sequences') {
|
||||
return mockChain({ data: null, error: null })
|
||||
}
|
||||
throw new Error(`Unexpected table: ${table}`)
|
||||
})
|
||||
|
||||
const response = await GET(mkReq('?source_type=invoice_cash_payment'), mkParams())
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
// No mapping for invoice_cash_payment → engine-matching fallback of 'A'
|
||||
// (the global default is intentionally NOT used here — no consolidation).
|
||||
expect(body.data).toEqual({ next: 1, series: 'A', fiscal_period_id: 'period-1' })
|
||||
})
|
||||
|
||||
it('rejects an unknown source_type with 400 before touching the database', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
const response = await GET(mkReq('?source_type=not_a_source_type'), mkParams())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockFrom).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a malformed date with 400 before touching the database', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
const response = await GET(mkReq('?date=2026-13-99x'), mkParams())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockFrom).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a malformed series with 400 before touching the database', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
const response = await GET(mkReq('?series=AB'), mkParams())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockFrom).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { validateQuery } from '@/lib/api/validate'
|
||||
import { VoucherSequenceNextQuerySchema } from '@/lib/api/schemas'
|
||||
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'voucher_sequence.next',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const url = new URL(request.url)
|
||||
const overridePeriodId = url.searchParams.get('period_id')
|
||||
const overrideSeries = url.searchParams.get('series')
|
||||
const query = validateQuery(request, VoucherSequenceNextQuerySchema, {
|
||||
log,
|
||||
operation: 'voucher_sequence.next',
|
||||
})
|
||||
if (!query.success) return query.response
|
||||
const { period_id: overridePeriodId, series: overrideSeries, source_type: sourceType } = query.data
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
// Vouchers are numbered per fiscal period, so the preview must reflect the
|
||||
// period of the entry's date (e.g. a back-dated payment), not today's.
|
||||
const date = query.data.date || today
|
||||
|
||||
const [{ data: period, error: periodError }, { data: settings, error: settingsError }] =
|
||||
await Promise.all([
|
||||
@@ -26,14 +35,14 @@ export const GET = withRouteContext(
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.lte('period_start', today)
|
||||
.gte('period_end', today)
|
||||
.lte('period_start', date)
|
||||
.gte('period_end', date)
|
||||
.maybeSingle(),
|
||||
overrideSeries
|
||||
? Promise.resolve({ data: null, error: null })
|
||||
: supabase
|
||||
.from('company_settings')
|
||||
.select('default_voucher_series')
|
||||
.select('default_voucher_series, default_voucher_series_per_source_type')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
])
|
||||
@@ -47,7 +56,15 @@ export const GET = withRouteContext(
|
||||
return errorResponse(settingsError, log, { requestId })
|
||||
}
|
||||
|
||||
const series = overrideSeries || settings?.default_voucher_series || 'A'
|
||||
// When a source_type is supplied, resolve the series exactly as the booking
|
||||
// engine does (per-source-type map → 'A'), so the preview can never disagree
|
||||
// with the verifikat that actually gets created. Without a source_type, keep
|
||||
// the legacy generic default for callers that just want "the next number".
|
||||
const series = overrideSeries
|
||||
? overrideSeries
|
||||
: sourceType
|
||||
? resolveDefaultSeriesForSource(settings, sourceType)
|
||||
: settings?.default_voucher_series || 'A'
|
||||
|
||||
if (!period) {
|
||||
return NextResponse.json({ data: { next: null, series, fiscal_period_id: null } })
|
||||
|
||||
@@ -98,9 +98,10 @@ export async function POST(
|
||||
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const items = (proforma.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }) => ({
|
||||
const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number }) => ({
|
||||
invoice_id: invoice.id,
|
||||
sort_order: item.sort_order,
|
||||
line_type: item.line_type ?? 'product',
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
@@ -164,7 +165,10 @@ export const POST = withRouteContext(
|
||||
details: { paymentDate },
|
||||
})
|
||||
}
|
||||
const sourceType = useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
|
||||
const sourceType = resolveInvoicePaymentSourceType({
|
||||
invoiceAlreadyBooked,
|
||||
accountingMethod,
|
||||
})
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: paymentDate,
|
||||
|
||||
@@ -188,6 +188,8 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
|
||||
// Fetch customer
|
||||
enqueue({ data: customer, error: null })
|
||||
// company_settings.vat_registered gate (registered → VAT flows as before)
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
// Insert invoice (number is null on insert; allocated immediately after items)
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Insert items
|
||||
@@ -239,6 +241,8 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
|
||||
// Fetch customer
|
||||
enqueue({ data: customer, error: null })
|
||||
// company_settings.vat_registered gate (registered → VAT flows as before)
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
// Insert invoice (stays unnumbered — the allocation step is skipped)
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Insert items
|
||||
@@ -288,6 +292,8 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
])
|
||||
|
||||
enqueue({ data: customer, error: null })
|
||||
// company_settings.vat_registered gate (registered → VAT flows as before)
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Items insertion fails
|
||||
enqueue({ data: null, error: { message: 'Items insert failed' } })
|
||||
@@ -330,6 +336,8 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
])
|
||||
|
||||
enqueue({ data: customer, error: null })
|
||||
// company_settings.vat_registered gate (registered → VAT flows as before)
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Items insertion succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
@@ -123,11 +123,32 @@ export const POST = withRouteContext(
|
||||
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const allowedRates = new Set(availableRates.map((r) => r.rate))
|
||||
|
||||
const subtotal = invoiceInput.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
|
||||
// VAT registration gate (defense in depth — the invoice form already hides
|
||||
// the Moms column when vat_registered is false). A non-momsregistrerad
|
||||
// company books no output VAT: zero every line rate so the sale lands as
|
||||
// momsfri (treatment 'exempt' → revenue 3004/3100, no 2611). 0% is a valid
|
||||
// rate for every customer type, so the allowedRates guard below still
|
||||
// passes. Mirrors lib/pending-operations/commit.ts commitCreateInvoice.
|
||||
const { data: vatSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('vat_registered')
|
||||
.eq('company_id', companyId!)
|
||||
.maybeSingle()
|
||||
const notVatRegistered = vatSettings?.vat_registered === false
|
||||
if (notVatRegistered && documentType !== 'delivery_note') {
|
||||
for (const item of invoiceInput.items) item.vat_rate = 0
|
||||
}
|
||||
|
||||
// Free-text rows carry no amounts and are excluded from totals + VAT.
|
||||
const subtotal = invoiceInput.items.reduce(
|
||||
(sum, item) => (item.line_type === 'text' ? sum : sum + item.quantity * item.unit_price),
|
||||
0,
|
||||
)
|
||||
|
||||
let vatAmount = 0
|
||||
if (documentType !== 'delivery_note') {
|
||||
for (const item of invoiceInput.items) {
|
||||
if (item.line_type === 'text') continue
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
if (!allowedRates.has(itemRate)) {
|
||||
return errorResponseFromCode('INVOICE_CREATE_VAT_RULE_VIOLATION', log, {
|
||||
@@ -225,7 +246,11 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueRates = new Set(invoiceInput.items.map((item) => item.vat_rate ?? vatRules.rate))
|
||||
const uniqueRates = new Set(
|
||||
invoiceInput.items
|
||||
.filter((item) => item.line_type !== 'text')
|
||||
.map((item) => item.vat_rate ?? vatRules.rate),
|
||||
)
|
||||
const isMixedRate = uniqueRates.size > 1
|
||||
|
||||
let exchangeRate: number | null = null
|
||||
@@ -280,10 +305,10 @@ export const POST = withRouteContext(
|
||||
// Proformas, delivery notes and quotes have no payment obligation,
|
||||
// so they keep the 0 default.
|
||||
remaining_amount: documentType === 'invoice' ? total - deductionTotal : 0,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_treatment: notVatRegistered ? 'exempt' : vatRules.treatment,
|
||||
vat_rate: documentType === 'delivery_note' ? 0 : (isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)),
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
moms_ruta: notVatRegistered ? null : vatRules.momsRuta,
|
||||
reverse_charge_text: notVatRegistered ? null : (vatRules.reverseChargeText || null),
|
||||
your_reference: invoiceInput.your_reference,
|
||||
our_reference: invoiceInput.our_reference,
|
||||
notes: invoiceInput.notes,
|
||||
@@ -304,6 +329,32 @@ export const POST = withRouteContext(
|
||||
}
|
||||
|
||||
const items = invoiceInput.items.map((item, index) => {
|
||||
// Free-text / blank rows carry no amounts and never book — store the
|
||||
// description only and zero everything else.
|
||||
if (item.line_type === 'text') {
|
||||
return {
|
||||
invoice_id: invoice.id,
|
||||
sort_order: index,
|
||||
line_type: 'text',
|
||||
description: item.description ?? '',
|
||||
quantity: 0,
|
||||
unit: '',
|
||||
unit_price: 0,
|
||||
line_total: 0,
|
||||
vat_rate: 0,
|
||||
vat_amount: 0,
|
||||
// Keys must match the product branch exactly — PostgREST rejects a
|
||||
// bulk insert whose objects have differing key sets.
|
||||
article_id: null,
|
||||
revenue_account: null,
|
||||
deduction_type: null,
|
||||
deduction_amount: 0,
|
||||
labor_hours: null,
|
||||
work_type: null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
}
|
||||
}
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const itemVat = documentType === 'delivery_note' ? 0 : Math.round(lineTotal * itemRate / 100 * 100) / 100
|
||||
@@ -322,6 +373,7 @@ export const POST = withRouteContext(
|
||||
return {
|
||||
invoice_id: invoice.id,
|
||||
sort_order: index,
|
||||
line_type: 'product',
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
@@ -509,9 +561,10 @@ async function createCreditNote(
|
||||
})
|
||||
}
|
||||
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null }) => ({
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null }) => ({
|
||||
invoice_id: creditNote.id,
|
||||
sort_order: item.sort_order,
|
||||
line_type: item.line_type ?? 'product',
|
||||
description: item.description,
|
||||
quantity: -Math.abs(item.quantity),
|
||||
unit: item.unit,
|
||||
|
||||
@@ -255,6 +255,7 @@ describe('POST /api/pending-operations/:id/commit', () => {
|
||||
{ data: pendingOp }, // fetch pending op
|
||||
{ data: { id: 'op-1' } }, // CAS claim
|
||||
{ data: customer }, // fetch customer
|
||||
{ data: { vat_registered: true } }, // company_settings VAT registration gate
|
||||
{ data: { id: 'inv-1', invoice_number: null } }, // insert invoice (no number — assigned at send)
|
||||
{ data: null, error: null }, // insert items
|
||||
{ data: { id: 'inv-1', invoice_number: null, customer: customer, items: [] } }, // fetch complete invoice
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
// The route is wrapped in withRouteContext (auth via requireAuth, company via
|
||||
// getActiveCompanyId, write gate via requireWritePermission) — mock those.
|
||||
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) } }))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
function authed(supabase: unknown) {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: mockUser as never,
|
||||
supabase: supabase as never,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** A salary_run_employees row joined with its employee, as the route selects it. */
|
||||
function runEmp(opts: {
|
||||
first_name: string
|
||||
last_name: string
|
||||
net_salary: number
|
||||
tax_withheld?: number
|
||||
tax_withheld_override?: number | null
|
||||
clearing_number?: string | null
|
||||
bank_account_number?: string | null
|
||||
}) {
|
||||
return {
|
||||
net_salary: opts.net_salary,
|
||||
tax_withheld: opts.tax_withheld ?? 0,
|
||||
tax_withheld_override: opts.tax_withheld_override ?? null,
|
||||
calculation_breakdown: { steps: [] },
|
||||
employee: {
|
||||
first_name: opts.first_name,
|
||||
last_name: opts.last_name,
|
||||
clearing_number: opts.clearing_number ?? null,
|
||||
bank_account_number: opts.bank_account_number ?? null,
|
||||
email: 'employee@example.com',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('POST /api/salary/runs/[id]/approve — bank-detail guard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('approves a nollkörning where a zero-net employee has no bank details', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
authed(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', status: 'review', company_id: 'company-1' } }, // run lookup
|
||||
{
|
||||
data: [
|
||||
runEmp({ first_name: 'Test', last_name: 'Testsson', net_salary: 0 }),
|
||||
runEmp({ first_name: 'Anna', last_name: 'Exempelsson', net_salary: 0 }),
|
||||
],
|
||||
}, // run employees
|
||||
{ data: { id: 'run-1', status: 'approved' } }, // update
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/approve', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.status).toBe('approved')
|
||||
})
|
||||
|
||||
it('still blocks when an employee who is actually paid has no bank details', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
authed(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', status: 'review', company_id: 'company-1' } },
|
||||
{
|
||||
data: [
|
||||
// Paid 24 000 but no clearing/account → must block.
|
||||
runEmp({ first_name: 'Test', last_name: 'Testsson', net_salary: 24000, tax_withheld: 8000 }),
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/approve', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string; details: string[] }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.details).toHaveLength(1)
|
||||
expect(body.details[0]).toContain('Test Testsson')
|
||||
expect(body.details[0]).toContain('Bankuppgifter saknas')
|
||||
})
|
||||
|
||||
it('approves a mixed run: pays the one with bank details, ignores the zero-net one without', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
authed(supabase)
|
||||
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', status: 'review', company_id: 'company-1' } },
|
||||
{
|
||||
data: [
|
||||
runEmp({
|
||||
first_name: 'Anna',
|
||||
last_name: 'Exempelsson',
|
||||
net_salary: 24000,
|
||||
tax_withheld: 8000,
|
||||
clearing_number: '8327',
|
||||
bank_account_number: '1234567',
|
||||
}),
|
||||
// Zero payout, no bank details — should not block.
|
||||
runEmp({ first_name: 'Test', last_name: 'Testsson', net_salary: 0 }),
|
||||
],
|
||||
},
|
||||
{ data: { id: 'run-1', status: 'approved' } },
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/approve', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { effectiveNetPayout } from '@/lib/salary/payment/effective-net'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -44,8 +45,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
if (!emp) continue
|
||||
const name = `${emp.first_name} ${emp.last_name}`
|
||||
|
||||
// Bank details required for payment
|
||||
if (!emp.clearing_number || !emp.bank_account_number) {
|
||||
// Bank details are only required when there's an actual payout. A zero
|
||||
// net (nollkörning, or fully net-deducted) produces no payment-file line,
|
||||
// so no destination account is needed — mirrors the pain.001 / BG-LB
|
||||
// generators, which only include employees with effectiveNet > 0.
|
||||
if (effectiveNetPayout(sre) > 0 && (!emp.clearing_number || !emp.bank_account_number)) {
|
||||
validationErrors.push(`${name}: Bankuppgifter saknas (clearingnummer och/eller kontonummer)`)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { generateBgLb } from '@/lib/salary/payment/bg-lb-generator'
|
||||
import { effectiveNetPayout } from '@/lib/salary/payment/effective-net'
|
||||
import { validateBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
import type { BgLbCompanyData, BgLbEmployee } from '@/lib/salary/payment/bg-lb-generator'
|
||||
|
||||
@@ -87,7 +88,11 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Only employees with a positive payout end up in the file (see filter
|
||||
// below), so missing bank details must only block when they're actually
|
||||
// being paid — a zero-net employee needs no destination account.
|
||||
const missingBank = runEmployees.filter((sre) => {
|
||||
if (effectiveNetPayout(sre) <= 0) return false
|
||||
const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null
|
||||
return !emp?.clearing_number || !emp?.bank_account_number
|
||||
})
|
||||
@@ -105,13 +110,9 @@ export async function GET(
|
||||
}
|
||||
|
||||
const employees: BgLbEmployee[] = runEmployees
|
||||
.map((sre) => {
|
||||
// Honor tax override on the bank payment file too — the net the
|
||||
// employee actually receives depends on the effective tax.
|
||||
const effectiveNet =
|
||||
sre.net_salary + (sre.tax_withheld - (sre.tax_withheld_override ?? sre.tax_withheld))
|
||||
return { sre, effectiveNet }
|
||||
})
|
||||
// Honor tax override on the bank payment file too — the net the employee
|
||||
// actually receives depends on the effective tax.
|
||||
.map((sre) => ({ sre, effectiveNet: effectiveNetPayout(sre) }))
|
||||
.filter(({ effectiveNet }) => effectiveNet > 0)
|
||||
.map(({ sre, effectiveNet }) => {
|
||||
const emp = sre.employee as {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { generatePain001 } from '@/lib/salary/payment/pain001-generator'
|
||||
import { effectiveNetPayout } from '@/lib/salary/payment/effective-net'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import type { Pain001CompanyData, Pain001Employee } from '@/lib/salary/payment/pain001-generator'
|
||||
|
||||
@@ -78,8 +79,11 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate all employees have bank accounts
|
||||
// Validate bank accounts — but only for employees who will actually appear
|
||||
// in the file (positive payout). A zero-net employee is filtered out below,
|
||||
// so missing bank details for them must not block the file.
|
||||
const missingBank = runEmployees.filter(sre => {
|
||||
if (effectiveNetPayout(sre) <= 0) return false
|
||||
const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null
|
||||
return !emp?.clearing_number || !emp?.bank_account_number
|
||||
})
|
||||
@@ -98,11 +102,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const employees: Pain001Employee[] = runEmployees
|
||||
.map(sre => {
|
||||
const effectiveNet =
|
||||
sre.net_salary + (sre.tax_withheld - (sre.tax_withheld_override ?? sre.tax_withheld))
|
||||
return { sre, effectiveNet }
|
||||
})
|
||||
.map(sre => ({ sre, effectiveNet: effectiveNetPayout(sre) }))
|
||||
.filter(({ effectiveNet }) => effectiveNet > 0)
|
||||
.map(({ sre, effectiveNet }) => {
|
||||
const emp = sre.employee as { first_name: string; last_name: string; clearing_number: string; bank_account_number: string }
|
||||
|
||||
Reference in New Issue
Block a user