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:
Mattsson
2026-06-10 13:52:24 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 21cfcbe180
commit f9ea9c0082
54 changed files with 2725 additions and 532 deletions
+9 -3
View File
@@ -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 })
}
}
+36
View File
@@ -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 } })
+58 -3
View File
@@ -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 () => {
+9 -3
View File
@@ -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 } })
+2 -1
View File
@@ -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,
+5 -1
View File
@@ -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,
+8
View File
@@ -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 })
+59 -6
View File
@@ -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)
})
})
+6 -2
View File
@@ -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 }