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
@@ -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[],
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user