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
+32
View File
@@ -397,6 +397,38 @@ describe('CreateInvoiceItemSchema', () => {
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ quantity: 'ten' }))
expect(result.success).toBe(false)
})
it('rejects a product row with an empty description', () => {
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ description: ' ' }))
expect(result.success).toBe(false)
})
it('rejects a product row with non-positive quantity', () => {
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ quantity: 0 }))
expect(result.success).toBe(false)
})
it('accepts a free-text row with an empty description and zero amounts', () => {
const result = CreateInvoiceItemSchema.safeParse({
line_type: 'text',
description: '',
quantity: 0,
unit: '',
unit_price: 0,
})
expect(result.success).toBe(true)
})
it('accepts a free-text row carrying explanatory text', () => {
const result = CreateInvoiceItemSchema.safeParse({
line_type: 'text',
description: 'Arbetet utfört enligt offert 2026-04',
quantity: 0,
unit: '',
unit_price: 0,
})
expect(result.success).toBe(true)
})
})
describe('CreateCreditNoteSchema', () => {
+48 -20
View File
@@ -147,6 +147,14 @@ export const JournalEntrySourceTypeSchema = z.enum([
'reminder_fee',
])
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
export const VoucherSequenceNextQuerySchema = z.object({
period_id: uuid.optional(),
series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z').optional(),
source_type: JournalEntrySourceTypeSchema.optional(),
date: isoDate.optional(),
})
export const AccountTypeSchema = z.enum([
'asset', 'equity', 'liability', 'revenue', 'expense',
])
@@ -192,26 +200,46 @@ export const DocumentUploadSourceSchema = z.enum([
// Invoice schemas
// ============================================================
export const CreateInvoiceItemSchema = z.object({
description: z.string().min(1, 'Item description is required'),
quantity: z.number().positive('Quantity must be positive'),
unit: z.string().min(1, 'Unit is required'),
unit_price: z.number(),
vat_rate: z.number().min(0).max(100).optional(),
// Article linkage. `article_id` ties the line to a catalog article (free-text
// lines omit it). `revenue_account` is the optional BAS class-3 override the
// engine books to; the API validates it against chart_of_accounts before use.
article_id: uuid.nullable().optional(),
revenue_account: revenueAccount.nullable().optional(),
// ROT/RUT-avdrag fields. `deduction_amount` is intentionally omitted from
// the client schema — the API computes it from rot-rut-rules.ts so a
// tampered client can't expand the 1513 receivable beyond the line total.
deduction_type: z.enum(['rot', 'rut']).nullable().optional(),
labor_hours: z.number().nonnegative().nullable().optional(),
work_type: z.string().max(64).nullable().optional(),
housing_designation: z.string().max(128).nullable().optional(),
apartment_number: z.string().max(32).nullable().optional(),
})
export const CreateInvoiceItemSchema = z
.object({
// 'text' = free-text or blank spacer row: description only, amounts ignored
// and excluded from totals/bookkeeping. Defaults to 'product'. Callers still
// send quantity/unit/unit_price for text rows (the form sends 0/''/0), so
// the inferred shape stays consistent for downstream code.
line_type: z.enum(['product', 'text']).optional(),
description: z.string().max(2000),
quantity: z.number(),
unit: z.string(),
unit_price: z.number(),
vat_rate: z.number().min(0).max(100).optional(),
// Article linkage. `article_id` ties the line to a catalog article (text
// rows omit it). `revenue_account` is the optional BAS class-3 override the
// engine books to; the API validates it against chart_of_accounts before use.
article_id: uuid.nullable().optional(),
revenue_account: revenueAccount.nullable().optional(),
// ROT/RUT-avdrag fields. `deduction_amount` is intentionally omitted from
// the client schema — the API computes it from rot-rut-rules.ts so a
// tampered client can't expand the 1513 receivable beyond the line total.
deduction_type: z.enum(['rot', 'rut']).nullable().optional(),
labor_hours: z.number().nonnegative().nullable().optional(),
work_type: z.string().max(64).nullable().optional(),
housing_designation: z.string().max(128).nullable().optional(),
apartment_number: z.string().max(32).nullable().optional(),
})
.superRefine((item, ctx) => {
// Free-text rows skip the product-line requirements (description may be
// empty for a spacer; quantity/unit/price are ignored).
if (item.line_type === 'text') return
if (item.description.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['description'], message: 'Item description is required' })
}
if (item.quantity <= 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['quantity'], message: 'Quantity must be positive' })
}
if (item.unit.length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['unit'], message: 'Unit is required' })
}
})
const optionalIsoDate = isoDate.or(z.literal('')).transform(v => v || undefined).optional()
+41
View File
@@ -1,4 +1,45 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
/**
* Classify a per-article revenue-account override against the company's chart:
*
* - 'ok' — active class-3 account in the chart; accept as-is.
* - 'activatable' — a class-3 account that is merely missing/inactive: either
* an inactive chart row or a known BAS class-3 number not yet
* in the chart. Routes translate this to ACCOUNTS_NOT_IN_CHART
* so the standard activate-and-retry dialog flow applies
* (same UX as the journal entry form).
* - 'invalid' — anything else: a non-revenue account or a number unknown to
* both the chart and the BAS catalogue. Never bookable.
*
* Throws on an unexpected DB error so the route wrapper maps it to the canonical
* envelope.
*/
export type RevenueAccountStatus = 'ok' | 'activatable' | 'invalid'
export async function checkRevenueAccount(
supabase: SupabaseClient,
companyId: string,
account: string,
): Promise<RevenueAccountStatus> {
const { data, error } = await supabase
.from('chart_of_accounts')
.select('account_class, is_active')
.eq('company_id', companyId)
.eq('account_number', account)
.maybeSingle()
if (error) throw error
if (data) {
if (data.account_class !== 3) return 'invalid'
return data.is_active ? 'ok' : 'activatable'
}
const ref = getBASReference(account)
return ref?.account_class === 3 ? 'activatable' : 'invalid'
}
/**
* True when `account` exists in the company's chart of accounts as an ACTIVE
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import {
applyDefaultSeriesToMap,
formatVoucher,
parseVoucher,
resolveDefaultSeriesForSource,
@@ -96,6 +97,47 @@ describe('resolveDefaultSeriesForSource', () => {
})
})
describe('applyDefaultSeriesToMap', () => {
it('moves types following the old default onto the new default', () => {
const result = applyDefaultSeriesToMap(
{ manual: 'A', invoice_paid: 'A', invoice_cash_payment: 'A' },
'A',
'V',
)
expect(result).toEqual({ manual: 'V', invoice_paid: 'V', invoice_cash_payment: 'V' })
})
it('preserves explicit per-type overrides that differ from the old default', () => {
const result = applyDefaultSeriesToMap(
{ manual: 'A', supplier_invoice_paid: 'B', salary_payment: 'C' },
'A',
'V',
)
// Only the type that was following the old default (A) moves; B and C stay.
expect(result).toEqual({ manual: 'V', supplier_invoice_paid: 'B', salary_payment: 'C' })
})
it('does not mutate the input map', () => {
const input = { manual: 'A', invoice_paid: 'A' }
applyDefaultSeriesToMap(input, 'A', 'V')
expect(input).toEqual({ manual: 'A', invoice_paid: 'A' })
})
it('returns an empty map when given null/undefined', () => {
expect(applyDefaultSeriesToMap(null, 'A', 'V')).toEqual({})
expect(applyDefaultSeriesToMap(undefined, 'A', 'V')).toEqual({})
})
it('is a no-op on values when old and new default are equal', () => {
const result = applyDefaultSeriesToMap(
{ manual: 'A', supplier_invoice_paid: 'B' },
'A',
'A',
)
expect(result).toEqual({ manual: 'A', supplier_invoice_paid: 'B' })
})
})
describe('formatVoucher', () => {
it('formats series + number for a posted entry', () => {
expect(formatVoucher({ voucher_series: 'A', voucher_number: 1 })).toBe('A1')
+4
View File
@@ -60,6 +60,10 @@ function generatePerRateLines(
const lines: CreateJournalEntryLineInput[] = []
const isForeign = currency != null && currency !== 'SEK'
// Free-text / blank rows carry no amounts and never book — drop them before
// grouping so they can't produce a zero-amount revenue line.
items = items.filter((item) => item.line_type !== 'text')
// Helper: convert item amount to SEK when dealing with foreign currency
const toSek = (amount: number): number => {
if (!isForeign) return amount
+27 -6
View File
@@ -35,6 +35,25 @@ function toFormAmount(n: number): string {
return rounded === 0 ? '' : rounded.toString()
}
/**
* Resolve the journal_entries.source_type used when booking an invoice payment.
*
* Mirrors the branching in app/api/invoices/[id]/mark-paid/route.ts: revenue is
* only recognised at payment (kontantmetoden / invoice_cash_payment) when the
* invoice has no prior issuance verifikat AND the company is on the cash method.
* Otherwise the payment clears the receivable (invoice_paid).
*
* Shared so the dialog's voucher preview and the route's actual booking always
* resolve the same series — they must not drift.
*/
export function resolveInvoicePaymentSourceType(opts: {
invoiceAlreadyBooked: boolean
accountingMethod: 'accrual' | 'cash'
}): 'invoice_cash_payment' | 'invoice_paid' {
const useCashEntry = !opts.invoiceAlreadyBooked && opts.accountingMethod === 'cash'
return useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
}
/**
* Propose journal entry lines for an invoice payment.
*
@@ -134,16 +153,18 @@ function proposeCashLines(
return amount
}
// Build credit lines per VAT rate group
// Build credit lines per VAT rate group. Free-text / blank rows carry no
// amounts and never book — drop them first.
const creditLines: FormLine[] = []
const billableItems = (invoice.items ?? []).filter((item) => item.line_type !== 'text')
if (invoice.items && invoice.items.length > 0) {
const hasPerLineVat = invoice.items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
if (billableItems.length > 0) {
const hasPerLineVat = billableItems.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
if (!hasPerLineVat) {
// Legacy: single rate from invoice level
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
const subtotal = invoice.items.reduce((sum, item) => sum + item.line_total, 0)
const subtotal = billableItems.reduce((sum, item) => sum + item.line_total, 0)
creditLines.push({
account_number: revenueAccount,
debit_amount: '',
@@ -151,7 +172,7 @@ function proposeCashLines(
line_description: (invoice.invoice_number ? `Försäljning faktura ${invoice.invoice_number}` : 'Försäljning faktura'),
})
const totalVat = invoice.items.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
const totalVat = billableItems.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
if (totalVat > 0) {
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
creditLines.push({
@@ -164,7 +185,7 @@ function proposeCashLines(
} else {
// Group items by vat_rate
const rateGroups = new Map<number, { subtotal: number; vatAmount: number }>()
for (const item of invoice.items) {
for (const item of billableItems) {
const rate = item.vat_rate ?? 0
const group = rateGroups.get(rate) || { subtotal: 0, vatAmount: 0 }
group.subtotal += item.line_total
@@ -60,6 +60,29 @@ export function resolveDefaultSeriesForSource(
return 'A'
}
/**
* Propagate a change to the global default voucher series across the
* per-source-type map. Source types that were still following the previous
* default move to the new default; explicit overrides (values that differ from
* the previous default) are preserved untouched.
*
* The booking engine resolves series from the per-source-type map, not from the
* global default, so the bookkeeping settings form calls this when the user
* changes the "Standardserie" dropdown — otherwise that control would be a
* no-op for bookkeeping. Pure; returns the next map (input is not mutated).
*/
export function applyDefaultSeriesToMap(
currentMap: VoucherSeriesMap | null | undefined,
prevDefault: string,
nextDefault: string,
): VoucherSeriesMap {
const out: VoucherSeriesMap = {}
for (const [key, value] of Object.entries(currentMap || {})) {
out[key] = value === prevDefault ? nextDefault : value
}
return out
}
/**
* Format a voucher (series + number) for UI display. Returns "—" when the
* voucher number is null (e.g. a draft entry that has not been committed yet).
@@ -114,6 +114,39 @@ describe('getErrorMessage — English locale uses registry English (C9)', () =>
})
})
describe('getErrorMessage — accumulated validation details', () => {
it('surfaces the specific per-item reasons instead of the generic 400 message', () => {
const msg = getErrorMessage(
{
error: 'Valideringsfel — korrigera innan godkännande',
details: ['Tomas Tysén: Bankuppgifter saknas (clearingnummer och/eller kontonummer)'],
warnings: [],
},
{ context: 'salary', statusCode: 400 },
)
expect(msg).toContain('Tomas Tysén')
expect(msg).toContain('Bankuppgifter saknas')
expect(msg).toContain('Valideringsfel')
// Must NOT collapse to the generic HTTP-400 fallback.
expect(msg).not.toBe('Förfrågan innehåller ogiltiga uppgifter.')
})
it('joins multiple items and caps the list with an overflow hint', () => {
const details = Array.from({ length: 7 }, (_, i) => `Anställd ${i + 1}: Bankuppgifter saknas`)
const msg = getErrorMessage({ error: 'Valideringsfel', details }, { statusCode: 400 })
expect(msg).toContain('Anställd 1')
expect(msg).toContain('Anställd 5')
expect(msg).toContain('•')
expect(msg).toContain('(+2 till)')
expect(msg).not.toContain('Anställd 6')
})
it('ignores a non-string details array and falls through to the status fallback', () => {
const msg = getErrorMessage({ error: 'oklart fel', details: [{ x: 1 }] }, { statusCode: 400 })
expect(msg).toBe('Förfrågan innehåller ogiltiga uppgifter.')
})
})
describe('getErrorMessage — existing patterns still work', () => {
it('regex match for "Entry date ... outside fiscal period" on plain string', () => {
const msg = getErrorMessage('Entry date 2024-06-15 is outside fiscal period "FY 2025"')
+20
View File
@@ -167,6 +167,9 @@ function isSwedishUserMessage(message: string): boolean {
/måste/i,
/redan finns/i,
/gick fel/i,
/valideringsfel/i,
/korrigera/i,
/bankuppgifter/i,
/behörighet/i,
/session/i,
/förfrågan/i,
@@ -383,6 +386,23 @@ export function getErrorMessage(
}
}
// Accumulated per-item validation list from routes that collect several
// problems before responding, e.g. the salary approve route:
// { error: 'Valideringsfel …', details: ['Tomas Tysén: Bankuppgifter saknas …', …] }
// Surface the specific reasons — otherwise this shape falls all the way
// through to the generic HTTP-400 message and the user learns nothing.
if (
Array.isArray(obj.details) &&
obj.details.length > 0 &&
obj.details.every((d) => typeof d === 'string' && d.trim() !== '')
) {
const items = (obj.details as string[]).map((d) => d.trim())
const shown = items.slice(0, 5).join(' • ')
const more = items.length > 5 ? ` (+${items.length - 5} till)` : ''
const lead = typeof obj.error === 'string' && obj.error.trim() ? `${obj.error.trim()}: ` : ''
return `${lead}${shown}${more}`
}
// Try Zod validation errors
const zodMessage = tryParseZodErrors(obj)
if (zodMessage) return zodMessage
@@ -81,6 +81,48 @@ async function seedPostedVoucher(params: {
return id
}
/**
* Seed a posted voucher with one debit line and one credit line (balanced).
* Lets kontantmetoden tests build a cash-receipt verifikat (debit 1930 /
* credit 3001 — no 1510) and a non-matching one (debit 1510 / credit 3001).
*/
async function seedVoucherDebitCredit(params: {
userId: string
companyId: string
fiscalPeriodId: string
amount?: number
debitAccount: string
creditAccount: string
}): Promise<string> {
const id = randomUUID()
const amount = params.amount ?? 1000
await getPool().query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES ($1, $2, $3, $4, $5, 'A', '2026-05-05', 'Inbetalning', 'manual', 'posted')`,
[id, params.userId, params.companyId, params.fiscalPeriodId, Math.floor(Math.random() * 100000)],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, $3, 0),
($1, $4, 0, $3)`,
[id, params.debitAccount, amount, params.creditAccount],
)
return id
}
/** Flip a company onto kontantmetoden so the link RPC keys on the 19xx debit. */
async function setCashMethod(companyId: string): Promise<void> {
await getPool().query(
`INSERT INTO public.company_settings (company_id, accounting_method)
VALUES ($1, 'cash')
ON CONFLICT (company_id) DO UPDATE SET accounting_method = 'cash'`,
[companyId],
)
}
describe('link_invoice_voucher pg-real guards', () => {
it('partial unique index blocks linking the same voucher to the same invoice twice', async () => {
const userId = await insertAuthUser()
@@ -414,3 +456,90 @@ describe('link_invoice_to_voucher RPC (atomic link — audit C2)', () => {
expect(Number(pay[0].count)).toBe(1)
})
})
// ============================================================
// link_invoice_to_voucher RPC — kontantmetoden branch
// On cash method no 1510 is ever booked (revenue is recognised at payment),
// so the RPC must key on the bank/cash DEBIT (19xx) instead of the AR credit.
// Mirrors the accounting-method branch in lib/invoices/voucher-matching.ts.
// ============================================================
describe('link_invoice_to_voucher RPC (kontantmetoden — 19xx debit)', () => {
it('cash method: links against the 1930 debit of a receipt voucher (no 1510)', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
await setCashMethod(companyId)
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId, total: 1000 })
// Cash receipt: debit 1930 (bank) / credit 3001 (revenue) — no receivable.
const voucherId = await seedVoucherDebitCredit({
userId,
companyId,
fiscalPeriodId,
amount: 1000,
debitAccount: '1930',
creditAccount: '3001',
})
const result = await callLinkRpc({ invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(true)
expect(result.invoice_status).toBe('paid')
expect(Number(result.paid_amount)).toBe(1000)
expect(Number(result.remaining_amount)).toBe(0)
const { rows: pay } = await getPool().query(
`SELECT amount FROM public.invoice_payments WHERE invoice_id = $1 AND journal_entry_id = $2`,
[invoiceId, voucherId],
)
expect(pay).toHaveLength(1)
expect(Number(pay[0].amount)).toBe(1000)
})
it('cash method: rejects a voucher with no bank/cash debit (NO_AR_CREDIT)', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
await setCashMethod(companyId)
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId, total: 1000 })
// An AR-clearing voucher (debit 1510 / credit 3001): valid on accrual, but
// on cash there is no 19xx debit, so it must not match.
const voucherId = await seedVoucherDebitCredit({
userId,
companyId,
fiscalPeriodId,
amount: 1000,
debitAccount: '1510',
creditAccount: '3001',
})
const result = await callLinkRpc({ invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(false)
expect(result.code).toBe('LINK_VOUCHER_NO_AR_CREDIT')
const { rows: inv } = await getPool().query(
`SELECT status, paid_amount FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(inv[0].status).toBe('sent')
expect(Number(inv[0].paid_amount)).toBe(0)
})
it('accrual default (no settings row): still keys on the 1510 credit', async () => {
// Regression guard: a company with no company_settings row must behave as
// accrual — the 1930-debit / 1510-credit voucher links via its AR credit.
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId, total: 1000 })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId, amount: 1000 })
const result = await callLinkRpc({ invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(true)
expect(result.invoice_status).toBe('paid')
})
})
+132 -1
View File
@@ -42,6 +42,7 @@ describe('validateVoucherForInvoiceLink', () => {
it('rejects when the voucher is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({ data: null, error: null }) // journal_entries.maybeSingle → null
const result = await validateVoucherForInvoiceLink(
supabase as never,
@@ -56,6 +57,7 @@ describe('validateVoucherForInvoiceLink', () => {
it('rejects when the voucher is not posted', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
@@ -79,9 +81,10 @@ describe('validateVoucherForInvoiceLink', () => {
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_NOT_POSTED')
})
it('rejects when the voucher has no AR credit', async () => {
it('rejects when the voucher has no AR credit (accrual)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
@@ -111,9 +114,85 @@ describe('validateVoucherForInvoiceLink', () => {
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_NO_AR_CREDIT')
})
it('cash method: accepts the same 1930-debit voucher that accrual rejects', async () => {
// Kontantmetoden books debit 19xx / credit 30xx and never touches 1510, so
// the matcher keys on the bank/cash debit instead of an AR credit.
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'cash' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0, currency: 'SEK' },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
],
})
enqueue({ data: [] }) // invoice_payments already-linked lookup
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.arCreditAmount).toBe(1000)
expect(result.paymentAmount).toBe(1000)
expect(result.isFullyPaid).toBe(true)
}
})
it('cash method: rejects when the voucher has no bank/cash debit', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'cash' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
// An AR-clearing voucher (1510 credit) — valid on accrual, but on cash
// there is no 19xx debit so it must not match.
{ account_number: '1510', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
{ account_number: '3001', debit_amount: 1000, credit_amount: 0, currency: 'SEK' },
],
})
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_NO_AR_CREDIT')
})
it('rejects when the voucher amount exceeds the remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
@@ -146,6 +225,7 @@ describe('validateVoucherForInvoiceLink', () => {
it('rejects when the line currency does not match the invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(makeInvoice({ remaining_amount: 1000, total: 1000, currency: 'EUR' }))
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
@@ -177,6 +257,7 @@ describe('validateVoucherForInvoiceLink', () => {
it('rejects when the voucher is already linked to this invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
@@ -209,6 +290,7 @@ describe('validateVoucherForInvoiceLink', () => {
it('returns ok=true with full-pay flag when amount equals remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
@@ -247,6 +329,7 @@ describe('validateVoucherForInvoiceLink', () => {
it('returns ok=true with partial-pay flag when amount is less than remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(makeInvoice({ remaining_amount: 1000, total: 1000, currency: 'SEK' }))
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
@@ -308,6 +391,7 @@ describe('findMatchingVouchersForInvoice', () => {
total: 1000,
due_date: '2026-05-01',
})
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({ data: null, error: { message: 'db error' } })
const result = await findMatchingVouchersForInvoice(
supabase as never,
@@ -316,6 +400,53 @@ describe('findMatchingVouchersForInvoice', () => {
)
expect(result).toEqual([])
})
it('cash method: surfaces a verifikat that debits a bank account (19xx), no 1510 needed', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = makeInvoice({
remaining_amount: 1000,
total: 1000,
currency: 'SEK',
due_date: '2026-05-01',
invoice_number: 'F-1',
})
enqueue({ data: { accounting_method: 'cash' } }) // resolveAccountingMethod
// journal_entries query with embedded lines (kontantmetoden: 19xx debit > 0)
enqueue({
data: [
{
id: 'je-1',
voucher_series: 'A',
voucher_number: 7,
entry_date: '2026-05-01',
description: 'Betalning faktura F-1',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
journal_entry_lines: [
{
id: 'l1',
account_number: '1930',
debit_amount: 1000,
credit_amount: 0,
currency: 'SEK',
},
],
},
],
})
enqueue({ data: [] }) // invoice_payments already-linked lookup
enqueue({ data: [] }) // fiscal_periods lock lookup
const result = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
invoice as never,
)
expect(result).toHaveLength(1)
expect(result[0].journal_entry_id).toBe('je-1')
expect(result[0].ar_credit_amount).toBe(1000)
})
})
// ============================================================
+33 -19
View File
@@ -633,17 +633,21 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
const styles = createStyles(branding)
const isCreditNote = !!invoice.credited_invoice_id
// Free-text / blank rows carry no amounts — exclude them from every VAT
// calculation. They still render as their own row in the line-items table.
const billableItems = items.filter((item) => item.line_type !== 'text')
// Check if items have mixed VAT rates
const hasPerLineVat = items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
const hasPerLineVat = billableItems.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
const uniqueRates = hasPerLineVat
? new Set(items.map((item) => item.vat_rate))
? new Set(billableItems.map((item) => item.vat_rate))
: new Set<number>()
const showVatColumn = hasPerLineVat && uniqueRates.size > 1
// Calculate per-rate VAT breakdown for totals
const vatByRate = new Map<number, { base: number; vat: number }>()
if (hasPerLineVat) {
for (const item of items) {
for (const item of billableItems) {
const rate = item.vat_rate ?? 0
const group = vatByRate.get(rate) || { base: 0, vat: 0 }
group.base += Math.abs(item.line_total)
@@ -824,22 +828,32 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
{/* Table rows */}
{items.map((item, index) => (
<View key={index} style={styles.tableRow}>
<Text style={styles.colDescription}>{item.description}</Text>
<Text style={styles.colQty}>{item.quantity}</Text>
<Text style={styles.colUnit}>{item.unit}</Text>
{!isDeliveryNote && (
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency, lang)}</Text>
)}
{!isDeliveryNote && showVatColumn && (
<Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text>
)}
{!isDeliveryNote && (
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency, lang)}</Text>
)}
</View>
))}
{items.map((item, index) =>
item.line_type === 'text' ? (
// Free-text / blank row: description spans the full width, no
// numeric columns. An empty description renders as a spacer.
<View key={index} style={styles.tableRow}>
<Text style={[styles.colDescription, { width: '100%' }]}>
{item.description || ' '}
</Text>
</View>
) : (
<View key={index} style={styles.tableRow}>
<Text style={styles.colDescription}>{item.description}</Text>
<Text style={styles.colQty}>{item.quantity}</Text>
<Text style={styles.colUnit}>{item.unit}</Text>
{!isDeliveryNote && (
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency, lang)}</Text>
)}
{!isDeliveryNote && showVatColumn && (
<Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text>
)}
{!isDeliveryNote && (
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency, lang)}</Text>
)}
</View>
)
)}
</View>
</View>
+5 -5
View File
@@ -12,11 +12,11 @@ export interface VatRateOption {
* Swedish/EU-unvalidated customers can choose between 25%, 12%, 6%, and 0% (exempt).
* Reverse charge and export customers are locked to 0%.
*
* The picker does NOT gate on the seller's VAT registration status. A
* non-momsregistrerad seller is shown the same options as a registered one —
* the form surfaces a warning at submit time (ML 16 kap. 23 § faktureringsmoms:
* stated VAT is owed even by non-registered sellers, but the buyer cannot
* deduct it as input VAT).
* This helper does NOT gate on the seller's VAT registration status — it only
* knows the customer side. The seller-side gate lives one level up: the invoice
* form hides the Moms column entirely when company_settings.vat_registered is
* false, and both the create route and the MCP commit force every line to 0%
* (momsfri) server-side, so a non-momsregistrerad company never books output VAT.
*/
export function getAvailableVatRates(
customerType: CustomerType,
+168 -78
View File
@@ -1,19 +1,23 @@
/**
* Link an existing posted verifikat to a customer invoice as its payment row.
*
* Used when the GL already contains a verifikat that credits AR (default
* 1510) — e.g. a SIE-imported payment voucher, a manually-entered cash
* receipt, or any flow where the bookkeeping landed without invoice linkage.
* No new journal entry is created. Only an invoice_payments row is inserted
* pointing at the existing journal_entry_id, plus the invoice's
* The matching is accounting-method aware (company_settings.accounting_method):
* • Faktureringsmetoden (accrual): match verifikat that CREDIT an AR account
* (default 1510, covers 151x) — e.g. a SIE-imported payment voucher or a
* manually-entered receipt that clears the receivable.
* • Kontantmetoden (cash): no 1510 is ever booked (revenue is recognised at
* payment — debit 19xx / credit 30xx+26xx), so instead match verifikat that
* DEBIT a liquid-funds account (BAS class 19 — kassa/bank, covers
* 1910/1920/1930/1940…). That voucher IS the payment the user already
* booked; linking just marks the invoice paid without a duplicate entry.
*
* No new journal entry is created in either case. Only an invoice_payments row
* is inserted pointing at the existing journal_entry_id, plus the invoice's
* paid_amount/remaining_amount/status are advanced.
*
* Vouchers that book income directly (credit 30xx instead of 1510) are
* rejected here with VOUCHER_NO_AR_CREDIT. The proper fix for those is a
* storno+correction via gnubok_correct_entry — out of scope for this V1.
*
* Both the web API route and the MCP commit handler call into the same
* `linkInvoiceToVoucher()` function so behaviour stays in lockstep.
* `linkInvoiceToVoucher()` function (→ link_invoice_to_voucher RPC) so
* behaviour stays in lockstep.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events/bus'
@@ -29,9 +33,42 @@ import type { Invoice, Customer } from '@/types'
const log = createLogger('voucher-matching')
/** AR account range. Default 1510 (Kundfordringar) — covers all 151x. */
/** AR account range. Default 1510 (Kundfordringar) — covers all 151x. Used on
* faktureringsmetoden, where the issuance verifikat books the receivable. */
const AR_ACCOUNT_PREFIX = '151'
/** Liquid-funds range (Kassa och bank, BAS class 19 — 1910/1920/1930/1940…).
* Used on kontantmetoden, where the payment verifikat debits a bank/cash
* account instead of crediting 1510. */
const CASH_ACCOUNT_PREFIX = '19'
/**
* Read the company's accounting method. Defaults to 'accrual' when the settings
* row or column is absent — mirrors mark-paid / propose-payment-lines.
*/
async function resolveAccountingMethod(
supabase: SupabaseClient,
companyId: string
): Promise<'accrual' | 'cash'> {
const { data, error } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', companyId)
.maybeSingle()
if (error) {
// A transient failure here would silently flip a cash company to the
// accrual (151x) search and render an empty candidate list — make the
// fallback visible so an intermittent empty state is diagnosable.
log.warn('accounting_method lookup failed; falling back to accrual', {
companyId,
message: error.message,
})
}
return (data as { accounting_method?: string } | null)?.accounting_method === 'cash'
? 'cash'
: 'accrual'
}
/** ±90 days from the invoice's due_date as the default search window. */
const DEFAULT_DATE_WINDOW_DAYS = 90
@@ -47,10 +84,12 @@ export interface VoucherCandidate {
voucher_number: number | null
entry_date: string
description: string
/** Total credit to the AR account on this voucher (always positive). */
/** Matched amount on this voucher, always positive: the AR credit (151x) on
* faktureringsmetoden, or the liquid-funds debit (19xx) on kontantmetoden.
* Kept under this name for API/UI back-compat across both methods. */
ar_credit_amount: number
currency: string
/** Currency of the AR-credit line; nullable when the line stores SEK only. */
/** Currency of the matched line; nullable when the line stores SEK only. */
ar_line_currency: string | null
/** True when the voucher's fiscal period is closed or locked. */
period_locked: boolean
@@ -94,9 +133,10 @@ interface CandidateContext {
const EXCLUDED_SOURCE_TYPES = ['opening_balance', 'storno']
/**
* Find posted journal entries whose lines credit an AR account and could
* plausibly be the payment for this invoice. Returns up to `limit` ranked
* candidates.
* Find posted journal entries that could plausibly be the payment for this
* invoice and return up to `limit` ranked candidates. On faktureringsmetoden
* those are vouchers crediting an AR account (151x); on kontantmetoden they are
* vouchers debiting a liquid-funds account (19xx) — see the module header.
*
* The query is intentionally generous on filtering — we let the validator
* make the final call at commit time. Ranking mirrors
@@ -116,84 +156,138 @@ export async function findMatchingVouchersForInvoice(
const remainingAmount = computeRemaining(invoice)
if (remainingAmount <= AMOUNT_TOLERANCE) return []
// Cash method: match the bank/cash DEBIT (19xx). Accrual: match the AR
// CREDIT (151x). The account prefix + side both switch on the method.
const isCash = (await resolveAccountingMethod(supabase, companyId)) === 'cash'
const accountPrefix = isCash ? CASH_ACCOUNT_PREFIX : AR_ACCOUNT_PREFIX
const amountColumn = isCash ? 'debit_amount' : 'credit_amount'
const dueDate = new Date(invoice.due_date)
const dateFrom = new Date(dueDate)
dateFrom.setDate(dateFrom.getDate() - windowDays)
const dateTo = new Date(dueDate)
dateTo.setDate(dateTo.getDate() + windowDays)
const { data: lines, error } = await supabase
.from('journal_entry_lines')
// Pre-filter the matched side to a band around the invoice amount before the
// row cap applies. Without this, a cash company with many 19xx-debit lines
// (every bank receipt) overflows the cap and the relevant voucher can be
// dropped before it is ever scored. The band is a superset of every case
// scoreCandidate accepts (exact remaining/total + fuzzy ±1% capped 500 SEK),
// so it never hides a single-line match.
const hiAmount = Math.max(remainingAmount, invoice.total)
const loAmount = Math.min(remainingAmount, invoice.total)
const amountPad = Math.min(hiAmount * 0.01, 500) + 0.02
const amountFloor = Math.max(0, loAmount - amountPad)
const amountCeil = hiAmount + amountPad
// Drive the query from journal_entries, embedding the matched lines, NOT
// from journal_entry_lines joined up to the entry. PostgREST executes the
// FROM table first: driving from lines means scanning `account LIKE '19%'`
// across ALL tenants and running the lines RLS policy (a per-row EXISTS via
// current_active_company_id()) thousands of times — on a cash company every
// bank receipt is a 19xx debit, and the query blows the authenticated
// statement_timeout (8s). Driving from entries hits company+date+status
// indexes first (a handful of rows), so the per-line RLS check only runs for
// those entries' lines. Same result set, milliseconds instead of seconds.
let query = supabase
.from('journal_entries')
.select(
`
id,
journal_entry_id,
account_number,
debit_amount,
credit_amount,
currency,
journal_entries!inner (
voucher_series,
voucher_number,
entry_date,
description,
status,
source_type,
fiscal_period_id,
company_id,
journal_entry_lines!inner (
id,
voucher_series,
voucher_number,
entry_date,
description,
status,
source_type,
fiscal_period_id,
company_id
account_number,
debit_amount,
credit_amount,
currency
)
`
)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.status', 'posted')
.like('account_number', `${AR_ACCOUNT_PREFIX}%`)
.gt('credit_amount', 0)
.gte('journal_entries.entry_date', dateFrom.toISOString().slice(0, 10))
.lte('journal_entries.entry_date', dateTo.toISOString().slice(0, 10))
.eq('company_id', companyId)
.eq('status', 'posted')
.gte('entry_date', dateFrom.toISOString().slice(0, 10))
.lte('entry_date', dateTo.toISOString().slice(0, 10))
.like('journal_entry_lines.account_number', `${accountPrefix}%`)
query = isCash
? query.gt('journal_entry_lines.debit_amount', 0)
: query.gt('journal_entry_lines.credit_amount', 0)
const { data: entryRows, error } = await query
.gte(`journal_entry_lines.${amountColumn}`, amountFloor)
.lte(`journal_entry_lines.${amountColumn}`, amountCeil)
.limit(limit * 10)
if (error || !lines) return []
if (error) {
// Surface transient failures instead of silently rendering "no candidates"
// — a swallowed error looks like a match that intermittently vanishes.
log.warn('voucher candidate query failed', {
companyId,
invoiceId: invoice.id,
message: error.message,
})
}
if (error || !entryRows) return []
// Group lines by journal_entry_id so we sum the AR credit per voucher.
// Sum the matched side per voucher (the embed already contains only the
// lines that passed the account/side/amount filters).
const byEntry = new Map<
string,
{ entry: VoucherRow; arCreditTotal: number; lineCurrency: string | null }
>()
for (const raw of lines) {
const line = raw as unknown as JournalEntryLine & {
journal_entries: VoucherRow
for (const raw of entryRows) {
const entry = raw as unknown as VoucherRow & {
journal_entry_lines: Pick<
JournalEntryLine,
'id' | 'account_number' | 'debit_amount' | 'credit_amount' | 'currency'
>[]
}
const entry = line.journal_entries
if (!entry) continue
if (EXCLUDED_SOURCE_TYPES.includes(entry.source_type ?? '')) continue
const credit = Number(line.credit_amount ?? 0)
if (credit <= 0) continue
const existing = byEntry.get(entry.id)
if (existing) {
existing.arCreditTotal += credit
} else {
byEntry.set(entry.id, {
entry,
arCreditTotal: credit,
lineCurrency: line.currency,
})
let matchedTotal = 0
let lineCurrency: string | null = null
for (const line of entry.journal_entry_lines ?? []) {
// Matched amount = the bank/cash debit (cash) or AR credit (accrual).
const matched = isCash ? Number(line.debit_amount ?? 0) : Number(line.credit_amount ?? 0)
if (matched <= 0) continue
matchedTotal += matched
if (!lineCurrency) lineCurrency = line.currency
}
if (matchedTotal <= 0) continue
byEntry.set(entry.id, { entry, arCreditTotal: matchedTotal, lineCurrency })
}
if (byEntry.size === 0) return []
// Drop entries already fully linked to *this* invoice.
// Fetch the already-linked payments (for dedup) and the fiscal-period locks
// (informational "låst period" badge) concurrently — both depend only on the
// grouped entries, so there is no reason to pay two sequential round-trips.
// Computing locks for entries that dedup later drops is harmless.
const candidateEntryIds = Array.from(byEntry.keys())
const { data: existingLinks } = await supabase
.from('invoice_payments')
.select('journal_entry_id')
.eq('company_id', companyId)
.eq('invoice_id', invoice.id)
.in('journal_entry_id', candidateEntryIds)
const periodIds = Array.from(
new Set(Array.from(byEntry.values()).map((v) => v.entry.fiscal_period_id))
)
const [{ data: existingLinks }, { data: periods }] = await Promise.all([
supabase
.from('invoice_payments')
.select('journal_entry_id')
.eq('company_id', companyId)
.eq('invoice_id', invoice.id)
.in('journal_entry_id', candidateEntryIds),
supabase
.from('fiscal_periods')
.select('id, status')
.in('id', periodIds),
])
// Drop entries already fully linked to *this* invoice.
const alreadyLinked = new Set(
(existingLinks ?? [])
.map((row) => (row as { journal_entry_id: string | null }).journal_entry_id)
@@ -202,16 +296,8 @@ export async function findMatchingVouchersForInvoice(
for (const id of alreadyLinked) byEntry.delete(id)
if (byEntry.size === 0) return []
// Resolve fiscal period locks in one batched query so we can surface a
// "period locked" flag in the candidate preview. Linking is allowed in
// locked periods (no JE mutation) — this is just informational.
const periodIds = Array.from(
new Set(Array.from(byEntry.values()).map((v) => v.entry.fiscal_period_id))
)
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id, status')
.in('id', periodIds)
// Linking is allowed in locked periods (no JE mutation) — this flag is just
// informational for the candidate preview.
const lockedPeriods = new Set(
(periods ?? [])
.filter(
@@ -360,6 +446,10 @@ export async function validateVoucherForInvoiceLink(
return { ok: false, code: 'LINK_VOUCHER_INVOICE_FULLY_PAID' }
}
// Match the bank/cash debit (cash) or the AR credit (accrual) — see header.
const isCash = (await resolveAccountingMethod(supabase, companyId)) === 'cash'
const accountPrefix = isCash ? CASH_ACCOUNT_PREFIX : AR_ACCOUNT_PREFIX
const { data: voucher, error: voucherError } = await supabase
.from('journal_entries')
.select('id, voucher_series, voucher_number, entry_date, description, status, source_type, fiscal_period_id, company_id')
@@ -391,10 +481,10 @@ export async function validateVoucherForInvoiceLink(
let lineCurrency: string | null = null
for (const raw of lines) {
const line = raw as { account_number: string; debit_amount: number | null; credit_amount: number | null; currency: string | null }
if (!line.account_number?.startsWith(AR_ACCOUNT_PREFIX)) continue
const credit = Number(line.credit_amount ?? 0)
if (credit <= 0) continue
arCreditTotal += credit
if (!line.account_number?.startsWith(accountPrefix)) continue
const matched = isCash ? Number(line.debit_amount ?? 0) : Number(line.credit_amount ?? 0)
if (matched <= 0) continue
arCreditTotal += matched
if (!lineCurrency) lineCurrency = line.currency
}
arCreditTotal = round2(arCreditTotal)
@@ -0,0 +1,184 @@
/**
* Unit tests for the create_invoice executor, run through the public
* `commitPendingOperation` dispatcher (executors are not exported).
*
* Covers the two server-authoritative VAT behaviors flagged in review:
* 1. A non-VAT-registered company gets every line rate coerced to 0 and the
* invoice stored as momsfri ('exempt'), regardless of what was staged.
* 2. Free-text rows (line_type 'text') are excluded from subtotal, VAT, and
* mixed-rate detection — a text row's 0% must not flip vat_rate to null.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { makeCustomer } from '@/tests/helpers'
import type { PendingOperation } from '@/types'
import { commitPendingOperation } from '../commit'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'create_invoice',
status: 'pending',
title: 'test',
params: {},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'medium',
created_at: '2026-05-03T00:00:00Z',
resolved_at: null,
updated_at: '2026-05-03T00:00:00Z',
...overrides,
} as PendingOperation
}
/**
* Queue-based supabase mock that also records `.insert()` payloads per table,
* so assertions can inspect what was actually written.
*/
function createCapturingSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
const queue = [...results]
const inserts: Record<string, unknown[]> = {}
const from = vi.fn((table: string) => {
const raw = queue.shift() ?? { data: null, error: null }
const result = { data: raw.data ?? null, error: raw.error ?? null }
const chain: object = new Proxy(
{},
{
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
if (prop === 'insert') {
return (payload: unknown) => {
;(inserts[table] ??= []).push(payload)
return chain
}
}
return () => chain
},
},
)
return chain
})
return { supabase: { from }, inserts }
}
const customer = makeCustomer({ id: 'cust-1', customer_type: 'swedish_business' })
/** Queue for the dispatcher + executor call sequence (SEK, no overrides):
* CAS claim → customers → company_settings → invoices insert →
* invoice_items insert → complete-invoice select → dispatcher update. */
function queueFor(settings: { vat_registered: boolean } | null) {
return [
{ data: { id: 'op-1' } },
{ data: customer },
{ data: settings },
{ data: { id: 'inv-1', invoice_number: null } },
{ data: null },
{ data: { id: 'inv-1' } },
{ data: null },
]
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: create_invoice', () => {
it('coerces a staged non-zero VAT rate to 0 for a non-VAT-registered company', async () => {
const { supabase, inserts } = createCapturingSupabase(queueFor({ vat_registered: false }))
const op = makePendingOp({
params: {
customer_id: 'cust-1',
items: [{ description: 'Konsulttimmar', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
invoice_date: '2026-06-01',
due_date: '2026-07-01',
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(inserts['invoices']).toHaveLength(1)
expect(inserts['invoices'][0]).toMatchObject({
subtotal: 1000,
vat_amount: 0,
total: 1000,
vat_rate: 0,
vat_treatment: 'exempt',
moms_ruta: null,
})
const itemRows = inserts['invoice_items'][0] as Array<Record<string, unknown>>
expect(itemRows).toHaveLength(1)
expect(itemRows[0]).toMatchObject({ vat_rate: 0, vat_amount: 0 })
})
it('keeps the staged rate for a VAT-registered company', async () => {
const { supabase, inserts } = createCapturingSupabase(queueFor({ vat_registered: true }))
const op = makePendingOp({
params: {
customer_id: 'cust-1',
items: [{ description: 'Konsulttimmar', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(inserts['invoices'][0]).toMatchObject({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_rate: 25,
moms_ruta: '05',
})
})
it('excludes text rows from totals and mixed-rate detection', async () => {
const { supabase, inserts } = createCapturingSupabase(queueFor({ vat_registered: true }))
const op = makePendingOp({
params: {
customer_id: 'cust-1',
items: [
{ description: 'Konsulttimmar', quantity: 2, unit: 'tim', unit_price: 500, vat_rate: 25 },
{ line_type: 'text', description: 'Avser vecka 23', quantity: 0, unit: '', unit_price: 0, vat_rate: 0 },
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
// The text row's 0% must not trigger mixed-rate (vat_rate: null).
expect(inserts['invoices'][0]).toMatchObject({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_rate: 25,
})
const itemRows = inserts['invoice_items'][0] as Array<Record<string, unknown>>
expect(itemRows).toHaveLength(2)
expect(itemRows[0]).toMatchObject({ line_type: 'product', vat_rate: 25, vat_amount: 250, line_total: 1000 })
expect(itemRows[1]).toMatchObject({
line_type: 'text',
description: 'Avser vecka 23',
quantity: 0,
unit_price: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
})
})
})
+49 -7
View File
@@ -650,8 +650,15 @@ async function commitCreateInvoice(
const items = params.items as Array<{
description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number
article_id?: string | null; revenue_account?: string | null
line_type?: 'product' | 'text'
}>
// Free-text rows carry no amounts and never book. The MCP staging tool does
// not accept line_type today, but the totals math must stay identical to
// app/api/invoices/route.ts, which excludes text rows from subtotal, VAT,
// and the mixed-rate detection.
const billableItems = items.filter((item) => item.line_type !== 'text')
const { data: customer, error: customerError } = await supabase
.from('customers').select('*').eq('id', customerId).eq('company_id', companyId).single()
@@ -663,10 +670,22 @@ async function commitCreateInvoice(
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
const allowedRates = new Set(availableRates.map((r) => r.rate))
const subtotal = items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
// VAT registration gate (mirrors app/api/invoices/route.ts). A
// non-momsregistrerad company books no output VAT: force every line to 0%
// (momsfri → treatment 'exempt'). 0% is allowed for every customer type, so
// the allowedRates guard below still passes.
const { data: vatSettings } = await supabase
.from('company_settings')
.select('vat_registered')
.eq('company_id', companyId)
.maybeSingle()
const notVatRegistered = vatSettings?.vat_registered === false
if (notVatRegistered) for (const item of items) item.vat_rate = 0
const subtotal = billableItems.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
let vatAmount = 0
for (const item of items) {
for (const item of billableItems) {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
if (!allowedRates.has(itemRate)) {
return { error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp`, status: 400 }
@@ -678,7 +697,7 @@ async function commitCreateInvoice(
// Validate any per-line revenue-account override (defense in depth — the field
// is frozen onto invoice_items and flows to generatePerRateLines()).
const overrideAccounts = Array.from(
new Set(items.map((i) => i.revenue_account).filter((a): a is string => !!a)),
new Set(billableItems.map((i) => i.revenue_account).filter((a): a is string => !!a)),
)
for (const acct of overrideAccounts) {
if (!(await isValidRevenueAccount(supabase, companyId, acct))) {
@@ -706,7 +725,7 @@ async function commitCreateInvoice(
}
}
const uniqueRates = new Set(items.map((item) => item.vat_rate ?? vatRules.rate))
const uniqueRates = new Set(billableItems.map((item) => item.vat_rate ?? vatRules.rate))
const isMixedRate = uniqueRates.size > 1
const { data: invoice, error: invoiceError } = await supabase
@@ -727,10 +746,10 @@ async function commitCreateInvoice(
vat_amount_sek: vatAmountSek,
total,
total_sek: totalSek,
vat_treatment: vatRules.treatment,
vat_treatment: notVatRegistered ? 'exempt' : vatRules.treatment,
vat_rate: 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),
our_reference: (params.our_reference as string) || null,
your_reference: (params.your_reference as string) || null,
notes: (params.notes as string) || null,
@@ -741,12 +760,32 @@ async function commitCreateInvoice(
if (invoiceError) return { error: invoiceError.message, status: 500 }
const invoiceItems = items.map((item, index) => {
// Text rows store the description only and zero everything else. Keys must
// match the product branch exactly — PostgREST rejects a bulk insert whose
// objects have differing key sets.
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,
article_id: null,
revenue_account: null,
}
}
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
const itemVat = Math.round(lineTotal * itemRate / 100 * 100) / 100
return {
invoice_id: invoice.id,
sort_order: index,
line_type: 'product',
description: item.description,
quantity: item.quantity,
unit: item.unit,
@@ -2218,6 +2257,7 @@ async function commitCreditInvoice(
const creditItems = (original.items || []).map((item: {
sort_order: number
line_type?: 'product' | 'text'
description: string
quantity: number
unit: string
@@ -2230,6 +2270,7 @@ async function commitCreditInvoice(
}) => ({
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,
@@ -2384,6 +2425,7 @@ async function commitConvertInvoice(
const items = (proforma.items ?? []).map((item: Record<string, unknown>) => ({
invoice_id: invoice.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
description: item.description,
quantity: item.quantity,
unit: item.unit,
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest'
import { effectiveNetPayout } from '../effective-net'
describe('effectiveNetPayout', () => {
it('returns net_salary when there is no tax override', () => {
expect(
effectiveNetPayout({ net_salary: 24000, tax_withheld: 8000, tax_withheld_override: null }),
).toBe(24000)
})
it('is zero for a nollkörning (nothing paid out)', () => {
expect(
effectiveNetPayout({ net_salary: 0, tax_withheld: 0, tax_withheld_override: null }),
).toBe(0)
})
it('raises the payout when tax is overridden lower than computed', () => {
// Computed tax 8000 → overridden to 5000 means 3000 more reaches the employee.
expect(
effectiveNetPayout({ net_salary: 24000, tax_withheld: 8000, tax_withheld_override: 5000 }),
).toBe(27000)
})
it('lowers the payout when tax is overridden higher than computed', () => {
expect(
effectiveNetPayout({ net_salary: 24000, tax_withheld: 8000, tax_withheld_override: 10000 }),
).toBe(22000)
})
})
+22
View File
@@ -0,0 +1,22 @@
/**
* The net amount actually paid out to an employee's bank account for a salary
* run, honoring any manual tax-withheld override. This is exactly the figure
* written into the pain.001 / Bankgirot LB payment files.
*
* Bank details (clearing + account number) are only required when this is > 0:
* a zero payout — e.g. a nollkörning, or an employee whose net is fully
* consumed by a nettolöneavdrag — produces no payment-file line, so there is
* no destination account to fill in. Gating the bank-details requirement on
* this keeps the approve guard and the payment-file generators in agreement.
*/
export interface EffectiveNetInput {
net_salary: number
tax_withheld: number
tax_withheld_override?: number | null
}
export function effectiveNetPayout(sre: EffectiveNetInput): number {
// net_salary was computed with the calculated tax; if the user overrode the
// tax, the payout shifts by the difference (lower tax → higher payout).
return sre.net_salary + (sre.tax_withheld - (sre.tax_withheld_override ?? sre.tax_withheld))
}