Add/pdf and templates (#705)
* fix(invoices): apply configured voucher series to payments + preview next voucher The booking engine resolves the series from default_voucher_series_per_source_type, but the global "Standardserie" dropdown wrote a separate field the engine ignored, and cash-method invoice payments (invoice_cash_payment) weren't exposed in settings — so configured series were silently dropped to "A". - Expose cash/private payment source types in the per-source-type form - Write the global default through to the map on save, keeping overrides - Resolve voucher-sequences/next by source_type (+date) to match the engine - Show the upcoming voucher (V2) in the payment dialog title - Share resolveInvoicePaymentSourceType so preview and booking can't drift Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(salary): keep AGI panel in sync with Skatteverket signing state The AGI panel mixed run-scoped generation state (agi_generated_at, agi_declarations) with period-scoped submission state (extension_data agi_submission_{period}), so the two could drift and present contradictory UI. Reconcile them: - Auto-detect a Mina Sidor BankID signature: while awaiting_signing, poll /agi/kvittenser on mount and on tab refocus so the panel flips to "signed" (hiding the signing actions) without a manual "Hamta kvittens" click. - Warn instead of offering to sign when the locked granskningsunderlag predates the run's latest AGI generation (draftIsStale) — avoids filing superseded figures. - Self-heal a stale "AGI-XML saknas" error once the run's AGI is (re)generated out-of-band (MCP/API/other tab). - Refetch the salary run on tab focus so agi_generated_at reflects out-of-band generation without a hard reload. - /agi/lasUpp now clears the cached agi_submission_{period} record, so unlocking drops the panel back to the pre-submission state instead of stranding it on a released "redo att signeras" draft. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: Implement VAT registration handling and invoice item line types - Added VAT registration check in commitCreateInvoice to set VAT rate to 0% for non-VAT registered companies. - Updated invoice creation logic to reflect 'exempt' VAT treatment and adjusted related fields accordingly. - Introduced support for free-text and blank spacer rows in invoice items by adding a new line_type field. - Enhanced invoice and credit note handling to accommodate new line types. - Added new localized messages for text rows in English and Swedish. - Created tests for salary run approval logic, ensuring bank details are validated correctly. - Implemented effective net payout calculation for salary runs, considering tax overrides. - Added SQL migrations to support new invoice item line types and accounting method awareness for linking invoices to vouchers. * feat(articles): artikelregister with revenue account + VAT rate per article Article register (non-inventory) with per-article VAT rate and optional BAS class-3 revenue-account override. Includes API routes, UI pages, MCP tools, pending-operation staging, and the activate-or-create account flow (ACCOUNTS_NOT_IN_CHART -> ActivateAccountsDialog, unknown numbers -> AddAccountDialog) reusing the journal entry UX. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): no-doc-required batch + bulk-missing endpoints Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(payments): supplier payment lines + cash-method invoice matching Shared payment-line proposal for supplier invoices, improved match-invoice/match-supplier-invoice flows (kontantmetoden-aware), and voucher-link support without requiring a 151x clearing entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): new journal entry dialog, SIE import tweaks, misc New journal entry dialog component, journal list/page updates, invoice editor updates, SIE import adjustments, transaction ingest and api-key tweaks, pr-agent workflow update. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): implement tax reduction features and localization updates * feat(tests): add VAT registration gate to pending operations commit tests --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
21cfcbe180
commit
f9ea9c0082
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user