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
@@ -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!,