* 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>
242 lines
8.6 KiB
TypeScript
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 },
|
|
)
|