* fix(bookkeeping): honor underlag VAT via vat_amount override in categorize flow
The categorize flow always derived VAT as rate × gross/(1+rate) from the
transaction amount, with no way to use the underlag's actual moms. On e.g.
a restaurant receipt with dricks (no VAT on the tip), the agent could see
the document's correct VAT but the staged booking recomputed the wrong
rate-based amount on every attempt.
- buildMappingResultFromCategory: optional vatAmountOverride replaces the
rate-derived VAT line ("Ingående/Utgående moms (enligt underlag)"; 0 =
no VAT line). Rejects negatives, amounts above the 25%-extraction bound,
and combination with reverse_charge / VAT-less treatments / private.
- gnubok_categorize_transaction: new vat_amount input, threaded into the
staged preview and persisted in the operation params.
- commitCategorizeTransaction: reads params.vat_amount so the approved
posting matches the staged preview exactly.
- PATCH /api/pending-operations/[id]: accepts vat_amount (null clears);
preserves a staged override across category edits while the treatment
still carries rate-based VAT, drops it when it no longer does.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review: guard order + agent guidance on vat_amount (PR #717 bots)
- Check treatment compatibility before the 25%-extraction bound so an
oversized override on reverse_charge reports the actual mistake (the
treatment), not the amount. Document why the typeof re-check stays:
commit-time params come from jsonb, so TS types don't hold at runtime.
- vat_amount property description now warns that foreign VAT is never
deductible as ingående moms and that a 0-moms document should use
vat_treatment="exempt" rather than vat_amount=0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mcp): tools/list payload budget + reject vat_amount 0
core-only failed: the verbose vat_amount descriptions pushed the projected
tools/list payload to 36,051 tokens (ceiling 36,000; main is at 35,862).
Per the guard's own guidance, trim descriptions instead of bumping:
now 35,943.
Folds in the Swedish review's round-2 point while trimming: vat_amount 0
is now rejected with a pointer to vat_treatment "exempt". A 0-moms
document is an exempt supply — "exempt" produces the identical expense
booking and the correct income account (3004), so 0 had no use case and
only created a silent momsdeklaration misclassification path. Schema
declares exclusiveMinimum: 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bookkeeping): use roundOre for vat_amount math (antipattern ratchet)
Second core-only failure: the naive-ore-round ratchet caught the new
Math.round(x*100)/100 lines (662 > baseline 661). Switch the override
path to roundOre from lib/money — including the pre-existing computed-VAT
line this PR touched — and ratchet the baseline down (659, raw-route-auth
168 locked in from main-side fixes).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
219 lines
7.4 KiB
TypeScript
219 lines
7.4 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { z } from 'zod'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { requireCompanyId } from '@/lib/company/context'
|
|
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
import { buildMappingResultFromCategory, getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
|
|
import { getVatRate } from '@/lib/bookkeeping/vat-entries'
|
|
import type { EntityType, Transaction, TransactionCategory, VatTreatment } from '@/types'
|
|
|
|
// 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 async function PATCH(
|
|
request: Request,
|
|
{ params }: { params: Promise<{ id: string }> },
|
|
) {
|
|
const supabase = await createClient()
|
|
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 { 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 ? err.message : '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 ? err.message : 'Ogiltig momsjustering' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
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,
|
|
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: error.message }, { status: 500 })
|
|
|
|
return NextResponse.json({ data: updated })
|
|
}
|