One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553
This commit is contained in:
@@ -8,6 +8,13 @@ import { matchPairs } from '@/lib/reconciliation/actions'
|
||||
const PairSchema = z.object({
|
||||
external_ids: z.array(z.string().uuid()).min(1).max(50),
|
||||
journal_entry_ids: z.array(z.string().uuid()).min(1).max(50),
|
||||
// Bank 1:N only: the signed slice per verifikat (transaction sign
|
||||
// convention). Omitted: each slice defaults to the voucher's bank line.
|
||||
allocations: z
|
||||
.array(z.object({ journal_entry_id: z.string().uuid(), amount: z.number() }))
|
||||
.min(2)
|
||||
.max(50)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const ReconciliationLinksBodySchema = z
|
||||
@@ -25,8 +32,10 @@ const ReconciliationLinksBodySchema = z
|
||||
* POST /api/reconciliation/accounts/{accountKey}/links
|
||||
*
|
||||
* The page's "Koppla" and "Koppla N föreslagna": link outside rows to existing
|
||||
* verifikat. A human clicked, so this applies directly (dry_run: true for the
|
||||
* preview). Same service function as v1 and the MCP commit executor.
|
||||
* verifikat (N:1), or one bank transaction to several verifikat (1:N, with
|
||||
* optional allocations). A human clicked, so this applies directly (dry_run:
|
||||
* true for the preview). Same service function as v1 and the MCP commit
|
||||
* executor.
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }>(
|
||||
'reconciliation.accounts.links.create',
|
||||
|
||||
@@ -53,6 +53,8 @@ import { POST as ignorePOST } from '../[accountKey]/items/[itemId]/ignore/route'
|
||||
|
||||
const ROW = '22222222-2222-4222-8222-222222222222'
|
||||
const ENTRY = '33333333-3333-4333-8333-333333333333'
|
||||
const ENTRY_2 = '44444444-4444-4444-8444-444444444444'
|
||||
const CASH = '55555555-5555-4555-8555-555555555555'
|
||||
// Dynamic-route params for the handlers under test; `never` keeps each
|
||||
// handler's own params type while letting one helper serve all of them.
|
||||
const p = (obj: Record<string, string>) => ({ params: Promise.resolve(obj) }) as never
|
||||
@@ -144,6 +146,33 @@ describe('dashboard reconciliation routes', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('links: forwards a bank 1:N pair with allocations, and rejects a one-element allocations array (#1553)', async () => {
|
||||
matchMock.mockResolvedValue({ dry_run: false, considered: 1, applied: [], skipped: [] })
|
||||
const pair = {
|
||||
external_ids: [ROW],
|
||||
journal_entry_ids: [ENTRY, ENTRY_2],
|
||||
allocations: [
|
||||
{ journal_entry_id: ENTRY, amount: -500 },
|
||||
{ journal_entry_id: ENTRY_2, amount: -300 },
|
||||
],
|
||||
}
|
||||
const res = await linksPOST(
|
||||
createMockRequest(`/api/reconciliation/accounts/bank:${CASH}/links`, { method: 'POST', body: { pairs: [pair] } }),
|
||||
p({ accountKey: `bank:${CASH}` }),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(matchMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', `bank:${CASH}`, { pairs: [pair] }, { dryRun: false })
|
||||
|
||||
const invalid = await linksPOST(
|
||||
createMockRequest(`/api/reconciliation/accounts/bank:${CASH}/links`, {
|
||||
method: 'POST',
|
||||
body: { pairs: [{ ...pair, allocations: [{ journal_entry_id: ENTRY, amount: -800 }] }] },
|
||||
}),
|
||||
p({ accountKey: `bank:${CASH}` }),
|
||||
)
|
||||
expect(invalid.status).toBe(400)
|
||||
})
|
||||
|
||||
it('unlink and ignore call the service with the ids', async () => {
|
||||
const del = await linkDELETE(createMockRequest(`/api/reconciliation/accounts/skattekonto/links/${ROW}`, { method: 'DELETE' }), p({ accountKey: 'skattekonto', linkId: ROW }))
|
||||
expect(del.status).toBe(200)
|
||||
|
||||
@@ -29,8 +29,10 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
const manualLinkMock = vi.fn()
|
||||
const linkToVouchersMock = vi.fn()
|
||||
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
|
||||
manualLink: (...args: unknown[]) => manualLinkMock(...args),
|
||||
linkTransactionToVouchers: (...args: unknown[]) => linkToVouchersMock(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
@@ -38,6 +40,7 @@ import { POST } from '../route'
|
||||
const emptyParams = { params: Promise.resolve({}) }
|
||||
const TX_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const JE_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const JE_ID_2 = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
describe('POST /api/reconciliation/bank/link', () => {
|
||||
beforeEach(() => {
|
||||
@@ -46,6 +49,7 @@ describe('POST /api/reconciliation/bank/link', () => {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
manualLinkMock.mockResolvedValue({ success: true })
|
||||
linkToVouchersMock.mockResolvedValue({ success: true, allocations: [] })
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
@@ -128,4 +132,102 @@ describe('POST /api/reconciliation/bank/link', () => {
|
||||
'1930',
|
||||
)
|
||||
})
|
||||
|
||||
it('splits the transaction over several verifikat when allocations are given instead of journal_entry_id (1:N, #1553)', async () => {
|
||||
linkToVouchersMock.mockResolvedValue({
|
||||
success: true,
|
||||
allocations: [
|
||||
{ journal_entry_id: JE_ID, amount: -500 },
|
||||
{ journal_entry_id: JE_ID_2, amount: -300 },
|
||||
],
|
||||
})
|
||||
const request = createMockRequest('/api/reconciliation/bank/link', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
transaction_id: TX_ID,
|
||||
account_number: '1940',
|
||||
allocations: [
|
||||
{ journal_entry_id: JE_ID, amount: -500 },
|
||||
{ journal_entry_id: JE_ID_2, amount: -300 },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(request, emptyParams)
|
||||
const { status, body } = await parseJsonResponse<{ data: { success: boolean; allocations: unknown[] } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.success).toBe(true)
|
||||
expect(body.data.allocations).toHaveLength(2)
|
||||
expect(linkToVouchersMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
'company-1',
|
||||
TX_ID,
|
||||
[
|
||||
{ journal_entry_id: JE_ID, amount: -500 },
|
||||
{ journal_entry_id: JE_ID_2, amount: -300 },
|
||||
],
|
||||
'user-1',
|
||||
'1940',
|
||||
)
|
||||
expect(manualLinkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a body with both journal_entry_id and allocations, with neither, or with a single allocation (400)', async () => {
|
||||
const both = await POST(
|
||||
createMockRequest('/api/reconciliation/bank/link', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
transaction_id: TX_ID,
|
||||
journal_entry_id: JE_ID,
|
||||
allocations: [
|
||||
{ journal_entry_id: JE_ID, amount: -500 },
|
||||
{ journal_entry_id: JE_ID_2, amount: -300 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
emptyParams,
|
||||
)
|
||||
expect(both.status).toBe(400)
|
||||
|
||||
const neither = await POST(
|
||||
createMockRequest('/api/reconciliation/bank/link', { method: 'POST', body: { transaction_id: TX_ID } }),
|
||||
emptyParams,
|
||||
)
|
||||
expect(neither.status).toBe(400)
|
||||
|
||||
const single = await POST(
|
||||
createMockRequest('/api/reconciliation/bank/link', {
|
||||
method: 'POST',
|
||||
body: { transaction_id: TX_ID, allocations: [{ journal_entry_id: JE_ID, amount: -800 }] },
|
||||
}),
|
||||
emptyParams,
|
||||
)
|
||||
expect(single.status).toBe(400)
|
||||
expect(manualLinkMock).not.toHaveBeenCalled()
|
||||
expect(linkToVouchersMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a refused split as 400 with the service error', async () => {
|
||||
linkToVouchersMock.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Fördelningen (-700) stämmer inte med transaktionens belopp (-800).',
|
||||
})
|
||||
const response = await POST(
|
||||
createMockRequest('/api/reconciliation/bank/link', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
transaction_id: TX_ID,
|
||||
allocations: [
|
||||
{ journal_entry_id: JE_ID, amount: -400 },
|
||||
{ journal_entry_id: JE_ID_2, amount: -300 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
emptyParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Fördelningen (-700) stämmer inte med transaktionens belopp (-800).')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { linkTransactionToVouchers, manualLink } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { BankLinkSchema } from '@/lib/api/schemas'
|
||||
|
||||
@@ -12,22 +12,31 @@ export const POST = withRouteContext(
|
||||
async (request, { supabase, user, companyId }) => {
|
||||
const validation = await validateBody(request, BankLinkSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { transaction_id, journal_entry_id, account_number } = validation.data
|
||||
const { transaction_id, journal_entry_id, allocations, account_number } = validation.data
|
||||
|
||||
const result = await manualLink(
|
||||
supabase,
|
||||
companyId,
|
||||
transaction_id,
|
||||
journal_entry_id,
|
||||
user.id,
|
||||
account_number ?? '1930',
|
||||
)
|
||||
// One verifikat: the plain link. Several: the 1:N split (#1553), all or
|
||||
// nothing, slices summing to the transaction.
|
||||
const result = journal_entry_id
|
||||
? await manualLink(supabase, companyId, transaction_id, journal_entry_id, user.id, account_number ?? '1930')
|
||||
: await linkTransactionToVouchers(
|
||||
supabase,
|
||||
companyId,
|
||||
transaction_id,
|
||||
allocations ?? [],
|
||||
user.id,
|
||||
account_number ?? '1930',
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
// A split echoes the slices as validated (defaults resolved); the plain
|
||||
// link has nothing to add.
|
||||
const resolvedAllocations = 'allocations' in result ? result.allocations : undefined
|
||||
return NextResponse.json({
|
||||
data: { success: true, ...(resolvedAllocations ? { allocations: resolvedAllocations } : {}) },
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user