Files
accounted/app/api/pending-operations/[id]/reject/route.ts
T
Jakob WennbergandClaude Opus 4.8 e5f1d7a916 fix(reports): count reversed entries in bank/supplier/AR reconciliation (#734)
A reversed (storno-corrected) journal entry must be summed together with
its storno + correction, exactly as the trial balance and balance sheet
already do (.in('status', ['posted','reversed'])). The reconciliation
paths used posted-only and manufactured phantom differences.

- bank-reconciliation getReconciliationStatus: gl_1930_balance now counts
  posted+reversed (so it equals the balansräkning for the account). Dropped
  the correction_adjustment subtraction and the reversed-linked-tx drop — a
  corrected/amount-corrected bank receipt now reconciles. The prior model
  broke once correctEntry began re-pointing the bank transaction to the live
  correction (the two changes were mutually inconsistent and produced a
  difference equal to the corrected amount).
- supplier-reconciliation (2440) and ar-reconciliation (1510/1513): same
  posted -> posted+reversed fix. Removes the false "Ej avstämd" gap a fully
  paid, fully corrected company shows — the books net to 0 over posted+reversed
  while a posted-only query double-counted the payment legs.

Also in this change:
- MCP gnubok_get_reconciliation_status: add account_number param (was hardcoded
  to 1930; the lib already supported per-account reconciliation).
- counterparty-templates normalizeCounterpartyName: strip trailing month /
  personal-initials tokens so "ngrok JW" / "Ngrok Mars" learn as one merchant.
- pending-operation reject 409 (route + MCP tool): clarify a resolved op was
  approved explicitly (no auto-commit path exists) instead of a bare
  "already committed".
- create_voucher: normalize description with String().trim() for consistency
  with line_description.
- migration 20260625120000: backfill stripped diacritics on ~830 companies'
  seeded chart-of-accounts names (Foretagskonto -> Företagskonto, etc.); the
  seed function was fixed for new companies in 20260516130000 but never
  backfilled. UPDATE-only and idempotent. Already applied to production.

Tests: 899 passing across the touched suites; lint + typecheck clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:46:41 +02:00

111 lines
4.1 KiB
TypeScript

import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
const RejectBodySchema = z.object({
rejection_category: z
.enum(['wrong_category', 'wrong_amount', 'duplicate', 'wrong_period', 'other'])
.optional(),
rejection_reason: z.string().max(2000).optional(),
})
/**
* POST /api/pending-operations/:id/reject
*
* Reject a pending operation. Optionally accepts a JSON body with
* `rejection_category` (fixed enum) and `rejection_reason` (free text). Both
* are stored on the row so agents can fetch them via gnubok_get_recent_rejections
* and learn from "no". The body is optional — bodyless POSTs from older
* clients still mark the op rejected with NULL category/reason.
*/
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)
// Body is optional — accept empty/missing body without rejecting the request.
// Old clients posted no body; the UI dialog will now post a body, but we
// keep accepting both shapes to avoid coupling the API to the UI version.
let rejectionCategory: string | undefined
let rejectionReason: string | undefined
const contentLength = request.headers.get('content-length')
if (contentLength && contentLength !== '0') {
try {
const raw = await request.json()
const parsed = RejectBodySchema.safeParse(raw)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join('; ') },
{ status: 400 },
)
}
rejectionCategory = parsed.data.rejection_category
rejectionReason = parsed.data.rejection_reason?.trim() || undefined
} catch {
// Body present but unparseable — fail closed.
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
}
const { data: op, error: fetchError } = await supabase
.from('pending_operations')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !op) {
return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 })
}
if (op.status !== 'pending') {
// There is no auto-commit path (removed in 20260505190027), so a non-pending
// status here means the op was resolved explicitly — almost always the user
// pressed Godkänn in the /pending (Att göra) UI in parallel, or another
// client already rejected it. Spell that out so an agent doesn't read the
// generic 409 as "the system committed it behind my back".
const explained =
op.status === 'rejected'
? 'Operation already rejected.'
: op.status === 'expired'
? 'Operation already expired and can no longer be rejected.'
: `Operation already ${op.status} — it was approved explicitly (most likely via the ` +
'Att göra / pending UI in parallel), not auto-committed. It can no longer be rejected; ' +
'reverse or correct the resulting verifikat instead.'
return NextResponse.json(
{ error: explained, status: op.status },
{ status: 409 }
)
}
const { error: updateError } = await supabase
.from('pending_operations')
.update({
status: 'rejected',
resolved_at: new Date().toISOString(),
...(rejectionCategory ? { rejection_category: rejectionCategory } : {}),
...(rejectionReason ? { rejection_reason: rejectionReason } : {}),
})
.eq('id', id)
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
return NextResponse.json({ data: { id, status: 'rejected' } })
}