Fix/m sprint fixes (#613)

* fix(dashboard): exclude ignored and already-triaged transactions from stale count

The "Gamla transaktioner" widget counted transactions that had been ignored
or already marked as is_business=true but not yet booked, so users saw a
nag for a row they had already dealt with — and the /transactions inbox
correctly hid it. Align the count with the inbox criterion (is_business
IS NULL, is_ignored = false) so the widget clears when the row leaves
the inbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(transactions): read entity_type from settings response wrapper

The transactions page read entityRes.entity_type directly, but
/api/settings returns { data: { entity_type, ... } }. The expression
was always undefined, so setEntityType never fired and entityType
stayed at its initial 'enskild_firma'. The template picker's
entity_type filter then dropped every aktiebolag-tagged user template
for AB customers — only entity_type='all' templates made it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* stale templates
bank sync
journal entry from transaction

* fixed pr comments

* fixed pr comment

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-30 01:28:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent fc7a46c3f2
commit ea1bf01f1e
76 changed files with 5474 additions and 532 deletions
@@ -0,0 +1,252 @@
/**
* GET /api/bookkeeping/fix-cash-mismatch → list affected payments
* POST /api/bookkeeping/fix-cash-mismatch → remediate one payment (or all)
*
* Targeted fix for the cash/clearing routing bug. The old matcher chose its
* journal entry shape from the company's current accounting_method instead
* of from invoice.journal_entry_id, so customers who sent invoices under
* accrual (Dr 1510 / Cr 30xx + 26xx on send) and then matched a bank
* receipt after the company had flipped to kontantmetoden ended up with:
* - 1510 Kundfordran NEVER credited (orphan receivable on the books)
* - 30xx Försäljning AND 26xx Utgående moms double-counted
* - momsdeklaration would over-report output VAT
*
* Detection: any invoice_payments row whose payment journal entry has
* source_type='invoice_cash_payment' while the underlying invoice carries
* its own (still-active) accrual JE.
*
* Remediation per affected payment:
* 1. reverseEntry(payment_je) — storno cancels Dr 1930 / Cr 30xx / Cr 26xx
* 2. createInvoicePaymentJournalEntry — posts the correct Dr 1930 / Cr 1510
* 3. Re-link invoice_payments + transactions to the new JE
*
* Net effect on the books: 30xx and 26xx are restored to their correct
* (single-count) amounts, 1510 is cleared, 1930 nets to a single debit,
* invoice keeps status='paid', transaction keeps invoice_id linkage.
*/
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { createInvoicePaymentJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInitialized } from '@/lib/init'
import type { Invoice } from '@/types'
ensureInitialized()
type AffectedPayment = {
payment_id: string
payment_journal_entry_id: string
invoice_id: string
invoice_number: string | null
counterparty_name: string | null
amount: number
payment_date: string
transaction_id: string | null
invoice_journal_entry_id: string
}
async function findAffected(
supabase: import('@supabase/supabase-js').SupabaseClient,
companyId: string,
): Promise<AffectedPayment[]> {
// 1. Find payment JEs that took the (now-wrong) cash path.
const { data: cashPaymentEntries, error: jeErr } = await supabase
.from('journal_entries')
.select('id, source_id, status')
.eq('company_id', companyId)
.eq('source_type', 'invoice_cash_payment')
.eq('status', 'posted')
if (jeErr) throw jeErr
if (!cashPaymentEntries || cashPaymentEntries.length === 0) return []
// 2. For each, the source_id is the invoice; affected iff that invoice
// ALSO has its own journal_entry_id (i.e. 1510 was booked on send).
const invoiceIds = Array.from(new Set(cashPaymentEntries.map((e) => e.source_id).filter(Boolean)))
if (invoiceIds.length === 0) return []
const { data: invoices, error: invErr } = await supabase
.from('invoices')
.select('id, invoice_number, journal_entry_id, customer:customers(name)')
.eq('company_id', companyId)
.in('id', invoiceIds)
.not('journal_entry_id', 'is', null)
if (invErr) throw invErr
const invoiceMap = new Map(
(invoices ?? []).map((i) => [
i.id as string,
{
invoice_number: (i.invoice_number as string | null) ?? null,
invoice_journal_entry_id: i.journal_entry_id as string,
counterparty_name: ((i.customer as { name?: string | null } | null)?.name) ?? null,
},
]),
)
// 3. Pull the invoice_payments rows so we can show + later re-link.
const affectedJeIds = cashPaymentEntries
.filter((e) => invoiceMap.has(e.source_id as string))
.map((e) => e.id as string)
if (affectedJeIds.length === 0) return []
const { data: payments, error: payErr } = await supabase
.from('invoice_payments')
.select('id, invoice_id, journal_entry_id, amount, payment_date, transaction_id')
.eq('company_id', companyId)
.in('journal_entry_id', affectedJeIds)
if (payErr) throw payErr
return (payments ?? []).map((p) => {
const inv = invoiceMap.get(p.invoice_id as string)!
return {
payment_id: p.id as string,
payment_journal_entry_id: p.journal_entry_id as string,
invoice_id: p.invoice_id as string,
invoice_number: inv.invoice_number,
counterparty_name: inv.counterparty_name,
amount: p.amount as number,
payment_date: p.payment_date as string,
transaction_id: (p.transaction_id as string | null) ?? null,
invoice_journal_entry_id: inv.invoice_journal_entry_id,
}
})
}
export const GET = withRouteContext(
'bookkeeping.fix_cash_mismatch.list',
async (_request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
try {
const affected = await findAffected(supabase, companyId!)
return NextResponse.json({ affected })
} catch (err) {
log.error('failed to detect cash-mismatch payments', err as Error)
return errorResponse(err, log, { requestId })
}
},
)
const PostSchema = z.object({
// Either a single payment to fix, or omit to fix all currently detected.
payment_id: z.string().uuid().optional(),
})
export const POST = withRouteContext(
'bookkeeping.fix_cash_mismatch.apply',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
let body: unknown
try {
body = await request.json()
} catch {
body = {}
}
const parsed = PostSchema.safeParse(body)
if (!parsed.success) {
return errorResponseFromCode('VALIDATION_ERROR', log, { requestId })
}
const { payment_id } = parsed.data
let targets: AffectedPayment[]
try {
const all = await findAffected(supabase, companyId!)
targets = payment_id ? all.filter((p) => p.payment_id === payment_id) : all
} catch (err) {
log.error('failed to detect targets', err as Error)
return errorResponse(err, log, { requestId })
}
if (targets.length === 0) {
return NextResponse.json({ fixed: 0, results: [] })
}
const results: Array<{
payment_id: string
ok: boolean
old_journal_entry_id: string
storno_journal_entry_id?: string
new_journal_entry_id?: string
error?: string
}> = []
for (const t of targets) {
try {
// Storno the wrong cash entry. This reverses Dr 1930 / Cr 30xx / Cr
// 26xx by posting the mirror, restoring revenue + VAT to their pre-
// match (correctly-counted-once) state.
const storno = await reverseEntry(supabase, companyId!, user.id, t.payment_journal_entry_id)
// Re-fetch the invoice so we have currency / exchange rate metadata
// for the clearing entry. Customer name is best-effort.
const { data: inv, error: invErr } = await supabase
.from('invoices')
.select('*, customer:customers(name)')
.eq('id', t.invoice_id)
.eq('company_id', companyId)
.single()
if (invErr || !inv) throw invErr ?? new Error('invoice missing')
const clearing = await createInvoicePaymentJournalEntry(
supabase,
companyId!,
user.id,
inv as Invoice,
t.payment_date,
undefined,
(inv.customer as { name?: string } | null)?.name ?? t.counterparty_name ?? undefined,
t.amount,
)
if (!clearing) throw new Error('clearing entry creation returned null')
// Re-link the invoice_payments row to the new (correct) JE.
const { error: relinkPayErr } = await supabase
.from('invoice_payments')
.update({ journal_entry_id: clearing.id })
.eq('id', t.payment_id)
.eq('company_id', companyId)
if (relinkPayErr) throw relinkPayErr
// Re-link the transaction too, so /transactions reflects the correct
// voucher when the user clicks through.
if (t.transaction_id) {
const { error: relinkTxErr } = await supabase
.from('transactions')
.update({ journal_entry_id: clearing.id })
.eq('id', t.transaction_id)
.eq('company_id', companyId)
if (relinkTxErr) {
log.warn('failed to relink transaction; voucher chain still correct via payment row', {
transactionId: t.transaction_id,
error: relinkTxErr.message,
})
}
}
results.push({
payment_id: t.payment_id,
ok: true,
old_journal_entry_id: t.payment_journal_entry_id,
storno_journal_entry_id: storno.id,
new_journal_entry_id: clearing.id,
})
} catch (err) {
log.error('remediation failed for payment', err as Error, { paymentId: t.payment_id })
results.push({
payment_id: t.payment_id,
ok: false,
old_journal_entry_id: t.payment_journal_entry_id,
error: err instanceof Error ? err.message : 'Unknown error',
})
}
}
return NextResponse.json({
fixed: results.filter((r) => r.ok).length,
failed: results.filter((r) => !r.ok).length,
results,
})
},
{ requireWrite: true },
)
@@ -6,6 +6,7 @@ import { ensureInitialized } from '@/lib/init'
import { eventBus } from '@/lib/events/bus'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { createLogger } from '@/lib/logger'
import { syncInvoiceStatusFromPaymentEntry } from '@/lib/bookkeeping/payment-sync'
const logger = createLogger('journal-entries')
@@ -56,6 +57,18 @@ export async function DELETE(
const companyId = await requireCompanyId(supabase, user.id)
// Read source_type/source_id BEFORE deleting so we can revert the linked
// invoice/supplier_invoice status afterwards. The GL row gets cancelled by
// delete_last_voucher but the invoice's paid status lives outside the GL
// and would otherwise stay stuck on "paid" after the user deletes the
// payment voucher.
const { data: entryBefore } = await supabase
.from('journal_entries')
.select('id, source_type, source_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
const { data, error } = await supabase.rpc('delete_last_voucher', {
p_company_id: companyId,
p_entry_id: id,
@@ -69,6 +82,14 @@ export async function DELETE(
)
}
if (entryBefore) {
try {
await syncInvoiceStatusFromPaymentEntry(supabase, companyId, entryBefore)
} catch (syncError) {
logger.warn('payment status sync failed after delete', { entryId: id, error: syncError })
}
}
await eventBus.emit({
type: 'journal_entry.deleted',
payload: {
+17 -8
View File
@@ -133,6 +133,15 @@ export const POST = withRouteContext(
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Drive the JE shape from the invoice's actual booking state, not from
// the current accounting_method setting. If the invoice was booked at
// send (Dr 1510 / Cr 30xx + VAT), the payment MUST clear 1510 —
// otherwise the receivable orphans and 30xx + VAT double-count. Only
// when there is no prior JE (pure kontantmetoden) do we recognise
// revenue + VAT here.
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
@@ -155,7 +164,7 @@ export const POST = withRouteContext(
details: { paymentDate },
})
}
const sourceType = accountingMethod === 'accrual' ? 'invoice_paid' : 'invoice_cash_payment'
const sourceType = useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
@@ -168,18 +177,18 @@ export const POST = withRouteContext(
}
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, input)
journalEntryId = journalEntry?.id ?? null
} else if (accountingMethod === 'accrual') {
const journalEntry = await createInvoicePaymentJournalEntry(
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
exchangeRateDifference, invoice.customer?.name,
)
journalEntryId = journalEntry?.id ?? null
} else {
} else if (useCashEntry) {
const journalEntry = await createInvoiceCashEntry(
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
entityType, invoice.customer?.name,
)
journalEntryId = journalEntry?.id ?? null
} else {
const journalEntry = await createInvoicePaymentJournalEntry(
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
exchangeRateDifference, invoice.customer?.name,
)
journalEntryId = journalEntry?.id ?? null
}
} catch (err) {
if (isBookkeepingError(err)) {
@@ -5,6 +5,7 @@ import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { validateBody } from '@/lib/api/validate'
import { SalaryEmployeeOverrideSchema } from '@/lib/api/schemas'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
ensureInitialized()
@@ -35,7 +36,20 @@ export async function GET(
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
}
return NextResponse.json({ data })
// Strip the encrypted personnummer ciphertext before sending to the browser
// — replace it with the YYYYMMDD-XXXX masked form so the page can render
// identity without exposing the suffix or the raw cipher blob.
const masked = {
...data,
employee: data.employee
? {
...data.employee,
personnummer: maskPersonnummer(decryptPersonnummer(data.employee.personnummer)),
}
: data.employee,
}
return NextResponse.json({ data: masked })
}
/**
@@ -0,0 +1,141 @@
/**
* GET /api/supplier-invoices/[id]/mark-paid/preview?amount=...&payment_account=...
*
* Read-only preview of the journal entry mark-paid would post. Mirrors the
* POST handler's routing: if the SI has a registration JE, payment clears
* 2440. Otherwise (kontantmetoden + never booked), expense + input VAT
* book here.
*/
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
type PreviewLine = {
account_number: string
debit_amount: number
credit_amount: number
description: string
}
const QuerySchema = z.object({
amount: z.coerce.number().positive(),
payment_account: z.string().min(1).optional(),
})
export const GET = withRouteContext(
'supplier_invoice.mark_paid_preview',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
const url = new URL(request.url)
const parsed = QuerySchema.safeParse({
amount: url.searchParams.get('amount'),
payment_account: url.searchParams.get('payment_account') ?? undefined,
})
if (!parsed.success) {
return errorResponseFromCode('VALIDATION_ERROR', log, { requestId })
}
const { amount, payment_account } = parsed.data
const { data: invoice, error: invErr } = await supabase
.from('supplier_invoices')
.select('*, items:supplier_invoice_items(*)')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (invErr || !invoice) {
return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', log, { requestId })
}
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, last_supplier_payment_account')
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const creditAccount =
payment_account ||
(settings as { last_supplier_payment_account?: string } | null)?.last_supplier_payment_account ||
'1930'
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
const lines: PreviewLine[] = []
let entryType: 'clearing' | 'cash' = 'clearing'
if (useCashEntry) {
entryType = 'cash'
const si = invoice as SupplierInvoice & { items?: SupplierInvoiceItem[] }
const items = si.items ?? []
let totalAmountSek = 0
let totalVatSek = 0
if (items.length > 0) {
for (const it of items) {
const lineTotal = resolveSekAmount(it.line_total, null, si.currency, si.exchange_rate)
const vat = resolveSekAmount(it.vat_amount, null, si.currency, si.exchange_rate)
const expenseAcct = (it as { expense_account?: string | null }).expense_account ?? '4000'
lines.push({
account_number: expenseAcct,
debit_amount: Math.round((lineTotal - vat) * 100) / 100,
credit_amount: 0,
description: it.description ?? 'Kostnad',
})
totalAmountSek += lineTotal
totalVatSek += vat
}
} else {
const subSek = resolveSekAmount(si.subtotal, si.subtotal_sek, si.currency, si.exchange_rate)
const vatSek = resolveSekAmount(si.vat_amount, si.vat_amount_sek, si.currency, si.exchange_rate)
lines.push({
account_number: '4000',
debit_amount: Math.round(subSek * 100) / 100,
credit_amount: 0,
description: 'Kostnad',
})
totalAmountSek = subSek + vatSek
totalVatSek = vatSek
}
if (totalVatSek > 0) {
lines.push({
account_number: '2641',
debit_amount: Math.round(totalVatSek * 100) / 100,
credit_amount: 0,
description: 'Ingående moms',
})
}
lines.push({
account_number: creditAccount,
debit_amount: 0,
credit_amount: Math.round(totalAmountSek * 100) / 100,
description: 'Utbetalning',
})
} else {
const rounded = Math.round(amount * 100) / 100
lines.push({
account_number: '2440',
debit_amount: rounded,
credit_amount: 0,
description: 'Kvittning leverantörsskuld',
})
lines.push({
account_number: creditAccount,
debit_amount: 0,
credit_amount: rounded,
description: 'Utbetalning',
})
}
return NextResponse.json({
entry_type: entryType,
lines,
invoice_already_booked: siAlreadyBooked,
accounting_method: accountingMethod,
})
},
)
@@ -5,6 +5,7 @@ import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { validateBody } from '@/lib/api/validate'
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
@@ -124,16 +125,54 @@ export const POST = withRouteContext(
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.select('accounting_method, last_supplier_payment_account')
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const paymentAccount = body.payment_account || undefined
// Route on the supplier invoice's actual booking state, not the current
// accounting_method. A supplier invoice that was booked at receipt under
// accrual (Dr expense + 2641 / Cr 2440) must clear 2440 here even if the
// company has since switched to kontantmetoden — otherwise the supplier
// debt orphans on 2440 and expense + input VAT double-count.
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash') {
if (body.lines) {
const totalDebit = body.lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = body.lines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return errorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', opLog, {
requestId,
details: { totalDebit, totalCredit },
})
}
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, paymentDate)
if (!fiscalPeriodId) {
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', opLog, {
requestId,
details: { paymentDate },
})
}
const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid'
const desc = invoice.supplier?.name
? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}`
: `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
const je = await createJournalEntry(supabase, companyId!, user.id, {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: desc,
source_type: sourceType,
source_id: invoice.id,
lines: body.lines,
})
if (je) journalEntryId = je.id
} else if (useCashEntry) {
const journalEntry = await createSupplierInvoiceCashEntry(
supabase, companyId!, user.id,
invoice as SupplierInvoice,
@@ -141,6 +180,7 @@ export const POST = withRouteContext(
paymentDate,
invoice.supplier?.supplier_type || 'swedish_business',
invoice.supplier?.name,
paymentAccount,
)
if (journalEntry) journalEntryId = journalEntry.id
} else {
@@ -150,6 +190,7 @@ export const POST = withRouteContext(
paymentAmount, paymentDate,
body.exchange_rate_difference,
invoice.supplier?.name,
paymentAccount,
)
if (journalEntry) journalEntryId = journalEntry.id
}
@@ -247,6 +288,19 @@ export const POST = withRouteContext(
opLog.warn('supplier_invoice.paid event emission failed', err as Error)
}
// Remember the chosen payment account so the next dialog can default to it.
// Only update when the caller actually picked one — the MCP / agent path
// sends no payment_account and shouldn't churn this setting.
if (paymentAccount && paymentAccount !== settings?.last_supplier_payment_account) {
const { error: settingsError } = await supabase
.from('company_settings')
.update({ last_supplier_payment_account: paymentAccount })
.eq('company_id', companyId)
if (settingsError) {
opLog.warn('failed to persist last_supplier_payment_account', settingsError)
}
}
return NextResponse.json({
success: true,
status: newStatus,
+105
View File
@@ -0,0 +1,105 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* POST /api/transactions/[id]/ignore
*
* Mark a bank transaction as ignored so it stops surfacing in the bank
* reconciliation view (and other "to book" funnels) without creating a
* verifikation. Use case: tiny ränteintäkter, rounding noise, opening-balance
* artefacts — anything the user wants off the unmatched list but doesn't want
* to fabricate a journal entry for.
*
* Refuses when the transaction is already booked; once a verifikation exists,
* the proper way to revisit it is /uncategorize (storno).
*/
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('id, journal_entry_id, is_ignored')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
if (transaction.journal_entry_id) {
return NextResponse.json(
{ error: 'Transaktionen är redan bokförd — använd Avmatcha eller backa verifikationen för att ändra status.' },
{ status: 409 }
)
}
if (transaction.is_ignored) {
return NextResponse.json({ success: true, already_ignored: true })
}
const { error: updateError } = await supabase
.from('transactions')
.update({ is_ignored: true })
.eq('id', id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
/**
* DELETE /api/transactions/[id]/ignore
*
* Reverse a previous ignore. The row comes back into the unmatched list with
* no further side effects — we never created a verifikation, so there's
* nothing to storno.
*/
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { error: updateError } = await supabase
.from('transactions')
.update({ is_ignored: false })
.eq('id', id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
@@ -393,6 +393,56 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
expect(body.remaining_amount).toBe(7500)
})
it('cash method ignores cash entry when invoice was already booked (accrual→cash migration)', async () => {
// Regression: customer sent invoices under accrual (1510 was debited on
// send), then switched to kontantmetoden before the bank receipt arrived.
// Old logic posted createInvoiceCashEntry — orphaning 1510 and double-
// counting revenue + VAT. Fix: route on invoice.journal_entry_id, not on
// the current accounting_method setting.
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
const invoice = {
...makeInvoice({
id: VALID_UUID,
status: 'sent',
total: 12500,
remaining_amount: 12500,
paid_amount: 0,
}),
// journal_entry_id lives on the DB column but not the TS Invoice type;
// attach via spread so the test row mirrors a real accrual-booked
// invoice the matcher will read.
journal_entry_id: 'je-send-on-accrual',
}
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
enqueue({ data: [], error: null }) // hard-duplicate check
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-clearing' })
// The PDF re-attach block runs because invoice.journal_entry_id is set;
// returning null skips the attach without aborting the match.
enqueue({ data: null, error: null }) // document_attachments lookup
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
enqueue({ data: null, error: null }) // insert invoice_payments
enqueue({ data: null, error: null }) // update transaction
enqueue({ data: null, error: null }) // logMatchEvent
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
body: { invoice_id: VALID_UUID },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ invoice_status: string }>(response)
expect(status).toBe(200)
expect(body.invoice_status).toBe('paid')
// Must clear 1510, not re-recognise revenue + VAT
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled()
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
})
it('returns 400 MATCH_AMOUNT_EXCEEDS_REMAINING when tx amount exceeds invoice remaining', async () => {
// Tx is +12 000 SEK, invoice has 5 000 SEK remaining. Legacy code path
// would push paid_amount past invoice.total; the new guard rejects so
@@ -0,0 +1,186 @@
/**
* GET /api/transactions/[id]/match-invoice/preview?invoice_id=...
*
* Returns the journal entry lines that match-invoice would create for this
* (transaction, invoice) pair. Read-only — does not stage or write anything.
*
* The shape mirrors the routing decision in the POST handler: if the invoice
* was already booked (invoice.journal_entry_id is set, i.e. 1510 is on the
* books), we preview the clearing entry (Dr 1930 / Cr 1510). Only when the
* invoice was never booked AND the company is on kontantmetoden AND the
* receipt fully pays the invoice do we preview the cash entry (Dr 1930 /
* Cr 30xx / Cr 26xx).
*
* The UI uses this to show the user the exact lines before they confirm —
* the lack of any preview was part of the reported bug.
*/
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { getRevenueAccount, getOutputVatAccount } from '@/lib/bookkeeping/invoice-entries'
import type { EntityType, Invoice, InvoiceItem } from '@/types'
import { z } from 'zod'
type PreviewLine = {
account_number: string
debit_amount: number
credit_amount: number
description: string
}
const QuerySchema = z.object({
invoice_id: z.string().uuid(),
})
export const GET = withRouteContext(
'transaction.match_invoice_preview',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id: transactionId } = await params
const { supabase, companyId, log, requestId } = ctx
const url = new URL(request.url)
const parsed = QuerySchema.safeParse({ invoice_id: url.searchParams.get('invoice_id') })
if (!parsed.success) {
return errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
details: { field: 'invoice_id', message: 'invoice_id must be a UUID' },
})
}
const { invoice_id } = parsed.data
const { data: transaction, error: txErr } = await supabase
.from('transactions')
.select('id, date, amount, currency')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (txErr || !transaction) {
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId })
}
const { data: invoice, error: invErr } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('id', invoice_id)
.eq('company_id', companyId)
.single()
if (invErr || !invoice) {
return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', log, { requestId })
}
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const paidAmount = transaction.amount
const currentRemaining =
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
const newRemaining = Math.max(
0,
Math.round((currentRemaining - paidAmount) * 100) / 100,
)
const isFullyPaid = newRemaining <= 0
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
const lines: PreviewLine[] = []
let entryType: 'clearing' | 'cash' = 'clearing'
if (useCashEntry) {
entryType = 'cash'
// Mirror createInvoiceCashEntry: per-rate revenue + VAT credits, 1930 debit.
const inv = invoice as Invoice & { items?: InvoiceItem[] }
const items = inv.items ?? []
const isForeign = inv.currency !== 'SEK'
// Per-item rate aggregation (matches generatePerRateLines semantics).
// InvoiceItem.line_total is the gross-net-line; the subtotal contribution
// is line_total minus that line's vat_amount.
const byRate = new Map<number, { subtotal: number; vat: number }>()
if (items.length > 0) {
for (const it of items) {
const rate = it.vat_rate ?? 25
const itemVat = resolveSekAmount(it.vat_amount, null, inv.currency, inv.exchange_rate)
const itemTotal = resolveSekAmount(it.line_total, null, inv.currency, inv.exchange_rate)
const sub = Math.round((itemTotal - itemVat) * 100) / 100
const bucket = byRate.get(rate) ?? { subtotal: 0, vat: 0 }
bucket.subtotal += sub
bucket.vat += itemVat
byRate.set(rate, bucket)
}
} else {
// Fallback to invoice-level totals
const sub = resolveSekAmount(inv.subtotal, inv.subtotal_sek, inv.currency, inv.exchange_rate)
const vat = resolveSekAmount(inv.vat_amount, inv.vat_amount_sek, inv.currency, inv.exchange_rate)
byRate.set(inv.vat_rate ?? 25, { subtotal: sub, vat })
}
const creditLines: PreviewLine[] = []
for (const [rate, totals] of byRate) {
const vatTreatment = totals.vat > 0
? (rate === 25 ? 'standard_25' : rate === 12 ? 'reduced_12' : rate === 6 ? 'reduced_6' : inv.vat_treatment)
: inv.vat_treatment
const revenueAcct = getRevenueAccount(vatTreatment, entityType)
creditLines.push({
account_number: revenueAcct,
debit_amount: 0,
credit_amount: Math.round(totals.subtotal * 100) / 100,
description: `Försäljning ${rate}%`,
})
if (totals.vat > 0) {
creditLines.push({
account_number: getOutputVatAccount(vatTreatment),
debit_amount: 0,
credit_amount: Math.round(totals.vat * 100) / 100,
description: `Utgående moms ${rate}%`,
})
}
}
const totalCredits = creditLines.reduce((s, l) => s + l.credit_amount, 0)
const cashDebit = isForeign
? Math.round(totalCredits * 100) / 100
: resolveSekAmount(inv.total, inv.total_sek, inv.currency, inv.exchange_rate)
lines.push({
account_number: '1930',
debit_amount: Math.round(cashDebit * 100) / 100,
credit_amount: 0,
description: 'Inbetalning från bank',
})
lines.push(...creditLines)
} else {
// Clearing entry: Dr 1930 / Cr 1510 at the paid amount in SEK.
const inv = invoice as Invoice
const bookedSek = resolveSekAmount(paidAmount, null, inv.currency, inv.exchange_rate)
const amount = Math.round(bookedSek * 100) / 100
lines.push({
account_number: '1930',
debit_amount: amount,
credit_amount: 0,
description: 'Inbetalning från bank',
})
lines.push({
account_number: '1510',
debit_amount: 0,
credit_amount: amount,
description: 'Kvittning kundfordran',
})
}
return NextResponse.json({
entry_type: entryType,
lines,
invoice_already_booked: invoiceAlreadyBooked,
accounting_method: accountingMethod,
is_fully_paid: isFullyPaid,
})
},
)
@@ -3,7 +3,7 @@ import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { withRouteContext } from '@/lib/api/with-route-context'
@@ -41,7 +41,7 @@ export const POST = withRouteContext(
operation: 'transaction.match_invoice',
})
if (!validation.success) return validation.response
const { invoice_id, force, expected_journal_entry_id } = validation.data
const { invoice_id, force, expected_journal_entry_id, lines: customLines } = validation.data
const txLog = log.child({ transactionId, invoiceId: invoice_id })
@@ -251,21 +251,66 @@ export const POST = withRouteContext(
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Drive the JE shape from the INVOICE'S booking state, not from the
// company's current accounting_method setting. If the invoice was already
// booked at send (Dr 1510 / Cr 30xx + VAT) we MUST clear 1510 here —
// otherwise the receivable stays orphaned and 30xx + VAT get double-
// counted. This happens when a company sent invoices under accrual,
// then flipped to kontantmetoden before payment arrived.
// Only when the invoice carries no prior JE (pure kontantmetoden, no
// receivable on the books) do we recognise revenue + VAT here.
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
let journalEntryId: string | null = null
let journalEntryError: string | null = null
try {
if (accountingMethod === 'cash' && isFullyPaid) {
if (customLines) {
// User-edited rows from the match dialog. Validate balance, then
// post via createJournalEntry directly. source_type still derives
// from the routing decision so downstream payment-sync (which keys
// off invoice_paid / invoice_cash_payment) keeps working.
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return errorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', txLog, {
requestId,
details: { totalDebit, totalCredit },
})
}
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, transaction.date)
if (!fiscalPeriodId) {
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
requestId,
details: { paymentDate: transaction.date },
})
}
const sourceType = useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
const desc = invoice.customer?.name
? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}`
: `Inbetalning kundfaktura ${invoice.invoice_number}`
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: desc,
source_type: sourceType,
source_id: invoice.id,
lines: customLines,
})
journalEntryId = journalEntry?.id ?? null
} else if (useCashEntry) {
const journalEntry = await createInvoiceCashEntry(
supabase, companyId, user.id, invoice as Invoice, transaction.date,
entityType, invoice.customer?.name,
)
journalEntryId = journalEntry?.id ?? null
} else {
// Accrual or cash partial: clearing entry against 1510. The cash-method
// partial path is intentional — under kontantmetoden 1510 has no prior
// balance, so this leaves a credit on 1510 that gets resolved when the
// final payment lands and createInvoiceCashEntry runs.
// Clearing entry against 1510. Covers accrual, cash-with-prior-JE
// (mid-stream switch), and cash partial. The cash partial path is
// intentional — under kontantmetoden 1510 has no prior balance, so
// partials leave a credit on 1510 that gets resolved on final
// payment when createInvoiceCashEntry would normally run.
const journalEntry = await createInvoicePaymentJournalEntry(
supabase, companyId, user.id, invoice as Invoice, transaction.date,
undefined, invoice.customer?.name, paidAmount,
@@ -356,7 +401,11 @@ export const POST = withRouteContext(
return errorResponseFromCode('MATCH_INVOICE_ALREADY_PAID', txLog, { requestId })
}
const paymentNotes = (accountingMethod === 'cash' && !isFullyPaid)
// The "intäkt bokförs vid slutbetalning" note only applies to genuine
// kontantmetoden partials — invoices that were never booked. When the
// invoice was booked under accrual, the clearing entry already handles
// the partial cleanly and the note would be misleading.
const paymentNotes = (!invoiceAlreadyBooked && accountingMethod === 'cash' && !isFullyPaid)
? 'Kontantmetoden: intäkt bokförs vid slutbetalning'
: null
@@ -0,0 +1,167 @@
/**
* GET /api/transactions/[id]/match-supplier-invoice/preview?supplier_invoice_id=...
*
* Read-only preview of the journal entry lines that match-supplier-invoice
* would create. Mirrors the routing decision in the POST handler: if the
* supplier invoice already has a registration JE (2440 posted at receipt),
* payment clears 2440. Only true kontantmetoden SIs (no registration JE)
* book expense + input VAT here.
*/
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
type PreviewLine = {
account_number: string
debit_amount: number
credit_amount: number
description: string
}
const QuerySchema = z.object({
supplier_invoice_id: z.string().uuid(),
})
export const GET = withRouteContext(
'transaction.match_supplier_invoice_preview',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id: transactionId } = await params
const { supabase, companyId, log, requestId } = ctx
const url = new URL(request.url)
const parsed = QuerySchema.safeParse({
supplier_invoice_id: url.searchParams.get('supplier_invoice_id'),
})
if (!parsed.success) {
return errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
details: { field: 'supplier_invoice_id', message: 'supplier_invoice_id must be a UUID' },
})
}
const { supplier_invoice_id } = parsed.data
const { data: transaction, error: txErr } = await supabase
.from('transactions')
.select('id, date, amount, currency')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (txErr || !transaction) {
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId })
}
const { data: invoice, error: invErr } = await supabase
.from('supplier_invoices')
.select('*, items:supplier_invoice_items(*)')
.eq('id', supplier_invoice_id)
.eq('company_id', companyId)
.single()
if (invErr || !invoice) {
return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', log, { requestId })
}
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, last_supplier_payment_account')
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const paymentAccount =
(settings as { last_supplier_payment_account?: string } | null)?.last_supplier_payment_account || '1930'
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
const lines: PreviewLine[] = []
let entryType: 'clearing' | 'cash' = 'clearing'
if (useCashEntry) {
entryType = 'cash'
const si = invoice as SupplierInvoice & { items?: SupplierInvoiceItem[] }
const items = si.items ?? []
// Mirror createSupplierInvoiceCashEntry: per-item expense debit + VAT
// debit + bank credit. We only need a faithful preview, not exact
// account-mapping fidelity — show one aggregate expense line per item
// (or a single fallback line if items are missing).
let totalAmountSek = 0
let totalVatSek = 0
if (items.length > 0) {
for (const it of items) {
const lineTotal = resolveSekAmount(it.line_total, null, si.currency, si.exchange_rate)
const vat = resolveSekAmount(it.vat_amount, null, si.currency, si.exchange_rate)
const expenseAcct = (it as { expense_account?: string | null }).expense_account ?? '4000'
lines.push({
account_number: expenseAcct,
debit_amount: Math.round((lineTotal - vat) * 100) / 100,
credit_amount: 0,
description: it.description ?? 'Kostnad',
})
totalAmountSek += lineTotal
totalVatSek += vat
}
} else {
const subSek = resolveSekAmount(si.subtotal, si.subtotal_sek, si.currency, si.exchange_rate)
const vatSek = resolveSekAmount(si.vat_amount, si.vat_amount_sek, si.currency, si.exchange_rate)
lines.push({
account_number: '4000',
debit_amount: Math.round(subSek * 100) / 100,
credit_amount: 0,
description: 'Kostnad',
})
totalAmountSek = subSek + vatSek
totalVatSek = vatSek
}
if (totalVatSek > 0) {
lines.push({
account_number: '2641',
debit_amount: Math.round(totalVatSek * 100) / 100,
credit_amount: 0,
description: 'Ingående moms',
})
}
lines.push({
account_number: paymentAccount,
debit_amount: 0,
credit_amount: Math.round(totalAmountSek * 100) / 100,
description: 'Utbetalning från bank',
})
} else {
// Clearing: Dr 2440 / Cr 1930 (or chosen payment account).
const si = invoice as SupplierInvoice
const amountSek = resolveSekAmount(
Math.abs(transaction.amount),
null,
transaction.currency,
null,
)
const total = resolveSekAmount(si.total, si.total_sek, si.currency, si.exchange_rate)
const amount = Math.round(Math.min(amountSek, total) * 100) / 100
lines.push({
account_number: '2440',
debit_amount: amount,
credit_amount: 0,
description: 'Kvittning leverantörsskuld',
})
lines.push({
account_number: paymentAccount,
debit_amount: 0,
credit_amount: amount,
description: 'Utbetalning från bank',
})
}
return NextResponse.json({
entry_type: entryType,
lines,
invoice_already_booked: siAlreadyBooked,
accounting_method: accountingMethod,
})
},
)
@@ -3,6 +3,7 @@ import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { withRouteContext } from '@/lib/api/with-route-context'
@@ -32,7 +33,7 @@ export const POST = withRouteContext(
operation: 'transaction.match_supplier_invoice',
})
if (!validation.success) return validation.response
const { supplier_invoice_id } = validation.data
const { supplier_invoice_id, lines: customLines } = validation.data
const txLog = log.child({ transactionId, supplierInvoiceId: supplier_invoice_id })
@@ -163,13 +164,22 @@ export const POST = withRouteContext(
const accountingMethod = settings?.accounting_method || 'accrual'
// Route on the supplier invoice's actual booking state — if 2440 was
// posted at receipt (accrual), the match must clear 2440 regardless of
// the company's current setting. Only true kontantmetoden invoices
// (no registration JE) book expense + input VAT here.
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
// Cash method (kontantmetoden) collapses registration + payment into a
// single entry that credits 1930 at sum(expenses_SEK). It has no
// exchange_rate_difference path — if the actual bank SEK differs from
// the invoice's booked SEK, the 1930 credit won't match the bank
// transaction and we'd silently leave a reconciliation gap. Block the
// combination and ask the user to switch to accrual or do a manual JE.
if (accountingMethod === 'cash' && exchangeRateDifference !== 0) {
// Only applies to true cash-method invoices — accrual-booked invoices
// never hit the cash branch.
if (useCashEntry && exchangeRateDifference !== 0) {
return errorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
requestId,
details: {
@@ -184,7 +194,36 @@ export const POST = withRouteContext(
let journalEntryError: string | null = null
try {
if (accountingMethod === 'cash') {
if (customLines) {
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return errorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', txLog, {
requestId,
details: { totalDebit, totalCredit },
})
}
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, transaction.date)
if (!fiscalPeriodId) {
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
requestId,
details: { paymentDate: transaction.date },
})
}
const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid'
const desc = invoice.supplier?.name
? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}`
: `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: desc,
source_type: sourceType,
source_id: invoice.id,
lines: customLines,
})
if (journalEntry) journalEntryId = journalEntry.id
} else if (useCashEntry) {
const journalEntry = await createSupplierInvoiceCashEntry(
supabase, companyId, user.id, invoice as SupplierInvoice,
(invoice.items || []) as SupplierInvoiceItem[],
+29 -2
View File
@@ -20,20 +20,47 @@ export async function GET(request: Request) {
const currency = searchParams.get('currency') || undefined
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
// When set, return only ignored rows — used by the reconciliation view to
// surface a "Visa ignorerade" undo list. The default (no param) behaviour
// continues to exclude ignored rows from unmatched results.
const onlyIgnored = searchParams.get('only_ignored') === 'true'
// account_number is accepted for API symmetry with the reconciliation status
// endpoint; transactions don't carry a cash_account FK today (PSD2 account
// identity is embedded in external_id), so we use it to derive a default
// currency when the caller didn't supply one. Anything more precise needs
// the cash_account_id backfill tracked as Tier 4.
const accountNumberParam = searchParams.get('account_number') || undefined
let derivedCurrency = currency
if (!derivedCurrency && accountNumberParam) {
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('currency')
.eq('company_id', companyId)
.eq('ledger_account', accountNumberParam)
.maybeSingle()
if (cashAccount?.currency) derivedCurrency = cashAccount.currency as string
}
let query = supabase
.from('transactions')
.select('id, date, description, amount, currency, amount_sek, exchange_rate, reference, journal_entry_id, reconciliation_method')
.select('id, date, description, amount, currency, amount_sek, exchange_rate, reference, journal_entry_id, reconciliation_method, is_ignored')
.eq('company_id', companyId)
// unmatched and reconciled are mutually exclusive — unmatched wins if both set
if (unmatched) {
query = query.is('journal_entry_id', null)
// Hide rows the user has explicitly suppressed from the reconciliation
// view. Other callers (e.g. BookDirectlyDialog) also benefit — once
// ignored, the row stops surfacing in the "to book" funnel everywhere.
if (!onlyIgnored) query = query.eq('is_ignored', false)
} else if (reconciled) {
query = query.not('journal_entry_id', 'is', null)
}
if (currency) query = query.eq('currency', currency)
if (onlyIgnored) query = query.eq('is_ignored', true)
if (derivedCurrency) query = query.eq('currency', derivedCurrency)
if (dateFrom) query = query.gte('date', dateFrom)
if (dateTo) query = query.lte('date', dateTo)
@@ -240,6 +240,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const entityType = ((settings as { entity_type?: string } | null)?.entity_type ??
'enskild_firma') as EntityType
// The JE shape is driven by the invoice's actual booking state, not the
// company's current accounting_method. An invoice that was booked at send
// under accrual (Dr 1510) must be cleared at payment regardless of where
// the setting sits today — otherwise the receivable orphans and 30xx +
// VAT double-count. Only true kontantmetoden invoices (never booked)
// recognise revenue + VAT here.
const invoiceAlreadyBooked = !!(typed as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
// Compute the would-be payment amount. Default path (no customLines):
// use remaining_amount, not total — protects against over-crediting AR
// when a concurrent partial payment slips through the pre-flight check
@@ -363,7 +372,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
input,
)
journalEntryId = entry?.id ?? null
} else if (accountingMethod === 'cash') {
} else if (useCashEntry) {
const entry = await createInvoiceCashEntry(
ctx.supabase,
ctx.companyId!,
@@ -26,7 +26,7 @@ import {
createSupplierInvoiceCashEntry,
createSupplierInvoicePaymentEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
@@ -117,6 +117,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
let bodyPaymentDate: string | undefined
let exchangeRateDifference: number | undefined
let bodyNotes: string | undefined
let customLines:
| Array<{ account_number: string; debit_amount: number; credit_amount: number; line_description?: string }>
| undefined
if (rawBody) {
const parsed = MarkSupplierInvoicePaidSchema.safeParse(rawBody)
if (!parsed.success) {
@@ -134,6 +137,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
bodyPaymentDate = parsed.data.payment_date
exchangeRateDifference = parsed.data.exchange_rate_difference
bodyNotes = parsed.data.notes
customLines = parsed.data.lines
}
const today = new Date().toISOString().split('T')[0]
@@ -276,14 +280,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
.maybeSingle()
const accountingMethod = (settings as { accounting_method?: string } | null)?.accounting_method ?? 'accrual'
// FX-required validation. Under accrual the registration JE used the
// invoice's exchange rate to compute subtotal_sek; the payment JE has to
// book any rate delta to 3960 / 7960 (BAS) or AP will carry a stranded
// 2440 balance after the bank line clears. The pitfall docs warn about
// this — enforce it.
// Route on the supplier invoice's actual booking state — if 2440 was
// posted at receipt, payment must clear 2440 regardless of the current
// accounting_method.
const siAlreadyBooked = !!(typed as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
// FX-required validation. Whenever the registration JE used the invoice's
// exchange rate to compute subtotal_sek (i.e. the SI was booked under
// accrual or migrated from accrual), the payment JE has to book any rate
// delta to 3960 / 7960 or AP will carry a stranded 2440 balance after the
// bank line clears. Gated on the booking state, not the current setting.
if (
typed.currency !== 'SEK' &&
accountingMethod === 'accrual' &&
!useCashEntry &&
exchangeRateDifference === undefined
) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
@@ -328,7 +338,36 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// Strict-mode: book the JE FIRST. Failure aborts before any SI mutation.
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash') {
if (customLines) {
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return v1ErrorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', ctx.log, {
requestId: ctx.requestId,
details: { totalDebit, totalCredit },
})
}
const fiscalPeriodId = await findFiscalPeriod(ctx.supabase, ctx.companyId!, paymentDate)
if (!fiscalPeriodId) {
return v1ErrorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', ctx.log, {
requestId: ctx.requestId,
details: { payment_date: paymentDate },
})
}
const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid'
const desc = supplierRow?.name
? `Utbetalning leverantörsfaktura ${typed.supplier_invoice_number}, ${supplierRow.name}`
: `Utbetalning leverantörsfaktura ${typed.supplier_invoice_number}`
const entry = await createJournalEntry(ctx.supabase, ctx.companyId!, ctx.userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: desc,
source_type: sourceType,
source_id: typed.id,
lines: customLines,
})
journalEntryId = entry?.id ?? null
} else if (useCashEntry) {
const entry = await createSupplierInvoiceCashEntry(
ctx.supabase,
ctx.companyId!,
@@ -26,7 +26,7 @@ import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { logMatchEvent } from '@/lib/invoices/match-log'
@@ -123,7 +123,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
},
})
}
const { invoice_id, force, expected_journal_entry_id } = parsed.data
const { invoice_id, force, expected_journal_entry_id, lines: customLines } = parsed.data
const txLog = ctx.log.child({ transactionId: txId, invoiceId: invoice_id })
const { data: transaction, error: fetchTxErr } = await ctx.supabase
@@ -309,15 +309,23 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const entityType: EntityType =
(settings?.entity_type as EntityType) || 'enskild_firma'
// Reject cash-method partial payments. Under kontantmetoden, utgående
// moms must be reported in the period of actual receipt (ML 13 kap 8 §);
// the partial-payment branch below uses createInvoicePaymentJournalEntry
// (the accrual-style 1510/1930 clearing entry), which doesn't model the
// per-installment moms event. Rather than silently over-report moms,
// refuse the operation and document the constraint. Full payments
// (isFullyPaid=true) flow through createInvoiceCashEntry which IS the
// correct kontantmetod path.
if (accountingMethod === 'cash' && !isFullyPaid) {
// The JE shape is driven by the INVOICE'S booking state, not the
// company's current setting. If the invoice already has a JE (Dr 1510
// posted at send), the match must clear 1510 — otherwise the receivable
// stays orphaned and 30xx + 26xx get double-counted. The current
// accounting_method only governs the cash-method fast path for
// invoices that were never booked.
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
// Reject cash-method partial payments ONLY for pure kontantmetoden
// invoices (no prior JE). Under kontantmetoden utgående moms must be
// reported in the period of actual receipt (ML 13 kap 8 §); the
// partial-payment branch uses the accrual-style clearing entry which
// doesn't model the per-installment moms event. When the invoice was
// already booked under accrual, the clearing entry IS the correct
// partial path regardless of the company's current setting.
if (!invoiceAlreadyBooked && accountingMethod === 'cash' && !isFullyPaid) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', txLog, {
requestId: ctx.requestId,
details: {
@@ -340,7 +348,36 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// strictly worse than a clean failure to retry.
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash' && isFullyPaid) {
if (customLines) {
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return v1ErrorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', txLog, {
requestId: ctx.requestId,
details: { totalDebit, totalCredit },
})
}
const fiscalPeriodId = await findFiscalPeriod(ctx.supabase, ctx.companyId!, transaction.date)
if (!fiscalPeriodId) {
return v1ErrorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
requestId: ctx.requestId,
details: { payment_date: transaction.date },
})
}
const sourceType = useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
const desc = invoice.customer?.name
? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}`
: `Inbetalning kundfaktura ${invoice.invoice_number}`
const je = await createJournalEntry(ctx.supabase, ctx.companyId!, ctx.userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: desc,
source_type: sourceType,
source_id: invoice.id,
lines: customLines,
})
journalEntryId = je?.id ?? null
} else if (useCashEntry) {
const je = await createInvoiceCashEntry(
ctx.supabase,
ctx.companyId!,
@@ -436,8 +473,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// The "intäkt bokförs vid slutbetalning" note only applies to genuine
// kontantmetoden partials — never-booked invoices. When the invoice was
// booked under accrual, the clearing entry handles the partial cleanly
// and the note would be misleading.
const paymentNotes =
accountingMethod === 'cash' && !isFullyPaid
!invoiceAlreadyBooked && accountingMethod === 'cash' && !isFullyPaid
? 'Kontantmetoden: intäkt bokförs vid slutbetalning'
: null
@@ -15,7 +15,7 @@ import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { logMatchEvent } from '@/lib/invoices/match-log'
@@ -103,7 +103,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
},
})
}
const { supplier_invoice_id } = parsed.data
const { supplier_invoice_id, lines: customLines } = parsed.data
const txLog = ctx.log.child({ transactionId: txId, supplierInvoiceId: supplier_invoice_id })
const { data: transaction, error: fetchTxErr } = await ctx.supabase
@@ -209,7 +209,13 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
if (accountingMethod === 'cash' && exchangeRateDifference !== 0) {
// Route on the supplier invoice's actual booking state. An invoice
// booked at receipt (registration_journal_entry_id set) must clear
// 2440 regardless of the company's current setting.
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
if (useCashEntry && exchangeRateDifference !== 0) {
return v1ErrorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
requestId: ctx.requestId,
details: {
@@ -224,7 +230,36 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// payment JE can't be created. See the parallel comment in match-invoice.
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash') {
if (customLines) {
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return v1ErrorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', txLog, {
requestId: ctx.requestId,
details: { totalDebit, totalCredit },
})
}
const fiscalPeriodId = await findFiscalPeriod(ctx.supabase, ctx.companyId!, transaction.date)
if (!fiscalPeriodId) {
return v1ErrorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
requestId: ctx.requestId,
details: { payment_date: transaction.date },
})
}
const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid'
const desc = invoice.supplier?.name
? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}`
: `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
const je = await createJournalEntry(ctx.supabase, ctx.companyId!, ctx.userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: desc,
source_type: sourceType,
source_id: invoice.id,
lines: customLines,
})
if (je) journalEntryId = je.id
} else if (useCashEntry) {
const je = await createSupplierInvoiceCashEntry(
ctx.supabase,
ctx.companyId!,