feat(reconciliation): residual booking + junction-aware bridge (#1862)
When the worksheet selection (N bank rows vs one verifikat) misses by a few kronor, 'Bokför mellanskillnaden som Bankavgift / Räntekostnad / Ränteintäkt / Öresavrundning och koppla' books the remainder on 6570 / 8410 / 8310 / 3740 against the bank account, links the rows to the main verifikat and anchors the residual verifikat through transaction_voucher_links. Bank accounts only (Skatteverket posts ränta and avgifter as rows of their own), capped at 5 000 kr, direction-checked against the kind; links are made first and undone if the booking is refused. Dashboard + v1 doors (transactions:write, Idempotency-Key, dry run), API skill regenerated. The bridge now treats transaction_voucher_links as links on both sides: migration 20260824190000 re-creates get_unlinked_gl_lines and get_account_gl_lines_for_matching to count junction-linked verifikat as matched (pg-real test), and the TS engine + items do the same for the transactions. This also stops bulk-booked samlingsverifikat from polluting the open buckets. 'Koppla bort' drops the junction rows too. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
0a3cb1a31a
commit
d88df74b85
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { AccountKeySchema } from '@/lib/reconciliation/schemas'
|
||||
import { bookResidualAndLink, ReconciliationResidualError } from '@/lib/reconciliation/residual'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ISO_DATE_RE } from '@/lib/invariants'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
|
||||
// Booking a residual commits a verifikat; the engine emits journal_entry
|
||||
// events that extension handlers subscribe to.
|
||||
ensureInitialized()
|
||||
|
||||
const ResidualBodySchema = z.object({
|
||||
external_ids: z.array(z.string().uuid()).min(1).max(50),
|
||||
journal_entry_id: z.string().uuid(),
|
||||
kind: z.enum(['bank_fee', 'rounding', 'interest_income', 'interest_expense']),
|
||||
entry_date: z.string().regex(ISO_DATE_RE).optional(),
|
||||
description: z.string().max(200).optional(),
|
||||
dry_run: z.boolean().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/reconciliation/accounts/{accountKey}/residual
|
||||
*
|
||||
* The worksheet's "bokför mellanskillnaden och koppla": books the remainder
|
||||
* of a selection (bank rows vs one verifikat) as a bank fee / interest /
|
||||
* rounding verifikat and links the selection. Bank accounts only.
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }>(
|
||||
'reconciliation.accounts.residual',
|
||||
async (request, { supabase, user, companyId }, { params }) => {
|
||||
const { accountKey } = await params
|
||||
if (!AccountKeySchema.safeParse(accountKey).success) {
|
||||
return NextResponse.json({ error: 'Okänt konto' }, { status: 404 })
|
||||
}
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Ogiltig JSON' }, { status: 400 })
|
||||
}
|
||||
const parsed = ResidualBodySchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Ogiltig body: external_ids, journal_entry_id och kind krävs' }, { status: 400 })
|
||||
}
|
||||
try {
|
||||
const result = await bookResidualAndLink(
|
||||
supabase,
|
||||
companyId,
|
||||
user.id,
|
||||
accountKey,
|
||||
{
|
||||
external_ids: parsed.data.external_ids,
|
||||
journal_entry_id: parsed.data.journal_entry_id,
|
||||
kind: parsed.data.kind,
|
||||
entry_date: parsed.data.entry_date,
|
||||
description: parsed.data.description,
|
||||
},
|
||||
{ dryRun: parsed.data.dry_run === true },
|
||||
)
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Okänt konto för det här företaget' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
if (err instanceof ReconciliationResidualError) {
|
||||
const status =
|
||||
err.code === 'RESIDUAL_ROWS_NOT_FOUND' || err.code === 'RESIDUAL_ENTRY_NOT_FOUND' ? 404 : 400
|
||||
return NextResponse.json({ error: getErrorMessage(err), code: err.code }, { status })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Tests for POST /api/reconciliation/accounts/{accountKey}/residual (cookie
|
||||
* session, withRouteContext). The residual engine is mocked; the wrapper is real.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
const residualMock = vi.fn()
|
||||
vi.mock('@/lib/reconciliation/residual', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/reconciliation/residual')>('@/lib/reconciliation/residual')
|
||||
return { ...actual, bookResidualAndLink: (...args: unknown[]) => residualMock(...args) }
|
||||
})
|
||||
|
||||
import { ReconciliationResidualError } from '@/lib/reconciliation/residual'
|
||||
import { POST } from '../[accountKey]/residual/route'
|
||||
|
||||
const CASH = '11111111-1111-4111-8111-111111111111'
|
||||
const KEY = `bank:${CASH}`
|
||||
const T1 = '22222222-2222-4222-8222-222222222222'
|
||||
const E1 = '44444444-4444-4444-8444-444444444444'
|
||||
const URL = `http://localhost/api/reconciliation/accounts/${KEY}/residual`
|
||||
const p = (obj: Record<string, string>) => ({ params: Promise.resolve(obj) }) as never
|
||||
const body = { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }
|
||||
|
||||
describe('POST /api/reconciliation/accounts/{accountKey}/residual', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
residualMock.mockResolvedValue({ dry_run: false, residual_journal_entry_id: 'res-1', residual_amount: -10, applied: [], skipped: [] })
|
||||
})
|
||||
|
||||
it('401 without a session', async () => {
|
||||
requireAuthMock.mockResolvedValue({ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) })
|
||||
const res = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('400 on a body without kind, 404 on a malformed key', async () => {
|
||||
const bad = await POST(createMockRequest(URL, { method: 'POST', body: { external_ids: [T1], journal_entry_id: E1 } }), p({ accountKey: KEY }))
|
||||
expect(bad.status).toBe(400)
|
||||
const badKey = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: '1930' }))
|
||||
expect(badKey.status).toBe(404)
|
||||
expect(residualMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books and links, forwarding dry_run', async () => {
|
||||
const res = await POST(createMockRequest(URL, { method: 'POST', body: { ...body, dry_run: true } }), p({ accountKey: KEY }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(residualMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
KEY,
|
||||
{ external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee', entry_date: undefined, description: undefined },
|
||||
{ dryRun: true },
|
||||
)
|
||||
const { body: out } = await parseJsonResponse<{ data: { residual_journal_entry_id: string } }>(res)
|
||||
expect(out.data.residual_journal_entry_id).toBe('res-1')
|
||||
})
|
||||
|
||||
it('maps refusals to 400 + code, missing rows to 404, unknown account to 404, and requires write', async () => {
|
||||
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('för stort', 'RESIDUAL_TOO_LARGE'))
|
||||
const refused = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
|
||||
expect(refused.status).toBe(400)
|
||||
expect((await parseJsonResponse<{ code: string }>(refused)).body.code).toBe('RESIDUAL_TOO_LARGE')
|
||||
|
||||
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('saknas', 'RESIDUAL_ROWS_NOT_FOUND'))
|
||||
const missingRows = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
|
||||
expect(missingRows.status).toBe(404)
|
||||
|
||||
residualMock.mockResolvedValueOnce(null)
|
||||
const unknown = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
|
||||
expect(unknown.status).toBe(404)
|
||||
|
||||
requireWriteMock.mockResolvedValue({ ok: false, response: NextResponse.json({ error: 'Läsbehörighet' }, { status: 403 }) })
|
||||
const forbidden = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
|
||||
expect(forbidden.status).toBe(403)
|
||||
})
|
||||
})
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/residual
|
||||
*
|
||||
* Book the remainder of a bank selection (N transactions vs one verifikat)
|
||||
* as a small verifikat (bank fee / interest / rounding) and link the
|
||||
* selection in the same call. Writes to the ledger: transactions:write, the
|
||||
* same scope that books a bank transaction. Dry-runnable; Idempotency-Key
|
||||
* required.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { AccountKeySchema } from '@/lib/reconciliation/schemas'
|
||||
import { bookResidualAndLink, ReconciliationResidualError, RESIDUAL_MAX_AMOUNT } from '@/lib/reconciliation/residual'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ISO_DATE_RE } from '@/lib/invariants'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
const ResidualRequest = z.object({
|
||||
external_ids: z.array(z.string().uuid()).min(1).max(50),
|
||||
journal_entry_id: z.string().uuid(),
|
||||
kind: z.enum(['bank_fee', 'rounding', 'interest_income', 'interest_expense']),
|
||||
entry_date: z.string().regex(ISO_DATE_RE).optional(),
|
||||
description: z.string().max(200).optional(),
|
||||
})
|
||||
|
||||
const LineSchema = z.object({ account_number: z.string(), debit_amount: z.number(), credit_amount: z.number() })
|
||||
|
||||
const ResidualResponse = z.object({
|
||||
dry_run: z.boolean(),
|
||||
residual_journal_entry_id: z.string().optional(),
|
||||
residual_amount: z.number().optional(),
|
||||
applied: z.array(z.object({ external_id: z.string(), journal_entry_id: z.string() })).optional(),
|
||||
skipped: z.array(z.object({ code: z.string(), message: z.string() })).optional(),
|
||||
would_book: z
|
||||
.object({
|
||||
kind: z.string(),
|
||||
counter_account: z.string(),
|
||||
ledger_account: z.string(),
|
||||
currency: z.string(),
|
||||
transactions_total: z.number(),
|
||||
entry_net: z.number(),
|
||||
residual_amount: z.number(),
|
||||
entry_date: z.string(),
|
||||
description: z.string(),
|
||||
lines: z.array(LineSchema),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'reconciliation.accounts.residual',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/reconciliation/accounts/:accountKey/residual',
|
||||
summary: 'Book the remainder of a bank selection as a fee/interest/rounding verifikat and link the selection.',
|
||||
description:
|
||||
`Body: { external_ids: [transaction ids], journal_entry_id, kind: "bank_fee" | "interest_expense" | "interest_income" | "rounding", entry_date?, description? }. Computes the difference between the transactions' sum and the verifikat's net on the bank account, books it on 6570 / 8410 / 8310 / 3740 against the bank account (dated on the latest transaction by default), links the transactions to the main verifikat and anchors the residual verifikat through transaction_voucher_links. Bank accounts only (bank:<cash_account_id>). Refused when the difference is 0 (RESIDUAL_ZERO), above ${RESIDUAL_MAX_AMOUNT} kr (RESIDUAL_TOO_LARGE: that is a missing booking, not a fee), or when the kind points the wrong way (RESIDUAL_DIRECTION). ?dry_run=true returns would_book without writing.`,
|
||||
useWhen:
|
||||
'A manual match misses by a small amount that is genuinely a bank fee, interest or rounding, and you want to close it in one step instead of booking a verifikat and then linking.',
|
||||
doNotUseFor:
|
||||
'Skattekonto rows (Skatteverket posts ränta and avgifter as their own rows: link them), or differences that are really a missing booking (book that properly).',
|
||||
pitfalls: [
|
||||
'The kind must match the direction: money that left the bank unbooked is bank_fee / interest_expense; money that arrived unbooked is interest_income; rounding works either way.',
|
||||
'Links are made before the booking and undone if the booking is refused (a locked period), so a refusal leaves nothing half done.',
|
||||
'Idempotency-Key is required; repeating the same key replays the first response.',
|
||||
],
|
||||
example: {
|
||||
request: { external_ids: ['22222222-2222-4222-8222-222222222222'], journal_entry_id: '44444444-4444-4444-8444-444444444444', kind: 'bank_fee' },
|
||||
response: {
|
||||
data: {
|
||||
dry_run: false,
|
||||
residual_journal_entry_id: '55555555-5555-4555-8555-555555555555',
|
||||
residual_amount: -10,
|
||||
applied: [{ external_id: '22222222-2222-4222-8222-222222222222', journal_entry_id: '44444444-4444-4444-8444-444444444444' }],
|
||||
skipped: [],
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'transactions:write',
|
||||
risk: 'medium',
|
||||
idempotent: false,
|
||||
reversible: false,
|
||||
dryRunSupported: true,
|
||||
request: { body: ResidualRequest },
|
||||
response: { success: dataEnvelope(ResidualResponse) },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey: string }> }>(
|
||||
'reconciliation.accounts.residual',
|
||||
async (request, ctx, params) => {
|
||||
const { accountKey } = await params.params
|
||||
if (!AccountKeySchema.safeParse(accountKey).success) {
|
||||
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'accountKey', message: 'Okänt konto.' },
|
||||
})
|
||||
}
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
const parsed = ResidualRequest.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
|
||||
})
|
||||
}
|
||||
try {
|
||||
const result = await bookResidualAndLink(ctx.supabase, ctx.companyId!, ctx.userId, accountKey, parsed.data, {
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
if (!result) {
|
||||
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'accountKey', message: 'Okänt konto för det här företaget.' },
|
||||
})
|
||||
}
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(result, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return ok(result, { requestId: ctx.requestId })
|
||||
} catch (err) {
|
||||
if (err instanceof ReconciliationResidualError) {
|
||||
const v1Code =
|
||||
err.code === 'RESIDUAL_ROWS_NOT_FOUND' || err.code === 'RESIDUAL_ENTRY_NOT_FOUND' ? 'NOT_FOUND' : 'VALIDATION_ERROR'
|
||||
return v1ErrorResponseFromCode(v1Code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { code: err.code, message: getErrorMessage(err) },
|
||||
})
|
||||
}
|
||||
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Tests for POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/residual:
|
||||
* real withApiV1 wrapper (auth, scope, membership, idempotency, dry-run), engine mocked.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
|
||||
})
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
const { residualMock } = vi.hoisted(() => ({ residualMock: vi.fn() }))
|
||||
vi.mock('@/lib/reconciliation/residual', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/reconciliation/residual')>('@/lib/reconciliation/residual')
|
||||
return { ...actual, bookResidualAndLink: residualMock }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { ReconciliationResidualError } from '@/lib/reconciliation/residual'
|
||||
import { POST } from '../[accountKey]/residual/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
|
||||
const queues = new Map<string, MockResult[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const CASH = '11111111-1111-4111-8111-111111111111'
|
||||
const KEY = `bank:${CASH}`
|
||||
const T1 = '22222222-2222-4222-8222-222222222222'
|
||||
const E1 = '44444444-4444-4444-8444-444444444444'
|
||||
const URL = `http://localhost/api/v1/companies/${COMPANY_ID}/reconciliation/accounts/${KEY}/residual`
|
||||
const body = { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }
|
||||
|
||||
function req(init: { body?: unknown; idem?: boolean; dryRun?: boolean } = {}): Request {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (init.idem !== false) headers['Idempotency-Key'] = `idem-${Math.random().toString(36).slice(2)}-aaaa-4abc-8def-1234567890ab`
|
||||
if (init.dryRun) headers['X-Dry-Run'] = 'true'
|
||||
return new Request(URL, { method: 'POST', headers, body: init.body !== undefined ? JSON.stringify(init.body) : undefined })
|
||||
}
|
||||
|
||||
function authOk(scopes: string[]) {
|
||||
mockValidate.mockResolvedValue({ valid: true, userId: 'user-1', keyId: 'key-1', keyName: 'Test key', scopes, mode: 'live' })
|
||||
}
|
||||
|
||||
const params = (accountKey = KEY) => ({ params: Promise.resolve({ companyId: COMPANY_ID, accountKey }) }) as never
|
||||
|
||||
describe('v1 reconciliation residual', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({ company_members: { data: { role: 'owner' } }, idempotency_keys: { data: null } }))
|
||||
residualMock.mockResolvedValue({ dry_run: false, residual_journal_entry_id: 'res-1', residual_amount: -10, applied: [{ external_id: T1, journal_entry_id: E1 }], skipped: [] })
|
||||
})
|
||||
|
||||
it('401 without a valid key; 403 without transactions:write', async () => {
|
||||
mockValidate.mockResolvedValue({ valid: false, error: 'invalid' })
|
||||
expect((await POST(req({ body }), params())).status).toBe(401)
|
||||
authOk(['reconciliation:write'])
|
||||
expect((await POST(req({ body }), params())).status).toBe(403)
|
||||
})
|
||||
|
||||
it('needs an Idempotency-Key, validates the body, 404s a bad key', async () => {
|
||||
authOk(['transactions:write'])
|
||||
expect((await POST(req({ body, idem: false }), params())).status).toBe(400)
|
||||
expect((await POST(req({ body: { external_ids: [T1] } }), params())).status).toBe(400)
|
||||
expect((await POST(req({ body }), params('1930'))).status).toBe(404)
|
||||
expect(residualMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books and links, and previews on dry run', async () => {
|
||||
authOk(['transactions:write'])
|
||||
const res = await POST(req({ body }), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json()).data.residual_journal_entry_id).toBe('res-1')
|
||||
expect(residualMock).toHaveBeenCalledWith(expect.anything(), COMPANY_ID, 'user-1', KEY, body, { dryRun: false })
|
||||
|
||||
residualMock.mockResolvedValue({ dry_run: true, would_book: { residual_amount: -10 } })
|
||||
const dry = await POST(req({ body, dryRun: true }), params())
|
||||
expect(dry.status).toBe(200)
|
||||
expect(residualMock).toHaveBeenLastCalledWith(expect.anything(), COMPANY_ID, 'user-1', KEY, body, { dryRun: true })
|
||||
})
|
||||
|
||||
it('maps refusals to VALIDATION_ERROR with the residual code, and missing rows to NOT_FOUND', async () => {
|
||||
authOk(['transactions:write'])
|
||||
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('fel riktning', 'RESIDUAL_DIRECTION'))
|
||||
const refused = await POST(req({ body }), params())
|
||||
expect(refused.status).toBe(400)
|
||||
const out = await refused.json()
|
||||
expect(out.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(out.error.details.code).toBe('RESIDUAL_DIRECTION')
|
||||
|
||||
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('saknas', 'RESIDUAL_ENTRY_NOT_FOUND'))
|
||||
expect((await POST(req({ body }), params())).status).toBe(404)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user