Files
accounted/app/api/pending-operations/[id]/route.ts
T
7cf15a105f fix(bookkeeping): settle unbound transactions on the company's single enabled cash account (#1831)
* fix(bookkeeping): settle unbound transactions on the company's single enabled cash account

A transaction with no cash_account_id booked its bank leg on the
hardcoded 1930 from the standard templates and category mappings even
when the company's only bank account is e.g. 1920 (PlusGiro), while the
booking dialogs previewed the right account via the client-side
resolveAccount fallback. resolveSettlementAccount now mirrors that
fallback: with a NULL cash_account_id it lists the company's enabled
cash accounts and, when EXACTLY ONE matches the transaction's currency,
settles there; zero or several candidates keep the 1930 fallback. The
explicit-cash_account_id branch (including its throw-on-error path,
issue #842) is byte-identical. Transaction currency is threaded into
the categorize, batch-categorize, pending-operation edit, MCP staging,
and invoice-inbox call sites; other callers get the SEK default.

Forward-only: historical wrong verifikat are corrected only via the
existing storno runbook (docs/SETTLEMENT_ACCOUNT_REMEDIATION.md).

Fixes #1722

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

* test: align duplicate-guard mock queue with combined pre-FY and settlement-fallback lookups

The merge of main (PR #1828) into this branch combined two changes that each
add one query to the categorize commit flow; the strictly ordered queued mock
in the allow_duplicate test needed the cash_accounts listing entry inserted
between the period lookup and the pre-FY clamp lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:26:08 +02:00

242 lines
8.6 KiB
TypeScript

import { NextResponse } from 'next/server'
import { z } from 'zod'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { buildMappingResultFromCategory, getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { buildTransactionEntryLines } from '@/lib/bookkeeping/transaction-entries'
import { getVatRate } from '@/lib/bookkeeping/vat-entries'
import type { EntityType, Transaction, TransactionCategory, VatTreatment } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// PATCH /api/pending-operations/[id]
//
// Edit-before-approve. Today only supports staged categorize_transaction
// operations: the user can pick a different category (and/or VAT treatment)
// before clicking Godkänn. We re-derive the booking via the same mapping
// engine the commit path uses so the preview the user approves equals the
// preview that gets posted.
//
// Other operation types return 400. As specialized editors land (e.g. edit
// invoice line items before send) they extend this dispatcher.
ensureInitialized()
const CATEGORIES = [
'income_services', 'income_products', 'income_other',
'expense_equipment', 'expense_software', 'expense_travel', 'expense_office',
'expense_marketing', 'expense_professional_services', 'expense_education',
'expense_representation', 'expense_consumables', 'expense_vehicle',
'expense_telecom', 'expense_bank_fees', 'expense_card_fees',
'expense_currency_exchange', 'expense_other', 'private', 'uncategorized',
] as const satisfies readonly TransactionCategory[]
const VAT_TREATMENTS = [
'standard_25', 'reduced_12', 'reduced_6',
'reverse_charge', 'export', 'exempt',
] as const satisfies readonly VatTreatment[]
const PatchSchema = z
.object({
category: z.enum(CATEGORIES).optional(),
vat_treatment: z.enum(VAT_TREATMENTS).nullable().optional(),
// Underlag's actual VAT override (null clears it; omit to preserve)
vat_amount: z.number().min(0).nullable().optional(),
})
.refine(
(v) => v.category !== undefined || v.vat_treatment !== undefined || v.vat_amount !== undefined,
{ message: 'Nothing to update' },
)
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
'pending_operation.update',
async (request, { supabase, companyId, log }, { params }) => {
const { id } = await params
let body: z.infer<typeof PatchSchema>
try {
body = PatchSchema.parse(await request.json())
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? getUserErrorMessage(err) : 'Invalid body' },
{ status: 400 },
)
}
const { data: op } = await supabase
.from('pending_operations')
.select('id, company_id, operation_type, status, params, preview_data, title')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (!op) return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 })
if (op.status !== 'pending') {
return NextResponse.json(
{ error: `Operation already ${op.status}: cannot edit.` },
{ status: 409 },
)
}
if (op.operation_type !== 'categorize_transaction') {
return NextResponse.json(
{ error: `Editing ${op.operation_type} is not supported.` },
{ status: 400 },
)
}
const oldParams = (op.params as Record<string, unknown>) ?? {}
const newCategory =
body.category ?? (oldParams.category as TransactionCategory | undefined)
const newVatTreatment =
body.vat_treatment !== undefined
? (body.vat_treatment ?? undefined)
: (oldParams.vat_treatment as VatTreatment | undefined)
if (!newCategory) {
return NextResponse.json({ error: 'category is required' }, { status: 400 })
}
const txId = oldParams.transaction_id as string | undefined
if (!txId) {
return NextResponse.json(
{ error: 'Operation has no transaction_id; cannot re-derive.' },
{ status: 500 },
)
}
// Re-derive the preview using the same mapping engine the commit path uses.
const { data: tx } = await supabase
.from('transactions')
.select('*')
.eq('id', txId)
.eq('company_id', companyId)
.maybeSingle()
if (!tx) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
const entityType = ((settings?.entity_type as EntityType) || 'enskild_firma')
const isBusiness = newCategory !== 'private'
// Resolve whether the (possibly defaulted) treatment carries a rate-based
// VAT line: only then can a vat_amount override survive. An explicit
// override on a VAT-less treatment is a caller error; a preserved one from
// before the edit is simply stale and gets dropped.
const probe = getCategoryAccountMapping(
newCategory, (tx as Transaction).amount, isBusiness, entityType, newVatTreatment,
)
const carriesRateVat =
isBusiness &&
probe.vatTreatment !== null &&
probe.vatTreatment !== 'reverse_charge' &&
getVatRate(probe.vatTreatment as VatTreatment) > 0
let newVatAmount: number | null
if (body.vat_amount !== undefined) {
if (body.vat_amount !== null && !carriesRateVat) {
return NextResponse.json(
{ error: 'vat_amount kräver en momspliktig vat_treatment (standard_25, reduced_12 eller reduced_6).' },
{ status: 400 },
)
}
newVatAmount = body.vat_amount
} else {
const previous = typeof oldParams.vat_amount === 'number' ? oldParams.vat_amount : null
newVatAmount = carriesRateVat ? previous : null
}
let mapping
try {
mapping = buildMappingResultFromCategory(
newCategory,
tx as Transaction,
isBusiness,
entityType,
newVatTreatment,
newVatAmount,
)
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? getUserErrorMessage(err) : 'Ogiltig momsjustering' },
{ status: 400 },
)
}
try {
const settlementAccount = await resolveSettlementAccount(
supabase,
companyId,
(tx as Transaction).cash_account_id,
log,
(tx as Transaction).currency,
)
mapping = applySettlementAccount(mapping, settlementAccount)
} catch (err) {
log.error('pending-operation edit: settlement account lookup failed', err as Error, {
operationId: id,
transactionId: txId,
})
return NextResponse.json(
{ error: getUserErrorMessage(err) },
{ status: 500 },
)
}
if (!mapping.debit_account || !mapping.credit_account) {
return NextResponse.json(
{ error: `Inget kontomappning för kategorin "${newCategory}" (${entityType}).` },
{ status: 400 },
)
}
const oldPreview = (op.preview_data as Record<string, unknown>) ?? {}
const newPreview = {
...oldPreview,
debit_account: mapping.debit_account,
credit_account: mapping.credit_account,
amount: Math.abs((tx as Transaction).amount),
currency: (tx as Transaction).currency,
// Re-derive the exact journal lines (net cost line, VAT, gross bank):
// spreading oldPreview would otherwise leave stale lines from staging.
lines: buildTransactionEntryLines(tx as Transaction, mapping).map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
description: l.line_description ?? '',
})),
vat_lines: (mapping.vat_lines ?? []).map((v) => ({
account: v.account_number,
amount: v.debit_amount || v.credit_amount,
})),
category: newCategory,
}
const newParams = {
...oldParams,
category: newCategory,
vat_treatment: newVatTreatment ?? null,
vat_amount: newVatAmount,
}
const { data: updated, error } = await supabase
.from('pending_operations')
.update({ params: newParams, preview_data: newPreview })
.eq('id', id)
.eq('company_id', companyId)
.select('id, params, preview_data, title, status')
.single()
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data: updated })
},
{ requireWrite: true },
)