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:
co-authored by
Claude Opus 4.7
parent
fc7a46c3f2
commit
ea1bf01f1e
@@ -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!,
|
||||
|
||||
Reference in New Issue
Block a user